Activation SDK — Expo (CNG) Integration Guide (iOS)
This guide covers integrating the Activation SDK into a React Native application on iOS using Expo with Continuous Native Generation (CNG). Unlike a bare React Native setup where you manually open Xcode and add Swift Package dependencies, edit AppDelegate.swift, and tweak Info.plist, CNG automates all of this through an Expo config plugin that runs at prebuild time.
Quick Start
If you want to get up and running fast, here's the minimum path:
- Set up environment variables — Add
IOS_BLINK_LICENSE_KEY,IOS_PROD_INTEL_KEY, andGAD_APP_IDto your.envfile (Section 3) - Configure the plugin — Set your license keys, GAM App ID, reward currency, and SDK version in
app.config.js(Section 5.5) - Create the local module — Scaffold
modules/blink-engage/in your project using the file-by-file instructions in Section 4 (manifests, podspec, native Swift files, TypeScript bridge). Long files live in the companionios-react-native-additional-code-snippets.md. Then add"blink-engage": "file:./modules/blink-engage"to your rootpackage.jsonand runnpm install - Run prebuild —
npx expo prebuild --platform iosto regenerateios/, run all plugins, and inject SPM + AppDelegate code (Section 15) - Set user identity — Call
setUser()after login with at least an email or phone (Section 8) - Render the offer wall — Either
<OfferWallView style={{ flex: 1 }} />(embedded) orawait showOfferWall()(modal) (Section 8) - Listen for rewards — Subscribe with
addRewardListener()to show earned rewards in real time (Section 9) - Set up webhooks — Configure your backend to receive
RewardUpdateevents for authoritative reward tracking (Section 10)
The rest of this guide provides the full architecture, implementation details, and reference material.
Table of Contents
- Architecture Overview
- How the iOS Pieces Fit Together
- Prerequisites
- Project Structure
- Expo Config Plugin — Automated Native Setup
- Native Module (iOS)
- JavaScript Bridge (Expo Modules API)
- Usage in React Native
- Handling Rewards (On-Device)
- Server-Side Rewards (Webhooks) (Backend)
- Receipt Validation
- Missed Earnings (Backend)
- Theme System
- Cleanup & Teardown
- Building & Prebuilding
- Configuration Reference
- Integration Checklist
- Troubleshooting
1. Architecture Overview
In a bare React Native project, you would manually:
- Open Xcode and add
BlinkEngageas a Swift Package dependency - Edit
Info.plistto addGADApplicationIdentifierand camera/photo usage strings - Edit
AppDelegate.swiftto import the SDKs, callBRScanManager.shared().licenseKey = …, configureBlinkEngageSDK.shared, install arewardCallback, and startMobileAds - Create
xcassetsimagesets for any custom theme icons - Create a Swift module conforming to
Moduleand register a native view - Hook the module up in your bridging header / Expo module config
With Expo + CNG, all of this is automated:
app.config.js ← Plugin props (license keys, GAM360 ID, etc.)
│
▼
blink-engage/plugin/src/index.ts ← Config plugin (runs at prebuild)
│
├─► project.pbxproj ← Adds SPM package "BlinkEngage" (6 sections)
├─► Info.plist ← Sets GADApplicationIdentifier
├─► AppDelegate.swift ← Injects BRScanManager + BlinkEngageSDK setup
└─► Images.xcassets ← Creates imagesets for theme icons
│
▼
modules/blink-engage/ios/ ← Expo native module (Swift)
├── BlinkEngageModule.swift ← Module definition (events, functions, view)
├── OfferWallView.swift ← Embedded ExpoView wrapping OffersWallViewController
├── JSBackedTheme.swift ← Theme implementation driven from JS via setTheme()
├── BRScanResultsSerializer.swift ← BRScanResults → [String: Any] for JS
└── BlinkEngageExpoModule.podspec ← Module's pod (with framework search paths)
│
▼
modules/blink-engage/src/ ← TypeScript bridge (Expo Modules API)
├── index.ts ← Public API (setUser, showOfferWall, setTheme, …)
├── BlinkEngageModule.ts ← requireNativeModule('BlinkEngage')
├── OfferWallView.tsx ← requireNativeViewManager('BlinkEngage')
├── theme.ts ← Theme types + key tables
├── setTheme.ts ← JS theme bridge (resolves icons via expo-asset)
└── types.ts ← TypeScript interfaces
Key things to know up front:
- The Activation/BlinkEngage SDK is integrated via SPM (Swift Package Manager). The plugin writes the SPM entries directly into
project.pbxprojso the package is re-resolved on every clean prebuild. A CocoaPods distribution exists too, but it's in sunset mode — bug fixes only, no new features, and it will be retired. New integrations should use SPM, which is what this guide covers. - The Expo module itself is still a CocoaPods pod (
BlinkEngageExpoModule) — that's how Expo modules are linked. It declares no SDK dependencies of its own; it just importsBlinkEngage/BlinkReceipt/GoogleMobileAdsand relies on the SPM products being on the framework search path. - Rewards flow through
NotificationCenter, not via direct callbacks. The AppDelegate-installedrewardCallbackpostsBlinkEngageRewardnotifications; the module observes them and re-emits as the JSonRewardevent. - Theme is JS-driven. The native
JSBackedThemesingleton holds three dictionaries; callingsetTheme(...)from JS atomically replaces them. Unset keys fall back to SDK defaults.
2. How the iOS Pieces Fit Together
There are five pieces working together at runtime. Knowing how they connect makes the rest of the guide much easier to read.
┌──────────────────────── Your React Native app ────────────────────────┐
│ │
│ await BlinkEngage.setUser(...) ← src/index.ts (TypeScript) │
│ await BlinkEngage.setTheme(...) │
│ <OfferWallView style={{ flex: 1 }} /> ← src/OfferWallView.tsx │
│ BlinkEngage.addRewardListener(...) │
│ │
└─────────────────────────────────┬─────────────────────────────────────┘
│ JSI / Expo Modules bridge
▼
┌──────────────── modules/blink-engage/ios (Swift) ──────────────────┐
│ │
│ BlinkEngageModule.swift │
│ ├── Function("setUser") → BlinkEngageSDK.shared.user.*Hash │
│ ├── AsyncFunction("setTheme") → JSBackedTheme.shared.apply(...) │
│ ├── AsyncFunction("showOfferWall") → presents modal │
│ ├── View(OfferWallView.self) ─┐ │
│ └── Events("onReward") │ │
│ │ │
│ OfferWallView.swift ───────────┘ │
│ └── embeds OffersWallViewController as a child VC │
│ │
│ JSBackedTheme.swift │
│ └── implements Theme; installed once via │
│ BlinkEngageSDK.shared.appearance = Appearance(theme: ...) │
│ │
└────────────────────────────┬───────────────────────────────────────┘
│ imports BlinkEngage / BlinkReceipt / GoogleMobileAds
▼
┌──────────── BlinkEngage SDK + BlinkReceipt + Google Mobile Ads ─────┐
│ │
│ Initialized in AppDelegate.swift (injected by the config plugin): │
│ • BlinkEngageSDK.start(debugMode:) (1.4.0+, one-shot) │
│ • BRScanManager.shared().licenseKey │
│ • BRScanManager.shared().enableBlinkEngage = true │
│ • BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig( │
│ currencyName: …, currencyPerDollar: …, userPayoutPercentage: │
│ …, currencyImage: …, │
│ rewardCallback: { context, rewardAmount, blinkReceiptId in │
│ NotificationCenter.default.post(name: "BlinkEngageReward") │
│ }) │
│ • MobileAds.shared.start(...) │
│ │
└──────────────────────────────────────────────┬──────────────────────┘
│ rewardCallback fires
▼
NotificationCenter → BlinkEngageModule
observer → sendEvent("onReward")
→ addRewardListener in JS
The interesting bit on iOS — and the main source of confusion for first-time integrators — is that rewards flow through NotificationCenter, not via a direct delegate. The reason: the rewardCallback lives in AppDelegate, which is created by the React Native runtime, while BlinkEngageModule instances are created later by Expo's module system and may even be re-created across reloads. A static notification observer in the module decouples the two.
3. Prerequisites
- Expo SDK 55+ (React Native 0.83+)
- iOS deployment target 13.4 or later (set by the module's podspec)
- Xcode 15+ (the SPM integration uses 15+ behavior — products live in
PackageFrameworks/) - A valid BlinkReceipt / Activation SDK license key (provided by Actual)
- A Google Ad Manager (GAM) account with an iOS Application ID
- An HTTPS server endpoint with a valid SSL certificate to receive webhook POST requests (see Section 10)
- Environment variables configured in
.env:IOS_BLINK_LICENSE_KEY— BlinkReceipt license keyIOS_PROD_INTEL_KEY— BlinkReceipt Product Intelligence key (optional, enables PI features)GAD_APP_ID— Google Ad Manager 360 iOS Application ID
- Camera and photo-library usage strings in
app.config.js(the plugin does not inject these for you — see Section 5.5)
4. Project Structure
How it's distributed. The SDK ships in two pieces:
- The native Swift Package (
BlinkEngage), pulled fromhttps://github.com/BlinkReceipt/blinkengage-ios— the config plugin pins it by exact version and Xcode resolves the rest of the graph (BlinkReceipt,GoogleMobileAds,GoogleUserMessagingPlatform).- The integration glue — a local Expo module under
modules/blink-engage/that you create in your project. It ties the Swift Package to React Native (a config plugin, a few Swift files, a TypeScript bridge). This guide tells you exactly which files to create and what to put in them. Anything too long to inline lives in the companionios-react-native-additional-code-snippets.md.The integration module is not published to npm — it's project-local and linked via
"blink-engage": "file:./modules/blink-engage".
The integration module lives as a local Expo module under your project root:
modules/blink-engage/
├── package.json # Module package — linked from root
├── expo-module.config.json # Tells Expo which native classes to load
├── plugin/
│ ├── package.json # Plugin package — build tooling
│ ├── tsconfig.json # TypeScript config for plugin compilation
│ └── src/
│ └── index.ts # Config plugin (prebuild-time native setup)
├── src/ # TypeScript bridge
│ ├── index.ts # Public API
│ ├── BlinkEngageModule.ts # Native module loader
│ ├── OfferWallView.tsx # Native view wrapper
│ ├── theme.ts # Theme key tables + types
│ ├── setTheme.ts # JS theme bridge
│ ├── themeValidation.ts # Drops unknown keys / invalid values
│ └── types.ts # Type definitions
└── ios/
├── BlinkEngageExpoModule.podspec ← Expo module pod (depends on ExpoModulesCore)
├── BlinkEngageModule.swift ← Module definition (JS ↔ native bridge)
├── OfferWallView.swift ← Embedded ExpoView host for OffersWallViewController
├── JSBackedTheme.swift ← Theme implementation driven by JS
└── BRScanResultsSerializer.swift ← BRScanResults → [String: Any] for JS
Module package.json
Create file:
modules/blink-engage/package.json
This defines the local Expo module as an npm package. The "main" and "exports" fields point to the TypeScript source — Expo's module system handles compilation at build time:
{
"name": "blink-engage",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./plugin": "./plugin/build/index.js"
},
"scripts": {
"build": "expo-module build",
"prepare": "cd plugin && npm ci && npm run build"
},
"peerDependencies": {
"expo": "*",
"expo-asset": "*",
"react": "*",
"react-native": "*"
},
"devDependencies": {
"expo-module-scripts": "^4.0.0"
}
}
Key things to note:
"exports"has two entries:"."for the runtime JS code, and"./plugin"for the config plugin (used byexpo prebuild)"prepare"script builds the config plugin automatically when you runnpm installexpo-assetis a peer dependency becausesetTheme()uses it to resolverequire('./icon.png')to a filesystem path before crossing the bridgepeerDependenciesare satisfied by your app — no need to install them separately
Linking the Module
In package.json at the project root, add the module as a local dependency:
{
"dependencies": {
"blink-engage": "file:./modules/blink-engage"
}
}
Then run npm install — this links the local module and triggers its prepare script (which builds the config plugin).
Plugin package.json and tsconfig.json
Create file:
modules/blink-engage/plugin/package.json
The config plugin is a separate TypeScript package that compiles to CommonJS (required by Expo's prebuild system):
{
"name": "blink-engage-plugin",
"version": "1.0.0",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"prepare": "npm run build"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"expo": "*"
}
}
Create file:
modules/blink-engage/plugin/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "./build",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
The plugin compiles from plugin/src/index.ts → plugin/build/index.js. This compiled output is what expo prebuild executes.
Expo Module Config
Create file:
modules/blink-engage/expo-module.config.json
This is the file that tells Expo's autolinking system which native classes to load:
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["BlinkEngageModule"]
},
"android": {
"modules": ["expo.modules.blinkengage.BlinkEngageModule"],
"packages": ["expo.modules.blinkengage.BlinkEngagePackage"]
},
"plugin": "./plugin/build/index.js"
}
What each field does:
ios.modules— loadsBlinkEngageModule(the Swift class inios/BlinkEngageModule.swift) as the native module. The class is auto-discovered by name; no manual registration needed.android.*— analogous Android entries (covered in the Android guide)plugin— points to the compiled config plugin thatexpo prebuildwill execute
5. Expo Config Plugin — Automated Native Setup
The config plugin (modules/blink-engage/plugin/src/index.ts) runs during expo prebuild and modifies the generated iOS project. On iOS it does four things:
- Adds the
BlinkEngageSwift Package toproject.pbxproj(six pbxproj sections — see Section 5.1) - Sets
GADApplicationIdentifierinInfo.plistfromgadAppId - Injects SDK initialization code into
AppDelegate.swift'sapplication(_:didFinishLaunchingWithOptions:)(license keys, reward currency,rewardCallback,MobileAds.shared.start) - Creates
xcassetsimagesets for anythemeIconslisted inapp.config.js
Each of these is idempotent — re-running prebuild does not duplicate entries.
Where the full plugin source lives: the subsections below explain each piece in isolation. The complete drop-in
plugin/src/index.ts(everything stitched together, plus the Android-side functions used by the same file) is inios-react-native-additional-code-snippets.md§1.
5.1 SPM Package Injection
The Activation/BlinkEngage SDK ships as a Swift Package at:
https://github.com/BlinkReceipt/blinkengage-ios
The plugin pins it to an exact version via app.config.js → ios.sdkVersion (recommended for production: pass an explicit value such as '1.5.1' so prebuilds are reproducible).
Why direct pbxproj surgery instead of "add via Xcode"? Because npx expo prebuild --clean regenerates the entire ios/ directory. Anything you add manually in Xcode is wiped. A config plugin that writes the same six pbxproj sections Xcode would write is the only way for SPM dependencies to survive a clean prebuild.
The six sections the plugin writes:
| Section | Purpose |
|---|---|
XCRemoteSwiftPackageReference | Declares the package URL + version requirement (e.g. exactVersion 1.5.1) |
XCSwiftPackageProductDependency | Selects the BlinkEngage product from the package |
PBXProject.packageReferences | Registers the package at the project level — what triggers Xcode's "Resolving Package Graph…" step |
PBXBuildFile | Bridges the product dependency into a build phase |
PBXFrameworksBuildPhase.files | Adds the framework to "Link Binary With Libraries" on the main app target (Expo projects can have multiple targets) |
PBXNativeTarget.packageProductDependencies | Associates the product with the app target so Xcode knows the build order |
Failure modes you'll see if a section is missing, useful for debugging if you ever crack open the generated project.pbxproj:
| Missing section | Symptom |
|---|---|
XCRemoteSwiftPackageReference | Xcode never resolves the package — "Missing package product 'BlinkEngage'" |
XCSwiftPackageProductDependency | Package resolves but no product is selected — same error as above |
PBXProject.packageReferences | Xcode skips package resolution entirely — products are never built |
PBXBuildFile | Compiles fine, fails at link with "Undefined symbols for BlinkEngage" |
PBXFrameworksBuildPhase.files | Same link-time failure — framework not on "Link Binary With Libraries" |
PBXNativeTarget.packageProductDependencies | Build order race — Swift compiler can hit import BlinkEngage before the package is built |
The full implementation of withBlinkEngageSPM (all six steps, ~150 lines of TypeScript) is in ios-react-native-additional-code-snippets.md §1. The shape:
const withBlinkEngageSPM: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
const objects = xcodeProject.hash.project.objects;
const productName = 'BlinkEngage';
const repositoryUrl = 'https://github.com/BlinkReceipt/blinkengage-ios';
const requirement = {
kind: 'exactVersion',
version: props.ios.sdkVersion || '1.5.1',
};
// Step 1: XCRemoteSwiftPackageReference
// ↳ declares the package URL + version requirement
// Step 2: XCSwiftPackageProductDependency
// ↳ selects the BlinkEngage product from the package
// Step 3: PBXProject.packageReferences
// ↳ registers the package at the project level (triggers "Resolving Package Graph…")
// Step 4: PBXBuildFile
// ↳ bridges the product into a build phase
// Step 5: PBXFrameworksBuildPhase.files (main app target only)
// ↳ adds the framework to "Link Binary With Libraries"
// Step 6: PBXNativeTarget.packageProductDependencies (main app target only)
// ↳ associates the product with the app target so Xcode knows the build order
return config;
});
};
Each step looks up its existing entry by stable identifier (repo name, product name) before creating a new one, so re-running prebuild is safe.
Transitive dependencies. The package's Package.swift declares BlinkReceipt, GoogleMobileAds, and GoogleUserMessagingPlatform as dependencies. The plugin only adds the top-level BlinkEngage product to the Xcode project — Xcode resolves and builds the rest automatically, so import BlinkReceipt and import GoogleMobileAds just work.
5.2 Info.plist Injection
The plugin sets a single key:
const withBlinkEngageInfoPlist: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withInfoPlist(config, (config) => {
if (props.gadAppId) {
config.modResults.GADApplicationIdentifier = props.gadAppId;
}
return config;
});
};
What the plugin does NOT add: camera and photo-library usage strings (
NSCameraUsageDescription,NSPhotoLibraryUsageDescription). Add those yourself inapp.config.jsunderios.infoPlist. WithoutNSCameraUsageDescriptionyour app will crash the first time it tries to launch the receipt camera.
5.3 AppDelegate Injection
This is the heaviest piece of the plugin. It locates application(_:didFinishLaunchingWithOptions:) and injects:
import BlinkReceipt,import BlinkEngage,import GoogleMobileAdsBlinkEngageSDK.start(debugMode:)— explicit SDK bootstrap (required since 1.4.0), guarded so it runs exactly once per processBRScanManager.shared()configuration — license key, prod-intel key,enableBlinkEngage = trueBlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(…)— single consolidated config holding currency name / conversion / payout / icon /rewardCallback(the callback postsBlinkEngageRewardnotifications for each reward type:ScanFinished,Promo,Boost,BarcodeCollection)MobileAds.shared.start(...)— initializes the Google Mobile Ads SDK
Here is the actual generated initialization code (the plugin produces this verbatim, so you can read it exactly as it will appear in your prebuilt AppDelegate.swift):
// ===== BlinkEngage SDK Configuration =====
// 1.4.0 mandates BlinkEngageSDK.start(debugMode:) is called exactly once
// before any access to BlinkEngageSDK.shared. The one-shot guard prevents
// a precondition crash on RN hot reloads / re-entry into didFinishLaunching.
enum BlinkEngageBootstrap { static var didStart = false }
if !BlinkEngageBootstrap.didStart {
BlinkEngageBootstrap.didStart = true
BlinkEngageSDK.start(debugMode: false)
}
BRScanManager.shared().licenseKey = "<IOS_BLINK_LICENSE_KEY>"
BRScanManager.shared().prodIntelKey = "<IOS_PROD_INTEL_KEY>" // empty if unset
BRScanManager.shared().enableBlinkEngage = true
// 1.4.0: reward currency settings + rewardCallback are consolidated into
// an immutable BlinkEngageRewardConfig assigned to BlinkEngageSDK.shared.rewardConfig.
// The reward icon (formerly AppearanceIconKey.offerRewardIcon, removed in 1.4.0)
// is now passed here as currencyImage.
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: "points",
currencyPerDollar: 100.0,
userPayoutPercentage: 1.0, // fraction in [0.4, 1.0]
currencyImage: UIImage(named: "OfferRewardIcon"),
currencyImageLocations: .all,
rewardCallback: { context, rewardAmount, blinkReceiptId in
// 1.4.0 callback signature: (String, NSNumber?, String?) -> NSNumber?
// The old `scanResults` parameter was removed in 1.4.0.
var userInfo: [String: Any] = ["context": context]
if let receiptId = blinkReceiptId { userInfo["blinkReceiptId"] = receiptId }
switch context {
case "ScanFinished":
let amount = 10.0 // <-- baseReward from app.config.js
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return NSNumber(value: amount)
case "Promo", "Boost", "BarcodeCollection":
let amount = rewardAmount?.doubleValue ?? 0
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return nil
default:
return nil
}
}
)
MobileAds.shared.start(completionHandler: nil as GADInitializationCompletionHandler?)
// ===== End BlinkEngage SDK Configuration =====
A few things worth understanding:
BlinkEngageSDK.start(debugMode:)must run exactly once. CallingBlinkEngageSDK.sharedbeforestart(...)triggers a precondition failure; callingstart(...)twice does the same. ThedidStartguard makes the snippet safe across RN hot reloads.debugMode: trueregisters the device as a Google Mobile Ads test device and relaxes receipt fraud validation — ship production builds withdebugMode: false.enableBlinkEngage = trueroutes the BlinkReceipt scan result through the BlinkEngage post-scan flow (ad loading screen → matched promotions → reward emission). When you launch a non-monetized standalone scan from the host app, the module flips this tofalsefor the duration of that scan.rewardConfigis set once and is effectively immutable. All currency / payout / icon / callback configuration lives on the singleBlinkEngageRewardConfiginstance. Assign once at launch; do not reassign per-event.userPayoutPercentageis a fraction in[0.4, 1.0]on the SDK (default0.6). The plugin's prop is expressed as a percent (0..100) for backwards-compatibility and divides by 100 before passing it in.rewardCallbackreturnsNSNumber?— the value the SDK uses for in-app reward display. ForScanFinishedwe returnbaseReward; for the others we returnnil(no host-side adjustment to the SDK-calculated amount).- The 1.4.0 callback signature is
(String, NSNumber?, String?) -> NSNumber?. The oldscanResults: NSDictionary?parameter was removed. If you need the parsed receipt data, the on-deviceBRScanResultsfromBRScanResultsDelegate.didFinishScanning(...)is the source of truth — that's how the standalone scan flow surfaces it. Promo / Boost / Missed Earnings rewards are server-authoritative; consume them via the webhooks in Section 10. blinkReceiptIdis the correlation key. It flows through the callback for every reward context that has a receipt; the module forwards it as a top-level field on the JSonRewardevent for client-side deduplication / analytics.
5.4 iOS Asset / xcassets Generation
For each entry in themeIcons in app.config.js, the plugin creates an Images.xcassets/<AssetName>.imageset/ directory containing:
- The image file (single
1xsource — ideally you provide@2x/@3x, but the plugin uses the same file at all scales as a fallback) Contents.jsondeclaring the imageset metadata
The mapping from JS keys to xcasset names is fixed by the SDK:
| JS theme key | xcasset name |
|---|---|
offerRewardIcon | OfferRewardIcon |
offerWallFloatingButtonIcon | OfferWallFloatingButtonIcon |
missedEarningsNavigationEditButtonIcon | MissedEarningsNavigationEditButtonIcon |
missedEarningsFieldEditIcon | MissedEarningsFieldEditIcon |
postScanReceiptButtonIcon | PostScanReceiptButtonIcon |
postScanBoostDefaultIcon | PostScanBoostDefaultIcon |
postScanSuccessIcon | PostScanSuccessIcon |
ugcBarcodeDetectedIcon | UgcBarcodeDetectedIcon |
ugcToastMessageWarningIcon | UgcToastMessageWarningIcon |
Two ways to ship icons — for the same
themeIconsmap:
- Compile-time (xcassets): the plugin copies the images into the Xcode project. The SDK loads them by name. No runtime cost.
- Runtime (
setTheme): the same icons can be passed viasetTheme({ icons: { ... } })from JS. Useful if your icons depend on user theme preferences. See Section 13.Most apps just use the compile-time path; it's the simpler default.
5.5 Plugin Props in app.config.js
The plugin is registered with its configuration in app.config.js. Props are grouped into platform-specific (ios, android) and shared blocks. Camera / photo-library usage strings live under the standard Expo ios.infoPlist block, not in the plugin props:
export default {
expo: {
// ...
ios: {
bundleIdentifier: 'com.example.app',
infoPlist: {
NSCameraUsageDescription:
'This app uses the camera to scan and process receipts.',
NSPhotoLibraryUsageDescription:
'This app needs access to your photo library to import receipt images for scanning.',
ITSAppUsesNonExemptEncryption: false,
},
},
plugins: [
[
'blink-engage/plugin',
{
ios: {
licenseKey: process.env.IOS_BLINK_LICENSE_KEY || '',
prodIntelKey: process.env.IOS_PROD_INTEL_KEY,
sdkVersion: '1.5.1', // SPM exact-version pin
debugModeEnabled: false, // passed to BlinkEngageSDK.start(debugMode:)
},
android: {
// ... see android.md
},
// Shared (both platforms)
gadAppId: process.env.GAD_APP_ID || '',
rewardCurrencyName: 'points',
rewardCurrencyPerDollar: 100,
userPayoutPercentage: 100,
baseReward: 10,
themeIcons: {
offerRewardIcon: './assets/icons/coin.png',
postScanSuccessIcon: './assets/icons/coin2.png',
postScanBoostDefaultIcon: './assets/icons/boost.png',
},
},
],
// ... other plugins
],
},
};
The full prop reference is in Section 16.
6. Native Module (iOS)
For React Native developers: This section walks through the Swift files that make up the native module. You don't need to be a Swift expert — the snippets are drop-in, with each file explained in plain English. You'll create 4 Swift files plus 1 podspec, each described below. Long files (the full
JSBackedTheme.swiftkey tables,BRScanResultsSerializer.swift) are kept whole inios-react-native-additional-code-snippets.mdso this guide stays readable.
Swift / iOS concepts you'll encounter
If you've only worked in JavaScript/TypeScript, here's a quick reference for the iOS-specific terms used in this section:
| Term | What it means (RN analogy) |
|---|---|
ExpoView | Expo's base class for native views — the iOS analogue of an Android ExpoView. Renders inside RN's view hierarchy. |
UIViewController | A "screen" in iOS (≈ a stack screen in React Navigation). The SDK ships its UI as view controllers. |
UINavigationController | A stack-style container that wraps view controllers and handles push/pop. Used by the SDK internally. |
Module / ModuleDefinition | Expo's Swift API for declaring functions/events/views exposed to JS — the iOS counterpart to Android's Module() definition. |
OnCreate / OnDestroy | Module lifecycle hooks. OnCreate runs when the module instance is built; OnDestroy when it's torn down. |
NotificationCenter | iOS's global pub/sub — like an EventEmitter shared across the whole app. |
@MainActor | Swift 6 marker meaning "this property/method must be accessed on the main thread." BlinkEngageSDK.shared.appearance is one of these. |
weak | A non-retaining reference. Used so the module doesn't keep a delegate or VC alive past its natural lifetime. |
Promise | An Expo type that resolves/rejects an async JS call. Mirrors a JavaScript Promise. |
6.1 Module Podspec
Create file:
modules/blink-engage/ios/BlinkEngageExpoModule.podspec
The Expo module is itself a CocoaPods pod. It depends on ExpoModulesCore for the module API and uses framework search paths to find the SDKs that come from SPM:
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'BlinkEngageExpoModule'
s.version = package['version']
s.summary = 'BlinkEngage Expo Module'
s.description = 'A native module for BlinkEngage SDK integration'
s.author = 'Microblink'
s.homepage = 'https://microblink.com'
s.platforms = { :ios => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# BlinkEngage, BlinkReceipt, and GoogleMobileAds come from SPM (see config plugin).
# ${PODS_CONFIGURATION_BUILD_DIR} resolves to the shared root where Xcode places SPM
# framework products; ${BUILT_PRODUCTS_DIR} would resolve to the pod's own directory
# and would not find them.
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE'=> 'wholemodule',
'FRAMEWORK_SEARCH_PATHS'=> '$(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks"',
'SWIFT_INCLUDE_PATHS' => '$(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks"'
}
s.source_files = '*.swift'
end
Why both FRAMEWORK_SEARCH_PATHS and SWIFT_INCLUDE_PATHS?
FRAMEWORK_SEARCH_PATHSfinds.frameworkbundles (used by Objective-C frameworks like BlinkReceipt).SWIFT_INCLUDE_PATHSfinds.swiftmoduledirectories (used by pure Swift modules).
Including both covers every product the SDK ships, regardless of how it's built.
Why both ${PODS_CONFIGURATION_BUILD_DIR} and ${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks?
- Older Xcodes place SPM products directly in the root build directory.
- Xcode 15+ places them in a
PackageFrameworks/subdirectory.
Including both keeps the module portable.
6.2 Module Definition — BlinkEngageModule.swift
Create file:
modules/blink-engage/ios/BlinkEngageModule.swift
This is the main bridge between JavaScript and native code. It defines the functions you can call from React Native (setUser, clearUser, showOfferWall, dismissOfferWall, setTheme, startReceiptScan, startMonetizedScan), the events it emits to JS (onReward), and registers the embedded offer wall view. The version below is the production-shape file: everything you need, nothing you don't.
import ExpoModulesCore
import BlinkEngage
import BlinkReceipt
import UIKit
import GoogleMobileAds
public class BlinkEngageModule: Module {
// Shared instance so the static reward observer can dispatch to the live module
private static weak var sharedInstance: BlinkEngageModule?
// Static observer to prevent duplicate registrations across module re-creation
private static var rewardObserver: NSObjectProtocol?
private static var isRewardObserverSetUp = false
public func definition() -> ModuleDefinition {
Name("BlinkEngage")
OnCreate {
BlinkEngageModule.sharedInstance = self
// Install the JS-backed theme exactly once. The Appearance wrapper holds
// a reference to our singleton; subsequent setTheme(...) calls mutate
// the singleton's dictionaries in-place, so we never reassign here.
//
// BlinkEngageSDK.shared.appearance is @MainActor-isolated under Swift 6
// strict concurrency, so we hop onto the main actor for the write.
Task { @MainActor in
BlinkEngageSDK.shared.appearance = Appearance(theme: JSBackedTheme.shared)
}
self.setupRewardObserver()
}
OnDestroy {
self.removeRewardObserver()
}
Events("onReward")
// ============================ Native View ===============================
View(OfferWallView.self) {
Prop("showFloatingAction") { (view: OfferWallView, value: Bool) in
view.showFloatingAction = value
}
}
// ============================ Theme =====================================
//
// Atomic full-replace. Unset keys revert to SDK defaults; unknown keys are
// dropped silently (the JS layer emits __DEV__ warnings for those cases).
AsyncFunction("setTheme") { (theme: [String: [String: String]], promise: Promise) in
let colors = theme["colors"] ?? [:]
let fontNames = theme["fontNames"] ?? [:]
let icons = theme["icons"] ?? [:]
JSBackedTheme.shared.apply(colors: colors, fontNames: fontNames, icons: icons)
promise.resolve(nil)
}
// ============================ User ======================================
Function("setUser") { (params: [String: String]) in
if let emailHash = params["emailHash"] { BlinkEngageSDK.shared.user.emailHash = emailHash }
if let phoneHash = params["phoneHash"] { BlinkEngageSDK.shared.user.phoneHash = phoneHash }
if let clientUserId = params["clientUserId"] { BlinkEngageSDK.shared.user.clientUserId = clientUserId }
}
Function("clearUser") {
BlinkEngageSDK.shared.user.emailHash = nil
BlinkEngageSDK.shared.user.phoneHash = nil
BlinkEngageSDK.shared.user.clientUserId = nil
}
// ============================ Offer Wall (modal) ========================
//
// Presents the SDK-provided OffersWallViewController inside a UINavigationController.
// Tapping "Scan Receipt" inside the wall starts a monetized scan via the delegate.
AsyncFunction("showOfferWall") { (options: [String: Any]?, promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}
let delegate = BlinkEngageModuleDelegate.shared
delegate.showFloatingAction = (options?["showFloatingAction"] as? Bool) ?? true
let offerWallVC = OffersWallViewController()
offerWallVC.delegate = delegate
let nav = UINavigationController(rootViewController: offerWallVC)
nav.modalPresentationStyle = .fullScreen
nav.isNavigationBarHidden = true
delegate.currentOfferWallNavController = nav
delegate.currentOfferWallViewController = offerWallVC
rootViewController.present(nav, animated: true) {
promise.resolve(true)
}
}
}
AsyncFunction("dismissOfferWall") { (promise: Promise) in
DispatchQueue.main.async {
if let nav = BlinkEngageModuleDelegate.shared.currentOfferWallNavController {
nav.dismiss(animated: true) {
BlinkEngageModuleDelegate.shared.currentOfferWallNavController = nil
BlinkEngageModuleDelegate.shared.currentOfferWallViewController = nil
promise.resolve(true)
}
} else {
promise.resolve(false)
}
}
}
// ============================ Monetized Scan ============================
//
// Standalone monetized scan — runs the BlinkEngage post-scan flow
// (ad loading, matched promotions, reward emission) without showing
// the offer wall first. Rewards arrive via the `onReward` event in
// exactly the same way as a scan launched from inside the offer wall.
AsyncFunction("startMonetizedScan") { (promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: rootViewController,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: BlinkEngageModuleDelegate.shared
)
promise.resolve(true)
}
}
// ============================ Standalone Scan ===========================
//
// Launches the BlinkReceipt camera with enableBlinkEngage = false.
// Returns the parsed scan results to JS via the resolved promise.
AsyncFunction("startReceiptScan") { (options: [String: Any]?, promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}
BRScanManager.shared().enableBlinkEngage = false
BlinkEngageModuleDelegate.shared.standaloneScanPromise = promise
let scanOptions = BRScanOptions()
if let detectDuplicates = options?["detectDuplicates"] as? Bool { scanOptions.detectDuplicates = detectDuplicates }
if let countryCode = options?["countryCode"] as? String { scanOptions.countryCode = countryCode }
var cameraType: BRCameraType = .uxEnhanced
if let cameraTypeString = options?["cameraType"] as? String, cameraTypeString == "standard" {
cameraType = .uxStandard
}
BRScanManager.shared().startStaticCamera(
from: rootViewController,
cameraType: cameraType,
scanOptions: scanOptions,
with: BlinkEngageModuleDelegate.shared
)
}
}
}
// ============================ Helpers =======================================
private func getRootViewController() -> UIViewController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.windows.first?.rootViewController else {
return UIApplication.shared.delegate?.window??.rootViewController
}
var top = root
while let presented = top.presentedViewController { top = presented }
return top
}
// ============================ Reward Observer ===============================
private func setupRewardObserver() {
guard !BlinkEngageModule.isRewardObserverSetUp else { return }
BlinkEngageModule.isRewardObserverSetUp = true
BlinkEngageModule.rewardObserver = NotificationCenter.default.addObserver(
forName: Notification.Name("BlinkEngageReward"),
object: nil,
queue: .main
) { notification in
guard let userInfo = notification.userInfo,
let context = userInfo["context"] as? String,
let amount = userInfo["amount"] as? Double else { return }
// Payload shape matches RewardEvent in src/types.ts.
// `blinkReceiptId` is forwarded as a top-level field so the JS layer
// can correlate the reward with the originating receipt (analytics,
// client-side dedup, etc.).
var payload: [String: Any] = ["context": context, "amount": amount]
if let receiptId = userInfo["blinkReceiptId"] as? String {
payload["blinkReceiptId"] = receiptId
}
BlinkEngageModule.sharedInstance?.sendEvent("onReward", payload)
}
}
private func removeRewardObserver() {
if let observer = BlinkEngageModule.rewardObserver {
NotificationCenter.default.removeObserver(observer)
BlinkEngageModule.rewardObserver = nil
BlinkEngageModule.isRewardObserverSetUp = false
}
}
}
Then the delegate that handles the SDK's OffersWallViewControllerDelegate and BRScanResultsDelegate callbacks for both modal-mode and standalone scans:
class BlinkEngageModuleDelegate: NSObject, OffersWallViewControllerDelegate, BRScanResultsDelegate {
static let shared = BlinkEngageModuleDelegate()
weak var currentOfferWallNavController: UINavigationController?
weak var currentOfferWallViewController: OffersWallViewController?
var showFloatingAction: Bool = true
var standaloneScanPromise: Promise?
// OffersWallViewControllerDelegate
func offerWallShouldDisplayFloatingAction(_ vc: OffersWallViewController) -> Bool {
return showFloatingAction
}
func offerWallDidSelectFloatingAction(_ vc: OffersWallViewController) {
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: self
)
}
// BRScanResultsDelegate
func didFinishScanning(_ cameraVC: UIViewController, with scanResults: BRScanResults?) {
if let promise = standaloneScanPromise {
var result: [String: Any] = ["success": true, "cancelled": false]
if let results = scanResults {
result.merge(BRScanResultsSerializer.serialize(results)) { _, new in new }
}
cameraVC.dismiss(animated: true) {
promise.resolve(result)
self.standaloneScanPromise = nil
}
} else {
cameraVC.dismiss(animated: true)
}
BRScanManager.shared().enableBlinkEngage = true
}
func didCancelScanning(_ cameraVC: UIViewController) {
if let promise = standaloneScanPromise {
cameraVC.dismiss(animated: true) {
promise.resolve(["success": false, "cancelled": true])
self.standaloneScanPromise = nil
}
} else {
cameraVC.dismiss(animated: true)
}
BRScanManager.shared().enableBlinkEngage = true
}
func scanningErrorOccurred(_ error: Error) {
standaloneScanPromise?.reject("E_SCAN_ERROR", error.localizedDescription)
standaloneScanPromise = nil
BRScanManager.shared().enableBlinkEngage = true
}
}
Why a static delegate instance? SDK callbacks fire from OffersWallViewController instances that may outlive any single BlinkEngageModule instance (especially during fast refresh / Hermes reloads). A static let shared keeps the delegate alive for the entire process and avoids weak-reference races.
Reward event payload. Every onReward event carries context + amount, plus an optional top-level blinkReceiptId whenever the SDK supplies one (ScanFinished always; Promo / Boost / BarcodeCollection when they originate from a specific receipt). The 1.4.0 rewardCallback no longer receives a BRScanResults parameter, so the on-device event does not carry parsed receipt fields. If you need the full scan data inline with a reward, use the standalone scan flow's BRScanResultsDelegate (the BRScanResultsSerializer snippet in §3 of ios-react-native-additional-code-snippets.md shows the full serialization shape). Server-authoritative reward details for Promo / Boost / Missed Earnings come through the webhooks in Section 10.
Client-side dedup is up to you. The SDK can re-fire ScanFinished for the same receipt during retries / reconnects. Use the blinkReceiptId on the event to drop duplicates in JS (a small Set<string> of recently seen ids, or compared against your reward ledger). The native module does not dedup on your behalf so it can deliver every event the SDK emits unmodified.
6.3 Embedded Offer Wall — OfferWallView.swift
Create file:
modules/blink-engage/ios/OfferWallView.swift
This is the iOS counterpart to Android's OfferWallView.kt. It embeds the SDK's OffersWallViewController directly into React Native's view hierarchy, so you can drop <OfferWallView style={{ flex: 1 }} /> into any layout.
Why this is non-trivial. React Native uses Yoga / Flexbox to compute view sizes. iOS UIViewControllers expect to be added to a view-controller hierarchy via addChild/didMove(toParent:), which Expo doesn't do automatically for arbitrary native views. We solve this by walking up the responder chain to find the host view controller, then adding the SDK's view controller as a child of that.
import ExpoModulesCore
import BlinkEngage
import BlinkReceipt
import UIKit
class OfferWallView: ExpoView {
private var offerWallViewController: OffersWallViewController?
private var hostViewController: UIViewController?
private var navController: UINavigationController?
/// Whether to show the floating "Scan Receipt" button.
var showFloatingAction: Bool = true {
didSet { embedOfferWallIfReady() }
}
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
// Don't embed yet — wait for didMoveToWindow when props are guaranteed to be set.
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
embedOfferWallIfReady()
} else {
tearDownOfferWall()
}
}
private func embedOfferWallIfReady() {
guard window != nil, offerWallViewController == nil else { return }
embedOfferWall()
}
private func embedOfferWall() {
guard offerWallViewController == nil else { return }
guard let hostVC = findViewController() else { return }
self.hostViewController = hostVC
let delegate = OfferWallViewDelegate.shared
delegate.showFloatingAction = self.showFloatingAction
delegate.parentView = self
DispatchQueue.main.async { [weak self] in
guard let self = self, self.offerWallViewController == nil else { return }
let offerWallVC = OffersWallViewController()
offerWallVC.delegate = delegate
self.offerWallViewController = offerWallVC
let nav = UINavigationController(rootViewController: offerWallVC)
nav.isNavigationBarHidden = true // SDK renders header on child screens only
self.navController = nav
hostVC.addChild(nav)
nav.view.frame = self.bounds
nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.addSubview(nav.view)
nav.didMove(toParent: hostVC)
}
}
override func layoutSubviews() {
super.layoutSubviews()
navController?.view.frame = self.bounds
}
private func tearDownOfferWall() {
if let nav = navController {
nav.willMove(toParent: nil)
nav.view.removeFromSuperview()
nav.removeFromParent()
}
navController = nil
offerWallViewController = nil
hostViewController = nil
if OfferWallViewDelegate.shared.parentView === self {
OfferWallViewDelegate.shared.parentView = nil
}
}
private func findViewController() -> UIViewController? {
var responder: UIResponder? = self
while let next = responder?.next {
if let vc = next as? UIViewController { return vc }
responder = next
}
return nil
}
}
class OfferWallViewDelegate: NSObject, OffersWallViewControllerDelegate, BRScanResultsDelegate {
static let shared = OfferWallViewDelegate()
var showFloatingAction: Bool = true
weak var parentView: OfferWallView?
func offerWallShouldDisplayFloatingAction(_ vc: OffersWallViewController) -> Bool {
return showFloatingAction
}
func offerWallDidSelectFloatingAction(_ vc: OffersWallViewController) {
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: self
)
}
func didFinishScanning(_ cameraVC: UIViewController, with scanResults: BRScanResults?) {
cameraVC.dismiss(animated: true)
}
func didCancelScanning(_ cameraVC: UIViewController) {
cameraVC.dismiss(animated: true)
}
}
A few implementation details worth understanding:
didMoveToWindowinstead ofinit— Expo sets props after the view is constructed but before it lands in a window. Embedding earlier thandidMoveToWindowmeans we'd potentially embed with a staleshowFloatingActionvalue.UINavigationControllerwrapper — the SDK renders headers on child screens (offer details, post-scan). Even though we hide the nav bar at the root, the navigation controller is required for the SDK's push transitions to work.autoresizingMask+layoutSubviews— together they keep the embedded VC's view sized to whatever the JS layout dictates (flex: 1, fixed height, whatever).teardownonwindow == nil— when the React tree unmounts the<OfferWallView />, we explicitly remove the child view controller. Without this, the SDK can hold strong references to delegates that prevent the parent VC from deallocating.
6.4 JS-Backed Theme — JSBackedTheme.swift
Create file:
modules/blink-engage/ios/JSBackedTheme.swift
This is the bridge between JS-driven theming and the SDK's Theme protocol. It's a singleton that holds three dictionaries — colors, font names, icons — and an atomic apply(...) method that the module's setTheme function calls.
The full key tables are long (over 100 color slots, plus font and icon mappings). Here's the structure of the file; the complete colorKey(from:), fontKey(from:), and iconKey(from:) mappings are in ios-react-native-additional-code-snippets.md §2:
import UIKit
import BlinkEngage
final class JSBackedTheme: NSObject, Theme {
static let shared = JSBackedTheme()
private override init() { super.init() }
var isRewardIconEnabled: Bool { true }
var isMerchantIconEnabled: Bool { true }
var globalFontMatrix: NSDictionary? { nil }
// Thread-safe storage using a concurrent queue with barrier writes.
// SDK UI may read theme keys from any thread; JS bridge writes from the module queue.
private let queue = DispatchQueue(
label: "com.blinkengage.theme.store",
attributes: .concurrent
)
private var _colors: [AppearanceColorKey: UIColor] = [:]
private var _fontNames: [AppearanceFontNameKey: String] = [:]
private var _icons: [AppearanceIconKey: UIImage] = [:]
func color(forKey key: AppearanceColorKey) -> UIColor? { queue.sync { _colors[key] } }
func fontName(forKey key: AppearanceFontNameKey) -> String? { queue.sync { _fontNames[key] } }
func image(forKey key: AppearanceIconKey) -> UIImage? { queue.sync { _icons[key] } }
func apply(
colors rawColors: [String: String],
fontNames rawFonts: [String: String],
icons rawIcons: [String: String]
) {
var parsedColors: [AppearanceColorKey: UIColor] = [:]
for (k, hex) in rawColors {
guard let key = Self.colorKey(from: k), let color = UIColor(hex: hex) else { continue }
parsedColors[key] = color
}
var parsedFonts: [AppearanceFontNameKey: String] = [:]
for (k, name) in rawFonts {
guard let key = Self.fontKey(from: k) else { continue }
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { parsedFonts[key] = trimmed }
}
var parsedIcons: [AppearanceIconKey: UIImage] = [:]
for (k, path) in rawIcons {
guard let key = Self.iconKey(from: k), let image = Self.loadImage(fromPath: path) else { continue }
parsedIcons[key] = image
}
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self._colors = parsedColors
self._fontNames = parsedFonts
self._icons = parsedIcons
}
}
private static func loadImage(fromPath path: String) -> UIImage? {
if path.hasPrefix("file://") {
if let url = URL(string: path) { return UIImage(contentsOfFile: url.path) }
return nil
}
return UIImage(contentsOfFile: path)
}
// String → key mappings. Three switch statements (one per category) map
// the JS-facing string keys to the SDK's AppearanceColorKey /
// AppearanceFontNameKey / AppearanceIconKey enums. Together they're ~250
// lines of mechanical case statements; the complete file is in
// ios-react-native-additional-code-snippets.md §2.
private static func colorKey(from s: String) -> AppearanceColorKey? {
switch s {
case "offerWallHeaderBackground": return .offerWallHeaderBackground
case "offerWallHeaderTitleLabel": return .offerWallHeaderTitleLabel
// … ~100 more cases — see ios-react-native-additional-code-snippets.md §2
default: return nil
}
}
private static func fontKey(from s: String) -> AppearanceFontNameKey? { /* see snippets §2 */ return nil }
private static func iconKey(from s: String) -> AppearanceIconKey? { /* see snippets §2 */ return nil }
}
You'll also need a small UIColor(hex:) extension to parse "#RRGGBB" and "#RRGGBBAA" strings — it's in ios-react-native-additional-code-snippets.md §4. Add it to the bottom of BlinkEngageModule.swift (or a separate UIColor+Hex.swift file in the same target).
How the theme gets installed. Once at module creation time:
Task { @MainActor in
BlinkEngageSDK.shared.appearance = Appearance(theme: JSBackedTheme.shared)
}
Appearance holds a strong reference to the Theme it's constructed with. We never reassign appearance again — we mutate the singleton's internal dictionaries in place. The SDK's next color/font/icon lookup reads the new value.
Why this design? A naive "setTheme re-creates Appearance(theme: …) and reassigns" approach would cause the SDK to drop currently-rendered UI and restart its appearance pipeline. The singleton-mutation approach keeps the SDK's view controllers happy across theme changes.
6.5 BRScanResults serialization
Create file:
modules/blink-engage/ios/BRScanResultsSerializer.swift
A pure helper that turns a BRScanResults instance into [String: Any] for JS. It's straightforward but verbose — ~120 lines mapping every confidence-wrapped field (results.merchantName?.value, results.total?.value, etc.) onto a flat dictionary.
The complete file is in ios-react-native-additional-code-snippets.md §3 — drop it in as-is. Your TypeScript ScanSuccess type in src/types.ts mirrors this shape one-for-one, so consumers get full type safety on the JS side.
7. JavaScript Bridge (Expo Modules API)
Now that the native Swift files are in place, you need 3 TypeScript files that connect your React Native code to the native module. These are standard Expo Modules API boilerplate.
7.1 Native Module Loader
Create file:
modules/blink-engage/src/BlinkEngageModule.ts
import { requireNativeModule } from 'expo-modules-core';
// Loads the native module exposing both functions and the onReward event.
export default requireNativeModule('BlinkEngage');
The string 'BlinkEngage' must match the Name("BlinkEngage") in BlinkEngageModule.swift.
7.2 Native View
Create file:
modules/blink-engage/src/OfferWallView.tsx
import { requireNativeViewManager } from 'expo-modules-core';
import React from 'react';
import { StyleProp, ViewStyle } from 'react-native';
export interface OfferWallViewProps {
/** Whether to show the floating "Scan Receipt" button. Default: true */
showFloatingAction?: boolean;
style?: StyleProp<ViewStyle>;
}
const NativeOfferWallView = requireNativeViewManager('BlinkEngage');
export function OfferWallView({
showFloatingAction = true,
style,
}: OfferWallViewProps) {
return (
<NativeOfferWallView
style={style}
showFloatingAction={showFloatingAction}
/>
);
}
export default OfferWallView;
The 'BlinkEngage' string must match the module name registering View(OfferWallView.self).
Cross-platform note:
showFloatingActionis honored on iOS (the embeddedOffersWallViewControllerreads it via the delegate). On Android the prop is currently a no-op — if you write cross-platform code, usesetFloatingActionconfig in your design assuming it may not affect Android until that work lands.
7.3 Public API
Create file:
modules/blink-engage/src/index.ts
This is the file your app imports from. It wraps the native module with a clean TypeScript API. SHA-256 hashing is done on the JS side using expo-crypto, so plain-text email/phone never crosses the bridge:
import * as Crypto from 'expo-crypto';
import { type EventSubscription } from 'expo-modules-core';
import { Platform } from 'react-native';
import BlinkEngageModule from './BlinkEngageModule';
import { setTheme } from './setTheme';
export type { EventSubscription };
export type {
BlinkEngageTheme,
BlinkEngageColorKey,
BlinkEngageFontKey,
BlinkEngageIconKey,
} from './theme';
export { createTheme } from './theme';
export {
OfferWallView,
OfferWallViewProps,
OfferWallViewRef,
} from './OfferWallView';
export { setTheme } from './setTheme';
// --- User identity ---
async function hashSHA256(value: string): Promise<string> {
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
value,
);
}
export async function setUser(params: BlinkEngageUserParams): Promise<void> {
const nativeParams: Record<string, string> = {};
if (params.email)
nativeParams.emailHash = await hashSHA256(params.email.toLowerCase());
if (params.phone) nativeParams.phoneHash = await hashSHA256(params.phone);
if (params.clientUserId) nativeParams.clientUserId = params.clientUserId;
BlinkEngageModule.setUser(nativeParams);
}
export function clearUser(): void {
BlinkEngageModule.clearUser();
}
// --- Offer wall ---
export function showOfferWall(options?: OfferWallOptions): Promise<boolean> {
if (Platform.OS === 'ios') {
return BlinkEngageModule.showOfferWall(options);
}
return BlinkEngageModule.showOfferWall();
}
export function dismissOfferWall(): Promise<boolean> {
return BlinkEngageModule.dismissOfferWall();
}
// --- Standalone scan ---
export function startReceiptScan(
options?: ReceiptScanOptions,
): Promise<ReceiptScanResult> {
if (Platform.OS === 'android') {
return BlinkEngageModule.startReceiptScan();
}
return BlinkEngageModule.startReceiptScan(options);
}
// --- Rewards ---
export function addRewardListener(
listener: (event: RewardEvent) => void,
): EventSubscription {
return BlinkEngageModule.addListener('onReward', listener);
}
Type definitions for BlinkEngageUserParams, OfferWallOptions, ReceiptScanOptions, ReceiptScanResult (a discriminated union), ScannedProduct, RewardEvent, etc. live in src/types.ts — the full file is in ios-react-native-additional-code-snippets.md §5.
The setTheme function lives in its own file (src/setTheme.ts) because it does extra work on the JS side: validates theme keys, resolves icon sources via expo-asset, then crosses the bridge with already-resolved file paths. See Section 13.
8. Usage in React Native
There are two ways to render the offer wall on iOS — both are supported and well-tested. Pick whichever fits your navigation model.
Option A — Embedded view (recommended)
Drop the offer wall as a native view inside your existing screen, just like a <View>:
import { View } from 'react-native';
import { OfferWallView } from 'blink-engage';
import { OfferWallHeader } from '@/components/@offerWall/OfferWallHeader';
import { useRouter } from 'expo-router';
export default function OfferWallScreen() {
const router = useRouter();
return (
<View style={{ flex: 1 }}>
<OfferWallHeader title="Offers" onClose={() => router.back()} />
<OfferWallView style={{ flex: 1 }} showFloatingAction={true} />
</View>
);
}
The OfferWallView needs a non-zero size (flex: 1 or explicit dimensions). Close/back navigation is handled by your host header — the SDK's OffersWallViewController is rendered without a system nav bar at the root.
Option B — Modal presentation
If you don't want to give the offer wall a dedicated route in your RN navigation, present it as a full-screen modal:
import * as BlinkEngage from 'blink-engage';
await BlinkEngage.showOfferWall({ showFloatingAction: true });
The promise resolves to true once the modal is presented. To dismiss programmatically:
await BlinkEngage.dismissOfferWall();
Or let the user tap the SDK's built-in close button.
When to use which? Embedded is better for tab bars / bottom sheets / anywhere you want native back-stack control. Modal is better for a one-shot "Earn Rewards" CTA that should feel like a separate flow. Both deliver the same reward events.
Setting User Identity
Required: At least one of
phonemust be set before displaying the offer wall. The offer wall will not load promotions without a registered user identity.
import * as BlinkEngage from 'blink-engage';
await BlinkEngage.setUser({
email: 'user@example.com',
phone: '+15551234567',
clientUserId: 'user-123',
});
BlinkEngage.clearUser(); // on logout
Email and phone are SHA-256 hashed on the JS side before crossing the bridge — plain text never reaches native.
Listening for Rewards
import * as BlinkEngage from 'blink-engage';
useEffect(() => {
const subscription = BlinkEngage.addRewardListener((event) => {
console.log(`Earned ${event.amount} (${event.context})`);
if (event.context === 'ScanFinished' && event.blinkReceiptId) {
console.log(`From receipt ${event.blinkReceiptId}`);
}
setTotalRewards((prev) => prev + event.amount);
});
return () => subscription.remove();
}, []);
Standalone (non-monetized) receipt scan
If you want a "Scan Receipt" feature outside the offer wall — pure receipt parsing with no ads, no promotion matching, no rewards — use startReceiptScan:
const result = await BlinkEngage.startReceiptScan({
detectDuplicates: true,
cameraType: 'enhanced',
});
if (result.success) {
console.log(`Merchant: ${result.merchantName}`);
console.log(`Total: ${result.total}`);
console.log(`Products: ${result.products?.length}`);
} else if (result.cancelled) {
// User backed out of the camera
} else {
console.error('Scan failed:', result.error);
}
The promise always resolves (even on cancel) with a discriminated-union result — see ReceiptScanResult in src/types.ts.
9. Handling Rewards (On-Device)
Important: The on-device SDK rewards flow is not the only reward channel. Promotional rewards, boost rewards, UGC rewards, and missed earnings rewards are calculated by Actual's backend and delivered to your server via webhooks. Both channels are required:
- SDK rewards flow — for real-time in-app reward display
- Webhooks — for reliable server-side tracking, auditability, and crediting users
See Section 10 for webhook details.
Rewards are emitted as native events through the Expo Modules event system. Each event contains:
| Field | Type | Description |
|---|---|---|
context | string | The reward type: "ScanFinished", "Promo", "Boost", or "BarcodeCollection" |
amount | number | Points earned for this reward |
blinkReceiptId | string? | The BlinkReceipt id of the originating receipt, when the SDK supplies one. Always set for ScanFinished; set on Promo/Boost/BarcodeCollection when those originate from a specific receipt. |
Note on
scanResults. SDK 1.4.0 removed thescanResultsparameter fromrewardCallback, so the on-device reward event does not carry parsed receipt fields. If you need the full parsedBRScanResultsinline with a scan, usestartReceiptScan()(returns the parsed result via promise) or correlate byblinkReceiptIdto yourReceiptProcessedwebhook payload (see Section 10).
Reward types
| Type | Trigger | Source |
|---|---|---|
ScanFinished | Every successful receipt scan | baseReward in app.config.js → returned from rewardCallback for "ScanFinished" |
Promo (Promotion) | Scanned receipt matches a qualified promotion | Backend calculates based on userPayoutPercentage and matched offer values |
Boost | User watches a rewarded ad or redeems a CPA offer on the receipt summary screen | SDK emits when the ad/CPA completes |
BarcodeCollection | User scans a qualifying barcode collection | SDK emits when barcode collection reward is triggered |
Reward flow on iOS
User scans receipt
│
▼
BlinkReceipt camera dismisses with BRScanResults
│
▼
BlinkEngage SDK runs post-scan flow (ad loading → matched promotions → receipt summary)
│
▼
SDK invokes rewardConfig.rewardCallback(context, rewardAmount, blinkReceiptId)
(1.4.0 signature: scanResults parameter was dropped)
│
▼
AppDelegate's rewardCallback (injected by config plugin)
posts NotificationCenter.default.post(name: "BlinkEngageReward", userInfo: ...)
│
▼
BlinkEngageModule's static observer receives the notification
│
▼
Module forwards { context, amount, blinkReceiptId? } via sendEvent("onReward", payload)
│
▼
JS addRewardListener callback fires
Multiple events typically arrive for a single scan: one ScanFinished plus zero or more of Promo / Boost / BarcodeCollection. Treat them as a stream, not a single fact.
Session lifecycle
Reward state is scoped per scan. When a new scan starts, the SDK does not reset your local accumulators for you — clear them yourself (e.g. setSessionRewards(0)) when entering the offer wall if you want a per-session counter.
Notes on deduplication
The SDK's reward pipeline can re-fire ScanFinished for the same receipt during retries / network reconnects. The native module passes every event through unmodified, so your JS listener may see the same blinkReceiptId more than once for ScanFinished. The simplest defense is a small in-memory Set<string> of recently-credited blinkReceiptIds; for a stronger guarantee, check against your reward ledger before crediting. Promo, Boost, and BarcodeCollection are server-authoritative and not expected to repeat per qualified action, but the same blinkReceiptId-based check works for them too.
10. Server-Side Rewards (Webhooks) (Backend)
Actual's backend sends webhook POST requests to your server when receipts are processed and rewards are calculated. This is the authoritative source for all backend-calculated rewards (promotions, boosts, UGC, missed earnings).
Setup
Provide the following to your Actual Account Management team:
- Which API key to use (share securely or only share the last 3 characters)
- Your app's bundle identifier (e.g.
com.yourcompany.app) - Your webhook endpoint URL (e.g.
https://api.yourcompany.com/webhooks/actual) - Which event types to subscribe to:
ReceiptProcessed,RewardUpdate, or both - Any custom HTTP headers you want included in webhook requests (optional)
Your endpoint must be HTTPS with a valid SSL certificate.
Event Types
| Event | When It Fires | What It Contains |
|---|---|---|
ReceiptProcessed | After a receipt is validated and processed | Receipt data, promotion match results, fraud status, blink_receipt_id |
RewardUpdate | When a reward is calculated by the backend | Reward type (promo, boost, ugc), amount, blink_receipt_id, reward_id |
Key Design Considerations
- Multiple webhooks per receipt: A single receipt can trigger separate webhooks for promo, boost, UGC, and missed earnings. Handle multiple events per
blink_receipt_id. - Idempotency: The same webhook can be delivered more than once during retries. Use
reward_idorblink_receipt_id+reward_typeto deduplicate. - Store
blink_receipt_id: This is the primary key that linksReceiptProcessed,RewardUpdate, and on-device scan context. The same id appears asevent.blinkReceiptIdon the on-device reward event. - No "all done" signal: There is no event indicating that all webhooks for a given receipt have been delivered. Missed earnings can add rewards hours or days later. Design for open-ended reward accrual per receipt.
- Retry behavior: Webhooks retry 3 times over ~65 seconds, then go to a dead letter queue. Build monitoring for missing expected webhooks.
11. Receipt Validation
The SDK validates receipts automatically before processing rewards. No configuration is required. Validation checks include:
- Duplicate detection — Receipts already submitted are rejected (cross-client, 14-day window).
- Fraud detection — Suspicious receipts are flagged.
- Receipt age — Receipts older than 14 days are rejected.
- Receipt date — Receipts without a parseable date are rejected.
All validation is handled internally by the SDK. The BRScanResults returned from startReceiptScan() exposes isDuplicate / isFraudulent flags directly (standalone scan flow). For monetized scans, the validation outcome is reflected in the backend ReceiptProcessed webhook payload — correlate by blinkReceiptId.
12. Missed Earnings (Backend)
If a receipt scan does not initially match promotions (e.g., due to OCR quality or missing items), the user may be presented with a missed-earnings correction flow. This allows users to manually verify or correct scanned items to recover eligible rewards.
Missed-earnings rewards are delivered via RewardUpdate webhooks and may arrive hours or days after the original scan. Design your backend to handle open-ended reward accrual per receipt.
13. Theme System
The iOS SDK supports full theming — colors, font names, and icons — via a JS-driven API. The same call signature works on both platforms (Android theming is currently a no-op — calls succeed but have no effect).
Authoring a theme
import { createTheme, setTheme } from 'blink-engage';
const appTheme = createTheme({
colors: {
offerWallHeaderBackground: '#0B1F33',
offerWallHeaderTitleLabel: '#FFFFFF',
offerWallFloatingButtonBackground: '#FFC72C',
offerWallFloatingButtonLabel: '#000000',
// ... see BlinkEngageColorKey for the full list
},
fontNames: {
offerWallHeaderTitleLabel: 'Outfit-Bold',
offerRewardPointsLabel: 'Teko-SemiBold',
// ... see BlinkEngageFontKey for the full list
},
icons: {
postScanSuccessIcon: require('./assets/icons/success.png'),
// ... see BlinkEngageIconKey for the full list
//
// NOTE: `offerRewardIcon` is intentionally NOT a runtime icon slot
// (since SDK 1.4.0). Configure it via the prebuild `themeIcons` map
// in app.config.js — the plugin copies it into the Asset Catalog and
// the AppDelegate snippet passes it to BlinkEngageRewardConfig.currencyImage.
},
});
await setTheme(appTheme);
Applying the theme on app startup
The recommended pattern is to wrap your root component with a provider that calls setTheme once after fonts/assets are ready:
import { useEffect, useState } from 'react';
import { setTheme } from 'blink-engage';
import { appTheme } from './theme';
export function BlinkEngageThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
await setTheme(appTheme);
setReady(true);
})();
}, []);
if (!ready) return null;
return <>{children}</>;
}
How theming actually works
- Atomic full replace. Each
setTheme(...)call replaces all three dictionaries (colors, font names, icons) inJSBackedThemeunder a barrier write. There is no patch / merge — to update one key, spread the existing theme:setTheme({...appTheme,colors: { ...appTheme.colors, offerWallHeaderTitleLabel: '#FFFFFF' },}); - Unset keys fall back to SDK defaults. Don't worry about specifying every slot — the SDK has its own baked-in palette.
- Unknown keys are dropped silently (with
__DEV__warnings on the JS side viathemeValidation.ts). - Icon sources are resolved via
expo-assetbefore crossing the bridge. Native receives plain filesystem paths, neverrequire()modules. - Fonts must already be registered. Setting
fontNames.offerRewardPointsLabel = 'Teko-SemiBold'only tells the SDK which font to use; the font file itself must be added viaexpo-font(seeapp.config.js).
Theme vs. compile-time icons
There are two ways to ship custom icons:
| Channel | When applied | What you do | Best for |
|---|---|---|---|
themeIcons plugin prop | Build time | Pass image paths in app.config.js; plugin generates xcassets | Static branding that ships with the app |
setTheme({ icons }) | Runtime | Pass require('./icon.png') or { uri: '...' } from JS | User-driven theme switches, A/B tests, server-driven assets |
Both end up in the same JSBackedTheme.icons dictionary at runtime — setTheme just overwrites whatever themeIcons prepared at build time. Use whichever fits your workflow.
The complete list of theme keys lives in BLINK_ENGAGE_COLOR_KEYS, BLINK_ENGAGE_FONT_KEYS, and BLINK_ENGAGE_ICON_KEYS — see ios-react-native-additional-code-snippets.md §6 (src/theme.ts). Companion files: §7 src/themeValidation.ts (validates and sanitizes theme payloads before they cross the bridge) and §8 src/setTheme.ts (resolves icon sources via expo-asset, then dispatches to the native bridge).
14. Cleanup & Teardown
iOS process lifecycle differs from Android — there's no equivalent to onDestroy that fires reliably on app termination. The relevant cleanup happens automatically:
- Reward observer — removed in the module's
OnDestroyblock (when the module instance is torn down, e.g. on Hermes reload). The staticisRewardObserverSetUpflag prevents duplicate registrations across re-creations. OfferWallView— when the React tree unmounts the view,didMoveToWindow(window: nil)fires and the Swift class detaches its embeddedUINavigationControllerviatearDownOfferWall(), breaking the strong reference cycle.- Modal offer wall — if you call
showOfferWalland never dismiss it before the app terminates, iOS dismisses presented view controllers as part of normal process termination. No explicit cleanup is required.
In short: there is no explicit teardown call the host app needs to make. The module cleans up its own observers; the OS cleans up the rest.
15. Building & Prebuilding
Prebuild (Generate Native Projects)
npx expo prebuild --platform ios
# or use the project script:
npm run prebuild:ios
This regenerates the ios/ directory and runs all config plugins, which will:
- Add the
BlinkEngageSwift Package toproject.pbxproj(six pbxproj sections). - Set
GADApplicationIdentifierinInfo.plist. - Inject the BlinkEngage SDK initialization block into
AppDelegate.swift'sapplication(_:didFinishLaunchingWithOptions:)(license keys, currency, reward callback,MobileAds.shared.start). - Create
Images.xcassets/<AssetName>.imageset/directories for anythemeIconslisted inapp.config.js.
Use
--cleanwhen switching SDK versions. A non-clean prebuild applies plugins on top of an existingios/directory and will not re-resolve SPM packages whose versions have changed.npx expo prebuild --platform ios --cleandeletesios/and regenerates from scratch — slower (~1 minute extra) but always correct. For day-to-day work the non-clean prebuild is fine.
Development Build
# Local simulator build
npm run build:ios:dev:sim
# Or via EAS
npm run build:ios:dev:sim:eas
Preview / Production
npm run build:ios:preview
npm run build:ios:prod
Verifying the Plugin Ran Correctly
After prebuild, you can inspect the generated native files:
# Check that BlinkEngage SPM package was added
grep "blinkengage-ios" ios/*.xcodeproj/project.pbxproj | head -3
# Check that AppDelegate has the SDK init
grep -A 2 "BRScanManager.shared().licenseKey" ios/*/AppDelegate.swift
# Check that Info.plist has GADApplicationIdentifier
plutil -extract GADApplicationIdentifier xml1 -o - ios/*/Info.plist
# Check that theme imagesets were created
ls ios/*/Images.xcassets/ | grep -E "Icon"
If any of these come up empty, the plugin didn't run — check that 'blink-engage/plugin' is registered correctly in app.config.js and that npm install completed without errors (the prepare script must have built the plugin).
16. Configuration Reference
Plugin Props
Props are grouped into platform blocks (ios, android) plus a shared block. Only iOS and shared props are listed here.
ios block
| Prop | Type | Required | Description |
|---|---|---|---|
licenseKey | string | Yes | BlinkReceipt license key (from .env) |
prodIntelKey | string | No | BlinkReceipt Product Intelligence key (enables PI features when present) |
sdkVersion | string | No | SPM exact-version pin for the BlinkEngage package — pass an explicit value (e.g. "1.5.1") so prebuilds are reproducible |
debugModeEnabled | boolean | No | Passed to BlinkEngageSDK.start(debugMode:) (1.4.0+) — registers the device as a Google Mobile Ads test device and relaxes receipt fraud validation. Default false. Leave off for production. |
Shared block (applies to both platforms)
| Prop | Type | Required | Description |
|---|---|---|---|
gadAppId | string | Yes | Google Ad Manager 360 iOS Application ID (from .env) |
rewardCurrencyName | string | No | Display name for rewards (default: "points") |
rewardCurrencyPerDollar | number | No | Conversion rate, e.g. 100 = "100 points per $1" (default: 100) |
userPayoutPercentage | number | No | Percentage of reward value shown to the user (default: 100) |
baseReward | number | No | Reward returned for ScanFinished from the AppDelegate rewardCallback (default: 10) |
themeIcons | object | No | Per-icon source paths; copied into Images.xcassets at prebuild time |
Environment Variables
| Variable | Description |
|---|---|
IOS_BLINK_LICENSE_KEY | BlinkReceipt license key |
IOS_PROD_INTEL_KEY | BlinkReceipt Product Intelligence key |
GAD_APP_ID | Google Ad Manager 360 Application ID |
iOS Info.plist (written by plugin or app.config.js)
| Key | Source | Required | Notes |
|---|---|---|---|
GADApplicationIdentifier | Plugin (gadAppId prop) | Yes | Set to your Ad Manager 360 App ID |
NSCameraUsageDescription | app.config.js ios.infoPlist | Yes | Required for the receipt camera; missing = crash on first launch |
NSPhotoLibraryUsageDescription | app.config.js ios.infoPlist | Yes | Required if users can pick existing receipt images |
ITSAppUsesNonExemptEncryption | app.config.js ios.infoPlist | Recommended | false for most apps — TestFlight asks if missing |
Artifacts Generated by the Plugin (at prebuild time)
| Path | Written by | Purpose |
|---|---|---|
ios/<App>.xcodeproj/project.pbxproj | withBlinkEngageSPM | SPM package reference + product dependency on main target |
ios/<App>/Info.plist | withBlinkEngageInfoPlist | GADApplicationIdentifier |
ios/<App>/AppDelegate.swift | withBlinkEngageAppDelegate | SDK init + reward callback inside didFinishLaunchingWithOptions |
ios/<App>/Images.xcassets/*.imageset/ | withBlinkEngageIOSAssets | Theme icons |
JS API Summary
| Method | Returns | Description |
|---|---|---|
setUser(params) | Promise<void> | Set user identity (auto-hashes email/phone with SHA-256) |
clearUser() | void | Clear user identity |
showOfferWall(options?) | Promise<boolean> | Present the offer wall as a full-screen modal |
dismissOfferWall() | Promise<boolean> | Dismiss the modal offer wall |
startReceiptScan(options?) | Promise<ReceiptScanResult> | Standalone non-monetized receipt scan; returns parsed scan results |
startMonetizedScan() | Promise<boolean> | Standalone monetized scan — runs the BlinkEngage post-scan flow (ads, promo matching, rewards) without first showing the offer wall. Rewards arrive via onReward. |
addRewardListener(listener) | EventSubscription | Subscribe to reward events; call .remove() to unsubscribe |
setTheme(theme) | Promise<void> | Atomically replace colors / fonts / icons used by the SDK UI |
Components
| Component | Props | Description |
|---|---|---|
OfferWallView | style?, showFloatingAction? | Embedded offer wall as an ExpoView |
17. Integration Checklist
Use this checklist before shipping to verify nothing was missed:
-
.envcontainsIOS_BLINK_LICENSE_KEY,IOS_PROD_INTEL_KEY(if using PI), andGAD_APP_ID -
app.config.jshas theblink-engage/pluginregistered with correct nested props (ios,android, shared) -
app.config.jsios.infoPlisthasNSCameraUsageDescriptionand (if needed)NSPhotoLibraryUsageDescription -
npx expo prebuild --platform iosruns without errors -
project.pbxprojreferences theBlinkEngageSPM package (grep blinkengage-ios ios/*.xcodeproj/project.pbxproj) -
Info.plistcontainsGADApplicationIdentifier -
AppDelegate.swiftcontainsBlinkEngageSDK.start(debugMode:),BRScanManager.shared().licenseKey = ..., andBlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(...) - Theme imagesets created in
ios/<App>/Images.xcassets/(ifthemeIconsis set) - App boots without crashing — confirm in Xcode console that
BlinkReceiptandBlinkEngagemodules loaded - At least one user identifier set (
emailHashorphoneHash) before showing the offer wall -
OfferWallViewrenders correctly withstyle={{ flex: 1 }}(if using embedded mode) -
showOfferWall()presents a full-screen modal (if using modal mode) - Camera permission prompt appears the first time a scan starts
- Reward events received in JS via
addRewardListener() - Receipt scan completes and shows ad loading → receipt summary → rewards
- Webhook endpoint receiving
ReceiptProcessedandRewardUpdateevents -
app-ads.txtconfigured and publicly accessible at your domain root -
ios.debugModeEnabledisfalse(or omitted) for production builds - If using
setTheme, fonts referenced viafontNamesare registered withexpo-font
18. Troubleshooting
SPM package not resolving
- "Missing package product 'BlinkEngage'": prebuild succeeded but Xcode's package graph is stale. In Xcode: File → Packages → Reset Package Caches, then Resolve Package Versions.
- "No such module 'BlinkEngage'": the SPM steps ran but linking didn't. Check that
project.pbxprojcontains both anXCSwiftPackageProductDependencyand aPBXBuildFileforBlinkEngage. The "Failure modes" table in Section 5.1 tells you which of the six steps is missing for which symptom.
import BlinkEngage fails inside the module's own Swift files
The BlinkEngageExpoModule pod can't see SPM-built frameworks. Verify the pod's xcconfig includes both PODS_CONFIGURATION_BUILD_DIR and PODS_CONFIGURATION_BUILD_DIR/PackageFrameworks in FRAMEWORK_SEARCH_PATHS and SWIFT_INCLUDE_PATHS (see Section 6.1). Run pod install from ios/ and clean build the project.
AppDelegate code missing after prebuild
- The plugin only injects when it can find
return super.application(application, didFinishLaunchingWithOptions: launchOptions). If your app uses a custom AppDelegate template that doesn't include this call, the injection is skipped silently. Either restore the standard Expo template or extend the plugin. - If
BRScanManager.shared()is already present (e.g. from a previous run), the plugin won't re-inject. After significant changes to plugin props, runexpo prebuild --cleanto force a regeneration.
App crashes on first scan with "This app has crashed because it attempted to access privacy-sensitive data without a usage description"
NSCameraUsageDescription is missing from Info.plist. Add it to app.config.js under ios.infoPlist and re-run prebuild — the plugin does not inject this for you.
Offer wall not rendering (embedded mode)
- The
OfferWallViewneeds a non-zero size — make sure you passstyle={{ flex: 1 }}or explicit width/height. - Check that the parent React Native view actually lays out — wrap in a
<View style={{ flex: 1 }}>if needed. - If you see "Could not find host view controller" in the Xcode console, the view was added outside a UIViewController hierarchy. This shouldn't happen in normal Expo apps; if it does, check that Expo's React Native screen container is the parent (e.g. you're not embedded in a custom container that breaks the responder chain).
No ads displayed during scan
GADApplicationIdentifiermay be missing fromInfo.plist— Google Mobile Ads silently disables itself when it's absent. Verify withplutil -extract GADApplicationIdentifier xml1 -o - ios/*/Info.plist.- The Activation backend may not be returning ad placements for your Google Ad Manager 360 configuration. Coordinate with Actual Account Management.
- Ensure
MobileAds.shared.start(...)is called — it is, by the AppDelegate injection. If you replaced the AppDelegate template, you may have lost it.
No rewards in JS after a successful scan
- Listener registered before the scan? Subscribe via
addRewardListeneronce at app start (or screen mount), not after the scan starts. - Reward observer alive? Check the Xcode console for
🔔 [BlinkEngageModule] Reward observer set up. If absent, the module never reachedOnCreate— usually a sign the JS bundle hasn't loaded the module yet. enableBlinkEngage = truefor monetized scans. If you callBRScanManager.shared().enableBlinkEngage = falseand never reset it, the SDK skips the post-scan reward pipeline entirely.setUsercalled? Without a registered user identity, the offer wall does not load promotions andPromorewards never fire.
Reward fires twice for the same receipt
ScanFinished events are deduplicated by blinkReceiptId natively. If you're still seeing duplicates, check that you're not adding multiple listeners (each useEffect without proper cleanup adds a new subscription). The standard pattern:
useEffect(() => {
const sub = BlinkEngage.addRewardListener(handler);
return () => sub.remove(); // <-- crucial
}, []);
"@MainActor" / Swift concurrency errors when building the module
BlinkEngageSDK.shared.appearance is @MainActor-isolated under recent Xcodes. The module's OnCreate must wrap the assignment in Task { @MainActor in ... } — see Section 6.2. If you're seeing this error, your BlinkEngageModule.swift is missing that wrap (or is calling BlinkEngageSDK.shared.appearance = ... from a non-main context).
Theme changes don't appear
setTheme(...)is a full replace, not a patch — to change one color, spread the previous theme:setTheme({...appTheme,colors: { ...appTheme.colors, offerWallHeaderBackground: '#000' },});- Currently-rendered SDK screens may need to be re-presented to pick up the new theme. Themes apply on next SDK lookup, not retroactively.
- Font-name overrides require the font file to be registered (via
expo-font). If the font isn't loaded, the SDK silently falls back to its default. - Icon paths from
setThememust be absolute filesystem paths. If you skipexpo-assetresolution and passrequire()directly, the bridge will reject the call. Always use the wrappersetThemefromblink-engage/src/setTheme.ts.