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, add the SPM package, and write the SDK config toInfo.plist(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 ← Writes all SDK config (gadAppId, license, reward)
└─► Images.xcassets ← Creates imagesets for theme icons
│
▼
modules/blink-engage/ios/ ← Expo native module (Swift)
├── BlinkEngageAppDelegate.swift ← AppDelegate subscriber: SDK init from Info.plist
├── 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 by the BlinkEngageAppDelegate subscriber (from plist): │
│ • BlinkEngageSDK.start(debugMode:) (1.4.0+, one-shot) │
│ • BRScanManager.shared().licenseKey │
│ • BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig( │
│ currencyName: …, currencyPerDollar: …, userPayoutPercentage: │
│ …, currencyImage: …, │
│ rewardCallback: { context, rewardAmount, blinkReceiptId in │
│ NotificationCenter.default.post(name: "BlinkEngageReward") │
│ }) │
│ • MobileAds.shared.start(...) │
│ (enableBlinkEngage is set per-scan on BRScanOptions, not here) │
└──────────────────────────────────────────────┬──────────────────────┘
│ 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 15.1 or later (set by the module's podspec; the BlinkEngage SDK requires iOS 15.0+)
- 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"],
"appDelegateSubscribers": ["BlinkEngageAppDelegate"]
},
"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.ios.appDelegateSubscribers— registersBlinkEngageAppDelegate(inios/BlinkEngageAppDelegate.swift) as anExpoAppDelegateSubscriber. Expo invokes itsapplication(_:didFinishLaunchingWithOptions:)from the app's generated AppDelegate, so the module bootstraps the SDK without any edits to the hostAppDelegate.swift(see Section 5.3).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 three things:
- Adds the
BlinkEngageSwift Package toproject.pbxproj(six pbxproj sections — see Section 5.1) - Writes the SDK config to
Info.plist—GADApplicationIdentifier, BlinkReceipt license / prod-intel, and the reward currency settings (name, conversion, payout, display styling). These are read at launch by theBlinkEngageAppDelegatesubscriber (Section 5.3); the plugin does not editAppDelegate.swift. - 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.7.0' 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.7.0) |
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.7.0',
};
// 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 Keys
Rather than baking configuration into AppDelegate.swift, the plugin writes every value the SDK needs into Info.plist. The module's AppDelegate subscriber (Section 5.3) reads them back at launch. This keeps the generated AppDelegate.swift stock and makes configuration fully declarative.
const withBlinkEngageInfoPlist: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withInfoPlist(config, (config) => {
// Per-platform — iOS uses its own Ad Manager 360 app id.
if (props.ios.gadAppId) {
config.modResults.GADApplicationIdentifier = props.ios.gadAppId;
}
// BlinkReceipt credentials.
config.modResults.BlinkReceiptLicenseKey = props.ios.licenseKey;
if (props.ios.prodIntelKey) {
config.modResults.BlinkReceiptProdIntelKey = props.ios.prodIntelKey;
}
// BlinkEngage runtime configuration.
config.modResults.BlinkEngageDebugModeEnabled =
props.ios.debugModeEnabled ?? false;
config.modResults.BlinkEngageRewardCurrencyName =
props.rewardCurrencyName || 'points';
config.modResults.BlinkEngageRewardCurrencyPerDollar =
props.rewardCurrencyPerDollar ?? 100;
// Stored as a percent (0..100); the subscriber converts to the
// fraction [0.4, 1.0] the SDK expects.
config.modResults.BlinkEngageRewardUserPayoutPercentage =
props.userPayoutPercentage ?? 100;
config.modResults.BlinkEngageBaseReward = props.baseReward ?? 10;
// Reward currency display styling (optional — omitted keys fall back to
// SDK defaults in BlinkEngageAppDelegate). Enums are passed as strings
// and mapped to the BlinkEngage enums in Swift.
if (props.currencyCode) {
config.modResults.BlinkEngageRewardCurrencyCode = props.currencyCode;
}
config.modResults.BlinkEngageRewardCurrencyCodePosition =
props.currencyCodePosition || 'Leading';
config.modResults.BlinkEngageRewardLabelStyle =
props.rewardLabelStyle || 'CurrencyImage';
config.modResults.BlinkEngageRewardMessagingStyle =
props.rewardMessagingStyle || 'CurrencyName';
config.modResults.BlinkEngageRewardRounding =
props.rewardRounding || 'Whole';
if (props.rewardImageLocations?.length) {
config.modResults.BlinkEngageRewardImageLocations =
props.rewardImageLocations.join(',');
}
return config;
});
};
| Info.plist key | Source prop | Read by |
|---|---|---|
GADApplicationIdentifier | ios.gadAppId | Google Mobile Ads SDK |
BlinkReceiptLicenseKey | ios.licenseKey | BlinkEngageAppDelegate |
BlinkReceiptProdIntelKey | ios.prodIntelKey | BlinkEngageAppDelegate |
BlinkEngageDebugModeEnabled | ios.debugModeEnabled | BlinkEngageAppDelegate |
BlinkEngageRewardCurrencyName | rewardCurrencyName | BlinkEngageAppDelegate |
BlinkEngageRewardCurrencyCode | currencyCode | BlinkEngageAppDelegate |
BlinkEngageRewardCurrencyCodePosition | currencyCodePosition | BlinkEngageAppDelegate |
BlinkEngageRewardCurrencyPerDollar | rewardCurrencyPerDollar | BlinkEngageAppDelegate |
BlinkEngageRewardUserPayoutPercentage | userPayoutPercentage | BlinkEngageAppDelegate |
BlinkEngageRewardLabelStyle | rewardLabelStyle | BlinkEngageAppDelegate |
BlinkEngageRewardMessagingStyle | rewardMessagingStyle | BlinkEngageAppDelegate |
BlinkEngageRewardRounding | rewardRounding | BlinkEngageAppDelegate |
BlinkEngageRewardImageLocations | rewardImageLocations | BlinkEngageAppDelegate |
BlinkEngageBaseReward | baseReward | BlinkEngageAppDelegate |
How the amount is rendered — pick one style, not a sequence.
rewardLabelStyleselects a single presentation for the reward amount; the three values are alternatives, not a fallback chain:
Style Renders as Uses CurrencyImage(default)1,234+ coin iconrewardImageLocationscontrols which surfaces show the iconCurrencyName1,234 pointscurrencyNameCurrencyCode$1,234/1,234€currencyCode+currencyCodePosition
currencyCodeandcurrencyCodePositionare only drawn under theCurrencyCodestyle — andCurrencyCodefalls back toCurrencyNamewhen nocurrencyCodeis set (you can't render a code that doesn't exist).rewardMessagingStylestyles the post-scan toast copy the same way (CurrencyName/CurrencyCode/NoAmount), independently of the label style.
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 SDK Initialization (AppDelegate Subscriber)
This changed. Earlier versions of the plugin injected the SDK bootstrap directly into the host
AppDelegate.swiftviawithAppDelegate. That string-surgery approach was brittle (it had to locatedidFinishLaunchingWithOptionsand splice code into it) and put module concerns into the app's source. The plugin no longer touchesAppDelegate.swift.
Instead, SDK initialization lives in an Expo AppDelegate subscriber that ships inside the module: BlinkEngageAppDelegate.swift (a class conforming to ExpoAppDelegateSubscriber). Expo invokes its application(_:didFinishLaunchingWithOptions:) from the app's generated AppDelegate, with no host-side code edits.
Two pieces wire it up:
-
Registration —
expo-module.config.jsonlists the subscriber under the iOS platform:{"platforms": ["ios", "android"],"ios": {"modules": ["BlinkEngageModule"],"appDelegateSubscribers": ["BlinkEngageAppDelegate"]}} -
Configuration — the subscriber reads the values the plugin wrote to
Info.plist(Section 5.2) instead of having them hardcoded. At launch it callsBlinkEngageSDK.start(debugMode:)(one-shot guarded, required since 1.4.0), setsBRScanManager.shared().licenseKey/.prodIntelKey, assignsBlinkEngageSDK.shared.rewardConfig, and starts Google Mobile Ads.
The essential shape (full source in Section 6.6):
public class BlinkEngageAppDelegate: ExpoAppDelegateSubscriber {
private static var didStart = false
public func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let info = Bundle.main
let debugMode =
(info.object(forInfoDictionaryKey: "BlinkEngageDebugModeEnabled") as? Bool) ?? false
if !Self.didStart {
Self.didStart = true
BlinkEngageSDK.start(debugMode: debugMode) // exactly once, before any .shared access
}
BRScanManager.shared().licenseKey =
(info.object(forInfoDictionaryKey: "BlinkReceiptLicenseKey") as? String) ?? ""
BRScanManager.shared().prodIntelKey =
(info.object(forInfoDictionaryKey: "BlinkReceiptProdIntelKey") as? String) ?? ""
// Reward config — every value read from Info.plist (see §5.2), incl. the
// optional display-styling keys mapped to BlinkEngage enums. The callback
// posts BlinkEngageReward notifications observed by BlinkEngageModule.swift.
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: /* BlinkEngageRewardCurrencyName */ "points",
// currencyCode / currencyCodePosition / label / messaging / rounding /
// imageLocations — all read from Info.plist; see the snippets file.
currencyPerDollar: /* BlinkEngageRewardCurrencyPerDollar */ 100.0,
userPayoutPercentage: /* BlinkEngageRewardUserPayoutPercentage ÷ 100 */ 1.0,
currencyImage: UIImage(named: "OfferRewardIcon"),
rewardCallback: { context, rewardAmount, blinkReceiptId in /* … */ return nil }
)
MobileAds.shared.start(completionHandler: nil as GADInitializationCompletionHandler?)
return true
}
}
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.enableBlinkEngageis no longer set globally here. As of SDK 1.7.0 it moved offBRScanManageronto eachBRScanOptionsinstance, so it is set per scan at every camera launch (see Section 6.2 / 6.3) —truefor monetized scans,falsefor standalone.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 prop is expressed as a percent (0..100); the subscriber divides by 100 after reading it fromInfo.plist.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.7.0', // SPM exact-version pin
gadAppId: process.env.GAD_APP_ID || '', // iOS Ad Manager 360 app id
debugModeEnabled: false, // passed to BlinkEngageSDK.start(debugMode:)
},
android: {
// ... see android.md (uses its own android.gadAppId)
},
// Shared (both platforms)
rewardCurrencyName: 'points',
rewardCurrencyPerDollar: 100,
userPayoutPercentage: 100,
baseReward: 10,
// Reward currency display styling (optional — omit any to use
// the SDK default). currencyCode / currencyCodePosition only
// render under a *Code* style (see the note below).
// currencyCode: '$', // default: null → disables code styles
// currencyCodePosition: 'Leading', // Leading ($1,234) | Trailing (1,234€)
rewardLabelStyle: 'CurrencyImage', // CurrencyImage | CurrencyName | CurrencyCode
rewardMessagingStyle: 'CurrencyName', // CurrencyName | CurrencyCode | NoAmount
rewardRounding: 'Whole', // Whole | Decimal
rewardImageLocations: [
// Surfaces showing the coin icon; omit for all four.
'OfferWallItem',
'ReceiptTotalReward',
'ReceiptBoost',
'ReceiptTask',
],
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 5 Swift files (module, AppDelegate subscriber, embedded view, theme, scan-results serializer) 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 => '15.1' }
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"] ?? [:]
let texts = theme["texts"] ?? [:] // 1.7.0: AppearanceTextKey overrides
JSBackedTheme.shared.apply(colors: colors, fontNames: fontNames, icons: icons, texts: texts)
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
}
let scanOptions = BRScanOptions()
// 1.7.0: enableBlinkEngage is set per-scan on BRScanOptions.
scanOptions.enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: rootViewController,
cameraType: .uxEnhanced,
scanOptions: scanOptions,
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
}
BlinkEngageModuleDelegate.shared.standaloneScanPromise = promise
let scanOptions = BRScanOptions()
// 1.7.0: disable BlinkEngage per-scan for standalone (non-monetized) scanning.
scanOptions.enableBlinkEngage = false
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) {
let scanOptions = BRScanOptions()
scanOptions.enableBlinkEngage = true // 1.7.0: per-scan flag
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: scanOptions,
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)
}
}
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)
}
}
func scanningErrorOccurred(_ error: Error) {
standaloneScanPromise?.reject("E_SCAN_ERROR", error.localizedDescription)
standaloneScanPromise = nil
}
}
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)
// 1.7.0: the SDK no longer draws its own header on child (store /
// group) offer lists — it relies on the host nav bar for the title.
// We style that bar and toggle it per-screen (hidden on the offer-
// wall root, shown on child screens) via the delegate below.
nav.delegate = self
nav.isNavigationBarHidden = true // root shows first → start hidden
Self.configureNavigationBarAppearance(nav.navigationBar)
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
}
// MARK: - Navigation bar styling
/// Dark brand color (#142641) for the child-screen title + back button.
private static let navBarForegroundColor = UIColor(
red: 20.0 / 255.0, green: 38.0 / 255.0, blue: 65.0 / 255.0, alpha: 1.0
)
/// Opaque white bar appearance, built once and shared. Applied both to the
/// nav bar and (in willShow) to each incoming VC's navigationItem so the
/// destination bar is fully styled before the push — otherwise UIKit
/// cross-fades the default appearance into ours mid-transition.
static let navBarAppearance: UINavigationBarAppearance = {
let a = UINavigationBarAppearance()
a.configureWithOpaqueBackground()
a.backgroundColor = .white
a.shadowColor = nil
a.titleTextAttributes = [
.foregroundColor: navBarForegroundColor,
.font: UIFont.systemFont(ofSize: 20, weight: .semibold),
]
let back = UIBarButtonItemAppearance()
back.normal.titleTextAttributes = [.foregroundColor: navBarForegroundColor]
a.backButtonAppearance = back
return a
}()
private static func configureNavigationBarAppearance(_ bar: UINavigationBar) {
bar.standardAppearance = navBarAppearance
bar.scrollEdgeAppearance = navBarAppearance
bar.compactAppearance = navBarAppearance
bar.tintColor = navBarForegroundColor
bar.isTranslucent = false
bar.prefersLargeTitles = false
}
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
}
}
// Hide the nav bar on the offer-wall root, show it (styled) on child screens.
// Toggling in willShow — snapping with animated:false, then settling via the
// transition coordinator — keeps the bar's frame stable so the title doesn't
// drift and the back button doesn't flicker during the push/pop.
extension OfferWallView: UINavigationControllerDelegate {
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
// Pin our appearance on the incoming screen before it animates in.
viewController.navigationItem.standardAppearance = Self.navBarAppearance
viewController.navigationItem.scrollEdgeAppearance = Self.navBarAppearance
viewController.navigationItem.compactAppearance = Self.navBarAppearance
let shouldHide = viewController === navigationController.viewControllers.first
guard animated, let coordinator = navigationController.transitionCoordinator else {
navigationController.setNavigationBarHidden(shouldHide, animated: false)
return
}
navigationController.setNavigationBarHidden(shouldHide, animated: false)
coordinator.animate(alongsideTransition: nil) { context in
if context.isCancelled {
let landedOnRoot = navigationController.topViewController
=== navigationController.viewControllers.first
navigationController.setNavigationBarHidden(landedOnRoot, animated: false)
}
}
}
}
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) {
let scanOptions = BRScanOptions()
scanOptions.enableBlinkEngage = true // 1.7.0: per-scan flag
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: scanOptions,
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 + host-provided header — as of SDK 1.7.0 the SDK no longer draws its own header on child (store / group) offer lists; it surfaces the screen title through the host navigation bar instead. So the wrapper is required not just for the SDK's push transitions but also to render the title. We style that bar (opaque white, dark title + back button) and, via theUINavigationControllerDelegate, hide it on the offer-wall root and show it on child screens. Doing the toggle inwillShow(snap withanimated: false, then settle through the transition coordinator) keeps the bar frame stable so the title doesn't drift and the back button doesn't flicker mid-transition.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 four dictionaries — colors, font names, icons, and text overrides — 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() }
// 1.7.0 Theme protocol: isMerchantIconEnabled + globalFontMatrix flags,
// plus the color/font/icon/text(forKey:) lookups below. (isRewardIconEnabled
// was removed in 1.4.0 — the reward icon now ships via
// BlinkEngageRewardConfig.currencyImage.)
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] = [:]
private var _texts: [AppearanceTextKey: String] = [:] // 1.7.0
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] } }
// 1.7.0: new required Theme method — overrides on-screen labels.
func text(forKey key: AppearanceTextKey) -> String? { queue.sync { _texts[key] } }
func apply(
colors rawColors: [String: String],
fontNames rawFonts: [String: String],
icons rawIcons: [String: String],
texts rawTexts: [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
}
var parsedTexts: [AppearanceTextKey: String] = [:]
for (k, value) in rawTexts {
guard let key = Self.textKey(from: k) else { continue }
// Empty is a valid override (e.g. icon-only button), so only trim.
parsedTexts[key] = value.trimmingCharacters(in: .whitespacesAndNewlines)
}
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self._colors = parsedColors
self._fontNames = parsedFonts
self._icons = parsedIcons
self._texts = parsedTexts
}
}
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 "offerWallBackground": return .offerWallBackground
case "offerWallSectionHeaderLabel": return .offerWallSectionHeaderLabel
// … ~100 more cases — see ios-react-native-additional-code-snippets.md §2
// NOTE: the offerWallHeader* color/font keys were removed in 1.7.0.
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 }
// 1.7.0: maps the two AppearanceTextKey slots (offer-wall floating button
// expanded / collapsed labels). See snippets §2.
private static func textKey(from s: String) -> AppearanceTextKey? { /* 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.
6.6 SDK Initialization — BlinkEngageAppDelegate.swift
Create file:
modules/blink-engage/ios/BlinkEngageAppDelegate.swift
This is where the SDK is bootstrapped at app launch. It's an Expo AppDelegate subscriber — a class conforming to ExpoAppDelegateSubscriber that Expo calls from the app's generated AppDelegate. Registering it in expo-module.config.json (ios.appDelegateSubscribers, see the Expo Module Config subsection) means the module configures the SDK without any edits to the host AppDelegate.swift — the plugin no longer injects code there.
Every value it needs is read from Info.plist, where the config plugin wrote them (Section 5.2).
import ExpoModulesCore
import BlinkReceipt
import BlinkEngage
import GoogleMobileAds
import UIKit
public class BlinkEngageAppDelegate: ExpoAppDelegateSubscriber {
// start(debugMode:) must run exactly once before any BlinkEngageSDK.shared access.
private static var didStart = false
public func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let info = Bundle.main
let debugMode =
(info.object(forInfoDictionaryKey: "BlinkEngageDebugModeEnabled") as? Bool) ?? false
if !Self.didStart {
Self.didStart = true
BlinkEngageSDK.start(debugMode: debugMode)
}
let licenseKey =
(info.object(forInfoDictionaryKey: "BlinkReceiptLicenseKey") as? String) ?? ""
BRScanManager.shared().licenseKey = licenseKey
BRScanManager.shared().prodIntelKey =
(info.object(forInfoDictionaryKey: "BlinkReceiptProdIntelKey") as? String) ?? ""
// Reward config — values from Info.plist; icon + locations fixed here.
let currencyName =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardCurrencyName") as? String) ?? "points"
let currencyPerDollar =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardCurrencyPerDollar") as? NSNumber)?.doubleValue ?? 100
// Plugin writes a percent (0..100); SDK wants a fraction in [0.4, 1.0].
let payoutPercent =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardUserPayoutPercentage") as? NSNumber)?.doubleValue ?? 100
let payoutFraction = payoutPercent / 100
let baseReward =
(info.object(forInfoDictionaryKey: "BlinkEngageBaseReward") as? NSNumber)?.doubleValue ?? 10
// Reward currency display styling — optional Info.plist keys (omitted →
// SDK defaults). Enums arrive as strings and map to the BlinkEngage enums.
let currencyCode =
info.object(forInfoDictionaryKey: "BlinkEngageRewardCurrencyCode") as? String
let codePosition: RewardCurrencyCodePosition =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardCurrencyCodePosition") as? String)
== "Trailing" ? .trailing : .leading
let labelStyle: RewardCurrencyLabelStyle
switch info.object(forInfoDictionaryKey: "BlinkEngageRewardLabelStyle") as? String {
case "CurrencyName": labelStyle = .currencyName
case "CurrencyCode": labelStyle = .currencyCode
default: labelStyle = .currencyImage
}
let messagingStyle: RewardCurrencyMessagingTextStyle
switch info.object(forInfoDictionaryKey: "BlinkEngageRewardMessagingStyle") as? String {
case "CurrencyCode": messagingStyle = .currencyCode
case "NoAmount": messagingStyle = .noAmount
default: messagingStyle = .currencyName
}
let rounding: RewardCurrencyRounding =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardRounding") as? String)
== "Decimal" ? .decimal : .whole
// "OfferWallItem,ReceiptBoost" → OptionSet; empty/absent → .all.
// Note: Android's "OfferWallItem" maps to iOS `.offerCell`.
let locationsString =
(info.object(forInfoDictionaryKey: "BlinkEngageRewardImageLocations") as? String) ?? ""
let parsedLocations = locationsString
.split(separator: ",")
.reduce(into: [] as RewardCurrencyImageLocations) { set, name in
switch name.trimmingCharacters(in: .whitespaces) {
case "OfferWallItem": set.insert(.offerCell)
case "ReceiptTotalReward": set.insert(.receiptTotalReward)
case "ReceiptBoost": set.insert(.receiptBoost)
case "ReceiptTask": set.insert(.receiptTask)
default: break
}
}
let imageLocations: RewardCurrencyImageLocations =
parsedLocations.isEmpty ? .all : parsedLocations
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: currencyName,
currencyCode: currencyCode,
currencyCodePosition: codePosition,
currencyPerDollar: currencyPerDollar,
userPayoutPercentage: payoutFraction,
currencyImage: UIImage(named: "OfferRewardIcon"),
currencyImageLocations: imageLocations,
rewardCurrencyLabelStyle: labelStyle,
rewardCurrencyMessagingTextStyle: messagingStyle,
rewardRounding: rounding,
rewardCallback: { context, rewardAmount, blinkReceiptId in
var userInfo: [String: Any] = ["context": context]
if let receiptId = blinkReceiptId { userInfo["blinkReceiptId"] = receiptId }
switch context {
case "ScanFinished":
userInfo["amount"] = baseReward
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return NSNumber(value: baseReward)
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?)
return true
}
}
Why a subscriber instead of editing AppDelegate.swift:
- No host-source edits. The module owns its own launch logic; the app's
AppDelegate.swiftstays stock. Expo links the subscriber automatically fromexpo-module.config.json. - Declarative config. Every tunable comes from
Info.plist(written by the plugin), so the same compiled subscriber works for any app — change a prop inapp.config.js, re-prebuild, done. enableBlinkEngageis intentionally not set here. As of 1.7.0 it's a per-scanBRScanOptionsflag (see Section 6.2), so there's no global default to configure at launch.
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.
Standalone (monetized) receipt scan
If you want the full monetized flow — ad loading, promotion matching, and rewards — but without first showing the offer wall, use startMonetizedScan. Unlike startReceiptScan, it does not return the parsed receipt; rewards arrive through the onReward event exactly as they do from a scan launched inside the offer wall, so make sure a listener is registered first (Listening for Rewards).
await BlinkEngage.startMonetizedScan(); // resolves true once the camera is presented
// reward(s) arrive via addRewardListener — not via this promise
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)
│
▼
rewardCallback in the BlinkEngageAppDelegate subscriber
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: {
offerWallBackground: '#0B1F33',
offerWallSectionHeaderLabel: '#FFFFFF',
offerWallFloatingButtonBackground: '#FFC72C',
offerWallFloatingButtonLabel: '#000000',
// ... see BlinkEngageColorKey for the full list
//
// NOTE: the offerWallHeader* color/font keys were removed in 1.7.0 —
// child offer lists no longer have an SDK-drawn header (their title
// shows in the host navigation bar instead).
},
fontNames: {
offerWallSectionHeaderLabel: '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, offerWallSectionHeaderLabel: '#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). - Write the SDK config to
Info.plist—GADApplicationIdentifier, BlinkReceipt license / prod-intel, and the reward currency / payout / base-reward / display-styling keys (read at launch by theBlinkEngageAppDelegatesubscriber). - 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 Info.plist has the config keys (SDK init reads these at launch)
plutil -extract GADApplicationIdentifier xml1 -o - ios/*/Info.plist
plutil -extract BlinkReceiptLicenseKey xml1 -o - ios/*/Info.plist
plutil -extract BlinkEngageBaseReward xml1 -o - ios/*/Info.plist
# Confirm the plugin did NOT inject into AppDelegate (SDK init now lives in the
# module's AppDelegate subscriber). This should print nothing:
grep "BRScanManager.shared().licenseKey" ios/*/AppDelegate.swift || echo "OK — AppDelegate is stock"
# Check that theme imagesets were created
ls ios/*/Images.xcassets/ | grep -E "Icon"
If the SPM / Info.plist checks 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). To confirm the AppDelegate subscriber is wired up, look for BlinkEngageAppDelegate in the generated provider: grep -r BlinkEngageAppDelegate ios/Pods/Target\ Support\ Files/.
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) |
gadAppId | string | Yes | Google Ad Manager 360 iOS Application ID (from GAD_APP_ID) |
sdkVersion | string | No | SPM exact-version pin for the BlinkEngage package — pass an explicit value (e.g. "1.7.0") 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 |
|---|---|---|---|
rewardCurrencyName | string | No | Display name for rewards (default: "points") |
currencyCode | string | No | Optional symbol/code (e.g. "$", "€"). Only rendered under a CurrencyCode style. Omit → null. |
currencyCodePosition | string | No | "Leading" ($1,234) or "Trailing" (1,234€). Only relevant when a code is shown. (default: "Leading") |
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) |
rewardLabelStyle | string | No | "CurrencyImage" (default), "CurrencyName", or "CurrencyCode" — how the amount renders |
rewardMessagingStyle | string | No | Scan-toast copy style: "CurrencyName" (default), "CurrencyCode", or "NoAmount" |
rewardRounding | string | No | "Whole" (default) or "Decimal" |
rewardImageLocations | string[] | No | Surfaces showing the currency image. Subset of "OfferWallItem", "ReceiptTotalReward", "ReceiptBoost", "ReceiptTask". Omit → all. |
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 (ios.gadAppId prop) | Yes | Set to your iOS 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 + BlinkReceipt license/prod-intel + reward config keys (read by the BlinkEngageAppDelegate subscriber) |
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,BlinkReceiptLicenseKey, and theBlinkEngageReward*config keys -
BlinkEngageAppDelegateis registered underios.appDelegateSubscribersinexpo-module.config.json(it bootstraps the SDK at launch — the hostAppDelegate.swiftis not modified) - 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.
SDK not initializing after prebuild
- The SDK is bootstrapped by the
BlinkEngageAppDelegatesubscriber, not by editingAppDelegate.swift. Confirm it is registered underios.appDelegateSubscribersinexpo-module.config.jsonand that the module is linked (npm installran itspreparescript). - Confirm the config keys actually landed in
Info.plist— e.g.plutil -extract BlinkReceiptLicenseKey xml1 -o - ios/*/Info.plist. If they're missing, the plugin didn't run; 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 runs in theBlinkEngageAppDelegatesubscriber at launch. If the subscriber isn't registered inexpo-module.config.json(ios.appDelegateSubscribers), the SDKs never start.
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. As of 1.7.0 this is set per-scan on theBRScanOptionspassed tostartStaticCamera(...)(not globally onBRScanManager). A monetized scan must passscanOptions.enableBlinkEngage = true; a standalone scan sets itfalseand runs no post-scan reward pipeline.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, offerWallBackground: '#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.