Skip to main content

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:

  1. Set up environment variables — Add IOS_BLINK_LICENSE_KEY, IOS_PROD_INTEL_KEY, and GAD_APP_ID to your .env file (Section 3)
  2. Configure the plugin — Set your license keys, GAM App ID, reward currency, and SDK version in app.config.js (Section 5.5)
  3. 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 companion ios-react-native-additional-code-snippets.md. Then add "blink-engage": "file:./modules/blink-engage" to your root package.json and run npm install
  4. Run prebuildnpx expo prebuild --platform ios to regenerate ios/, run all plugins, and inject SPM + AppDelegate code (Section 15)
  5. Set user identity — Call setUser() after login with at least an email or phone (Section 8)
  6. Render the offer wall — Either <OfferWallView style={{ flex: 1 }} /> (embedded) or await showOfferWall() (modal) (Section 8)
  7. Listen for rewards — Subscribe with addRewardListener() to show earned rewards in real time (Section 9)
  8. Set up webhooks — Configure your backend to receive RewardUpdate events for authoritative reward tracking (Section 10)

The rest of this guide provides the full architecture, implementation details, and reference material.


Table of Contents

  1. Architecture Overview
  2. How the iOS Pieces Fit Together
  3. Prerequisites
  4. Project Structure
  5. Expo Config Plugin — Automated Native Setup
  6. Native Module (iOS)
  7. JavaScript Bridge (Expo Modules API)
  8. Usage in React Native
  9. Handling Rewards (On-Device)
  10. Server-Side Rewards (Webhooks) (Backend)
  11. Receipt Validation
  12. Missed Earnings (Backend)
  13. Theme System
  14. Cleanup & Teardown
  15. Building & Prebuilding
  16. Configuration Reference
  17. Integration Checklist
  18. Troubleshooting

1. Architecture Overview

In a bare React Native project, you would manually:

  • Open Xcode and add BlinkEngage as a Swift Package dependency
  • Edit Info.plist to add GADApplicationIdentifier and camera/photo usage strings
  • Edit AppDelegate.swift to import the SDKs, call BRScanManager.shared().licenseKey = …, configure BlinkEngageSDK.shared, install a rewardCallback, and start MobileAds
  • Create xcassets imagesets for any custom theme icons
  • Create a Swift module conforming to Module and register a native view
  • Hook the module up in your bridging header / Expo module config

With Expo + CNG, all of this is automated:

app.config.js ← Plugin props (license keys, GAM360 ID, etc.)


blink-engage/plugin/src/index.ts ← Config plugin (runs at prebuild)

├─► project.pbxproj ← Adds SPM package "BlinkEngage" (6 sections)
├─► Info.plist ← Sets GADApplicationIdentifier
├─► AppDelegate.swift ← Injects BRScanManager + BlinkEngageSDK setup
└─► Images.xcassets ← Creates imagesets for theme icons


modules/blink-engage/ios/ ← Expo native module (Swift)
├── BlinkEngageModule.swift ← Module definition (events, functions, view)
├── OfferWallView.swift ← Embedded ExpoView wrapping OffersWallViewController
├── JSBackedTheme.swift ← Theme implementation driven from JS via setTheme()
├── BRScanResultsSerializer.swift ← BRScanResults → [String: Any] for JS
└── BlinkEngageExpoModule.podspec ← Module's pod (with framework search paths)


modules/blink-engage/src/ ← TypeScript bridge (Expo Modules API)
├── index.ts ← Public API (setUser, showOfferWall, setTheme, …)
├── BlinkEngageModule.ts ← requireNativeModule('BlinkEngage')
├── OfferWallView.tsx ← requireNativeViewManager('BlinkEngage')
├── theme.ts ← Theme types + key tables
├── setTheme.ts ← JS theme bridge (resolves icons via expo-asset)
└── types.ts ← TypeScript interfaces

Key things to know up front:

  • The Activation/BlinkEngage SDK is integrated via SPM (Swift Package Manager). The plugin writes the SPM entries directly into project.pbxproj so 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 imports BlinkEngage/BlinkReceipt/GoogleMobileAds and relies on the SPM products being on the framework search path.
  • Rewards flow through NotificationCenter, not via direct callbacks. The AppDelegate-installed rewardCallback posts BlinkEngageReward notifications; the module observes them and re-emits as the JS onReward event.
  • Theme is JS-driven. The native JSBackedTheme singleton holds three dictionaries; calling setTheme(...) from JS atomically replaces them. Unset keys fall back to SDK defaults.

2. How the iOS Pieces Fit Together

There are five pieces working together at runtime. Knowing how they connect makes the rest of the guide much easier to read.

┌──────────────────────── Your React Native app ────────────────────────┐
│ │
│ await BlinkEngage.setUser(...) ← src/index.ts (TypeScript) │
│ await BlinkEngage.setTheme(...) │
│ <OfferWallView style={{ flex: 1 }} /> ← src/OfferWallView.tsx │
│ BlinkEngage.addRewardListener(...) │
│ │
└─────────────────────────────────┬─────────────────────────────────────┘
│ JSI / Expo Modules bridge

┌──────────────── modules/blink-engage/ios (Swift) ──────────────────┐
│ │
│ BlinkEngageModule.swift │
│ ├── Function("setUser") → BlinkEngageSDK.shared.user.*Hash │
│ ├── AsyncFunction("setTheme") → JSBackedTheme.shared.apply(...) │
│ ├── AsyncFunction("showOfferWall") → presents modal │
│ ├── View(OfferWallView.self) ─┐ │
│ └── Events("onReward") │ │
│ │ │
│ OfferWallView.swift ───────────┘ │
│ └── embeds OffersWallViewController as a child VC │
│ │
│ JSBackedTheme.swift │
│ └── implements Theme; installed once via │
│ BlinkEngageSDK.shared.appearance = Appearance(theme: ...) │
│ │
└────────────────────────────┬───────────────────────────────────────┘
│ imports BlinkEngage / BlinkReceipt / GoogleMobileAds

┌──────────── BlinkEngage SDK + BlinkReceipt + Google Mobile Ads ─────┐
│ │
│ Initialized in AppDelegate.swift (injected by the config plugin): │
│ • BlinkEngageSDK.start(debugMode:) (1.4.0+, one-shot) │
│ • BRScanManager.shared().licenseKey │
│ • BRScanManager.shared().enableBlinkEngage = true │
│ • BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig( │
│ currencyName: …, currencyPerDollar: …, userPayoutPercentage: │
│ …, currencyImage: …, │
│ rewardCallback: { context, rewardAmount, blinkReceiptId in │
│ NotificationCenter.default.post(name: "BlinkEngageReward") │
│ }) │
│ • MobileAds.shared.start(...) │
│ │
└──────────────────────────────────────────────┬──────────────────────┘
│ rewardCallback fires

NotificationCenter → BlinkEngageModule
observer → sendEvent("onReward")
→ addRewardListener in JS

The interesting bit on iOS — and the main source of confusion for first-time integrators — is that rewards flow through NotificationCenter, not via a direct delegate. The reason: the rewardCallback lives in AppDelegate, which is created by the React Native runtime, while BlinkEngageModule instances are created later by Expo's module system and may even be re-created across reloads. A static notification observer in the module decouples the two.


3. Prerequisites

  • Expo SDK 55+ (React Native 0.83+)
  • iOS deployment target 13.4 or later (set by the module's podspec)
  • Xcode 15+ (the SPM integration uses 15+ behavior — products live in PackageFrameworks/)
  • A valid BlinkReceipt / Activation SDK license key (provided by Actual)
  • A Google Ad Manager (GAM) account with an iOS Application ID
  • An HTTPS server endpoint with a valid SSL certificate to receive webhook POST requests (see Section 10)
  • Environment variables configured in .env:
    • IOS_BLINK_LICENSE_KEY — BlinkReceipt license key
    • IOS_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:

  1. The native Swift Package (BlinkEngage), pulled from https://github.com/BlinkReceipt/blinkengage-ios — the config plugin pins it by exact version and Xcode resolves the rest of the graph (BlinkReceipt, GoogleMobileAds, GoogleUserMessagingPlatform).
  2. 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 companion ios-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 by expo prebuild)
  • "prepare" script builds the config plugin automatically when you run npm install
  • expo-asset is a peer dependency because setTheme() uses it to resolve require('./icon.png') to a filesystem path before crossing the bridge
  • peerDependencies are 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.tsplugin/build/index.js. This compiled output is what expo prebuild executes.

Expo Module Config

Create file: modules/blink-engage/expo-module.config.json

This is the file that tells Expo's autolinking system which native classes to load:

{
"platforms": ["ios", "android"],
"ios": {
"modules": ["BlinkEngageModule"]
},
"android": {
"modules": ["expo.modules.blinkengage.BlinkEngageModule"],
"packages": ["expo.modules.blinkengage.BlinkEngagePackage"]
},
"plugin": "./plugin/build/index.js"
}

What each field does:

  • ios.modules — loads BlinkEngageModule (the Swift class in ios/BlinkEngageModule.swift) as the native module. The class is auto-discovered by name; no manual registration needed.
  • android.* — analogous Android entries (covered in the Android guide)
  • plugin — points to the compiled config plugin that expo prebuild will execute

5. Expo Config Plugin — Automated Native Setup

The config plugin (modules/blink-engage/plugin/src/index.ts) runs during expo prebuild and modifies the generated iOS project. On iOS it does four things:

  1. Adds the BlinkEngage Swift Package to project.pbxproj (six pbxproj sections — see Section 5.1)
  2. Sets GADApplicationIdentifier in Info.plist from gadAppId
  3. Injects SDK initialization code into AppDelegate.swift's application(_:didFinishLaunchingWithOptions:) (license keys, reward currency, rewardCallback, MobileAds.shared.start)
  4. Creates xcassets imagesets for any themeIcons listed in app.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 in ios-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.jsios.sdkVersion (recommended for production: pass an explicit value such as '1.5.1' so prebuilds are reproducible).

Why direct pbxproj surgery instead of "add via Xcode"? Because npx expo prebuild --clean regenerates the entire ios/ directory. Anything you add manually in Xcode is wiped. A config plugin that writes the same six pbxproj sections Xcode would write is the only way for SPM dependencies to survive a clean prebuild.

The six sections the plugin writes:

SectionPurpose
XCRemoteSwiftPackageReferenceDeclares the package URL + version requirement (e.g. exactVersion 1.5.1)
XCSwiftPackageProductDependencySelects the BlinkEngage product from the package
PBXProject.packageReferencesRegisters the package at the project level — what triggers Xcode's "Resolving Package Graph…" step
PBXBuildFileBridges the product dependency into a build phase
PBXFrameworksBuildPhase.filesAdds the framework to "Link Binary With Libraries" on the main app target (Expo projects can have multiple targets)
PBXNativeTarget.packageProductDependenciesAssociates 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 sectionSymptom
XCRemoteSwiftPackageReferenceXcode never resolves the package — "Missing package product 'BlinkEngage'"
XCSwiftPackageProductDependencyPackage resolves but no product is selected — same error as above
PBXProject.packageReferencesXcode skips package resolution entirely — products are never built
PBXBuildFileCompiles fine, fails at link with "Undefined symbols for BlinkEngage"
PBXFrameworksBuildPhase.filesSame link-time failure — framework not on "Link Binary With Libraries"
PBXNativeTarget.packageProductDependenciesBuild order race — Swift compiler can hit import BlinkEngage before the package is built

The full implementation of withBlinkEngageSPM (all six steps, ~150 lines of TypeScript) is in ios-react-native-additional-code-snippets.md §1. The shape:

const withBlinkEngageSPM: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
const objects = xcodeProject.hash.project.objects;

const productName = 'BlinkEngage';
const repositoryUrl = 'https://github.com/BlinkReceipt/blinkengage-ios';
const requirement = {
kind: 'exactVersion',
version: props.ios.sdkVersion || '1.5.1',
};

// Step 1: XCRemoteSwiftPackageReference
// ↳ declares the package URL + version requirement
// Step 2: XCSwiftPackageProductDependency
// ↳ selects the BlinkEngage product from the package
// Step 3: PBXProject.packageReferences
// ↳ registers the package at the project level (triggers "Resolving Package Graph…")
// Step 4: PBXBuildFile
// ↳ bridges the product into a build phase
// Step 5: PBXFrameworksBuildPhase.files (main app target only)
// ↳ adds the framework to "Link Binary With Libraries"
// Step 6: PBXNativeTarget.packageProductDependencies (main app target only)
// ↳ associates the product with the app target so Xcode knows the build order

return config;
});
};

Each step looks up its existing entry by stable identifier (repo name, product name) before creating a new one, so re-running prebuild is safe.

Transitive dependencies. The package's Package.swift declares BlinkReceipt, GoogleMobileAds, and GoogleUserMessagingPlatform as dependencies. The plugin only adds the top-level BlinkEngage product to the Xcode project — Xcode resolves and builds the rest automatically, so import BlinkReceipt and import GoogleMobileAds just work.

5.2 Info.plist Injection

The plugin sets a single key:

const withBlinkEngageInfoPlist: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withInfoPlist(config, (config) => {
if (props.gadAppId) {
config.modResults.GADApplicationIdentifier = props.gadAppId;
}
return config;
});
};

What the plugin does NOT add: camera and photo-library usage strings (NSCameraUsageDescription, NSPhotoLibraryUsageDescription). Add those yourself in app.config.js under ios.infoPlist. Without NSCameraUsageDescription your app will crash the first time it tries to launch the receipt camera.

5.3 AppDelegate Injection

This is the heaviest piece of the plugin. It locates application(_:didFinishLaunchingWithOptions:) and injects:

  1. import BlinkReceipt, import BlinkEngage, import GoogleMobileAds
  2. BlinkEngageSDK.start(debugMode:) — explicit SDK bootstrap (required since 1.4.0), guarded so it runs exactly once per process
  3. BRScanManager.shared() configuration — license key, prod-intel key, enableBlinkEngage = true
  4. BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(…) — single consolidated config holding currency name / conversion / payout / icon / rewardCallback (the callback posts BlinkEngageReward notifications for each reward type: ScanFinished, Promo, Boost, BarcodeCollection)
  5. MobileAds.shared.start(...) — initializes the Google Mobile Ads SDK

Here is the actual generated initialization code (the plugin produces this verbatim, so you can read it exactly as it will appear in your prebuilt AppDelegate.swift):

// ===== BlinkEngage SDK Configuration =====
// 1.4.0 mandates BlinkEngageSDK.start(debugMode:) is called exactly once
// before any access to BlinkEngageSDK.shared. The one-shot guard prevents
// a precondition crash on RN hot reloads / re-entry into didFinishLaunching.
enum BlinkEngageBootstrap { static var didStart = false }
if !BlinkEngageBootstrap.didStart {
BlinkEngageBootstrap.didStart = true
BlinkEngageSDK.start(debugMode: false)
}

BRScanManager.shared().licenseKey = "<IOS_BLINK_LICENSE_KEY>"
BRScanManager.shared().prodIntelKey = "<IOS_PROD_INTEL_KEY>" // empty if unset
BRScanManager.shared().enableBlinkEngage = true

// 1.4.0: reward currency settings + rewardCallback are consolidated into
// an immutable BlinkEngageRewardConfig assigned to BlinkEngageSDK.shared.rewardConfig.
// The reward icon (formerly AppearanceIconKey.offerRewardIcon, removed in 1.4.0)
// is now passed here as currencyImage.
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: "points",
currencyPerDollar: 100.0,
userPayoutPercentage: 1.0, // fraction in [0.4, 1.0]
currencyImage: UIImage(named: "OfferRewardIcon"),
currencyImageLocations: .all,
rewardCallback: { context, rewardAmount, blinkReceiptId in
// 1.4.0 callback signature: (String, NSNumber?, String?) -> NSNumber?
// The old `scanResults` parameter was removed in 1.4.0.
var userInfo: [String: Any] = ["context": context]
if let receiptId = blinkReceiptId { userInfo["blinkReceiptId"] = receiptId }
switch context {
case "ScanFinished":
let amount = 10.0 // <-- baseReward from app.config.js
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return NSNumber(value: amount)
case "Promo", "Boost", "BarcodeCollection":
let amount = rewardAmount?.doubleValue ?? 0
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return nil
default:
return nil
}
}
)

MobileAds.shared.start(completionHandler: nil as GADInitializationCompletionHandler?)
// ===== End BlinkEngage SDK Configuration =====

A few things worth understanding:

  • BlinkEngageSDK.start(debugMode:) must run exactly once. Calling BlinkEngageSDK.shared before start(...) triggers a precondition failure; calling start(...) twice does the same. The didStart guard makes the snippet safe across RN hot reloads. debugMode: true registers the device as a Google Mobile Ads test device and relaxes receipt fraud validation — ship production builds with debugMode: false.
  • enableBlinkEngage = true routes the BlinkReceipt scan result through the BlinkEngage post-scan flow (ad loading screen → matched promotions → reward emission). When you launch a non-monetized standalone scan from the host app, the module flips this to false for the duration of that scan.
  • rewardConfig is set once and is effectively immutable. All currency / payout / icon / callback configuration lives on the single BlinkEngageRewardConfig instance. Assign once at launch; do not reassign per-event.
  • userPayoutPercentage is a fraction in [0.4, 1.0] on the SDK (default 0.6). The plugin's prop is expressed as a percent (0..100) for backwards-compatibility and divides by 100 before passing it in.
  • rewardCallback returns NSNumber? — the value the SDK uses for in-app reward display. For ScanFinished we return baseReward; for the others we return nil (no host-side adjustment to the SDK-calculated amount).
  • The 1.4.0 callback signature is (String, NSNumber?, String?) -> NSNumber?. The old scanResults: NSDictionary? parameter was removed. If you need the parsed receipt data, the on-device BRScanResults from BRScanResultsDelegate.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.
  • blinkReceiptId is 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 JS onReward event 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 1x source — ideally you provide @2x / @3x, but the plugin uses the same file at all scales as a fallback)
  • Contents.json declaring the imageset metadata

The mapping from JS keys to xcasset names is fixed by the SDK:

JS theme keyxcasset name
offerRewardIconOfferRewardIcon
offerWallFloatingButtonIconOfferWallFloatingButtonIcon
missedEarningsNavigationEditButtonIconMissedEarningsNavigationEditButtonIcon
missedEarningsFieldEditIconMissedEarningsFieldEditIcon
postScanReceiptButtonIconPostScanReceiptButtonIcon
postScanBoostDefaultIconPostScanBoostDefaultIcon
postScanSuccessIconPostScanSuccessIcon
ugcBarcodeDetectedIconUgcBarcodeDetectedIcon
ugcToastMessageWarningIconUgcToastMessageWarningIcon

Two ways to ship icons — for the same themeIcons map:

  • 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 via setTheme({ icons: { ... } }) from JS. Useful if your icons depend on user theme preferences. See Section 13.

Most apps just use the compile-time path; it's the simpler default.

5.5 Plugin Props in app.config.js

The plugin is registered with its configuration in app.config.js. Props are grouped into platform-specific (ios, android) and shared blocks. Camera / photo-library usage strings live under the standard Expo ios.infoPlist block, not in the plugin props:

export default {
expo: {
// ...
ios: {
bundleIdentifier: 'com.example.app',
infoPlist: {
NSCameraUsageDescription:
'This app uses the camera to scan and process receipts.',
NSPhotoLibraryUsageDescription:
'This app needs access to your photo library to import receipt images for scanning.',
ITSAppUsesNonExemptEncryption: false,
},
},
plugins: [
[
'blink-engage/plugin',
{
ios: {
licenseKey: process.env.IOS_BLINK_LICENSE_KEY || '',
prodIntelKey: process.env.IOS_PROD_INTEL_KEY,
sdkVersion: '1.5.1', // SPM exact-version pin
debugModeEnabled: false, // passed to BlinkEngageSDK.start(debugMode:)
},
android: {
// ... see android.md
},

// Shared (both platforms)
gadAppId: process.env.GAD_APP_ID || '',
rewardCurrencyName: 'points',
rewardCurrencyPerDollar: 100,
userPayoutPercentage: 100,
baseReward: 10,
themeIcons: {
offerRewardIcon: './assets/icons/coin.png',
postScanSuccessIcon: './assets/icons/coin2.png',
postScanBoostDefaultIcon: './assets/icons/boost.png',
},
},
],
// ... other plugins
],
},
};

The full prop reference is in Section 16.


6. Native Module (iOS)

For React Native developers: This section walks through the Swift files that make up the native module. You don't need to be a Swift expert — the snippets are drop-in, with each file explained in plain English. You'll create 4 Swift files plus 1 podspec, each described below. Long files (the full JSBackedTheme.swift key tables, BRScanResultsSerializer.swift) are kept whole in ios-react-native-additional-code-snippets.md so 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:

TermWhat it means (RN analogy)
ExpoViewExpo's base class for native views — the iOS analogue of an Android ExpoView. Renders inside RN's view hierarchy.
UIViewControllerA "screen" in iOS (≈ a stack screen in React Navigation). The SDK ships its UI as view controllers.
UINavigationControllerA stack-style container that wraps view controllers and handles push/pop. Used by the SDK internally.
Module / ModuleDefinitionExpo's Swift API for declaring functions/events/views exposed to JS — the iOS counterpart to Android's Module() definition.
OnCreate / OnDestroyModule lifecycle hooks. OnCreate runs when the module instance is built; OnDestroy when it's torn down.
NotificationCenteriOS's global pub/sub — like an EventEmitter shared across the whole app.
@MainActorSwift 6 marker meaning "this property/method must be accessed on the main thread." BlinkEngageSDK.shared.appearance is one of these.
weakA non-retaining reference. Used so the module doesn't keep a delegate or VC alive past its natural lifetime.
PromiseAn Expo type that resolves/rejects an async JS call. Mirrors a JavaScript Promise.

6.1 Module Podspec

Create file: modules/blink-engage/ios/BlinkEngageExpoModule.podspec

The Expo module is itself a CocoaPods pod. It depends on ExpoModulesCore for the module API and uses framework search paths to find the SDKs that come from SPM:

require 'json'

package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))

Pod::Spec.new do |s|
s.name = 'BlinkEngageExpoModule'
s.version = package['version']
s.summary = 'BlinkEngage Expo Module'
s.description = 'A native module for BlinkEngage SDK integration'
s.author = 'Microblink'
s.homepage = 'https://microblink.com'
s.platforms = { :ios => '13.4' }
s.source = { git: '' }
s.static_framework = true

s.dependency 'ExpoModulesCore'

# BlinkEngage, BlinkReceipt, and GoogleMobileAds come from SPM (see config plugin).
# ${PODS_CONFIGURATION_BUILD_DIR} resolves to the shared root where Xcode places SPM
# framework products; ${BUILT_PRODUCTS_DIR} would resolve to the pod's own directory
# and would not find them.
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE'=> 'wholemodule',
'FRAMEWORK_SEARCH_PATHS'=> '$(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks"',
'SWIFT_INCLUDE_PATHS' => '$(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks"'
}

s.source_files = '*.swift'
end

Why both FRAMEWORK_SEARCH_PATHS and SWIFT_INCLUDE_PATHS?

  • FRAMEWORK_SEARCH_PATHS finds .framework bundles (used by Objective-C frameworks like BlinkReceipt).
  • SWIFT_INCLUDE_PATHS finds .swiftmodule directories (used by pure Swift modules).

Including both covers every product the SDK ships, regardless of how it's built.

Why both ${PODS_CONFIGURATION_BUILD_DIR} and ${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks?

  • Older Xcodes place SPM products directly in the root build directory.
  • Xcode 15+ places them in a PackageFrameworks/ subdirectory.

Including both keeps the module portable.

6.2 Module Definition — BlinkEngageModule.swift

Create file: modules/blink-engage/ios/BlinkEngageModule.swift

This is the main bridge between JavaScript and native code. It defines the functions you can call from React Native (setUser, clearUser, showOfferWall, dismissOfferWall, setTheme, startReceiptScan, startMonetizedScan), the events it emits to JS (onReward), and registers the embedded offer wall view. The version below is the production-shape file: everything you need, nothing you don't.

import ExpoModulesCore
import BlinkEngage
import BlinkReceipt
import UIKit
import GoogleMobileAds

public class BlinkEngageModule: Module {
// Shared instance so the static reward observer can dispatch to the live module
private static weak var sharedInstance: BlinkEngageModule?

// Static observer to prevent duplicate registrations across module re-creation
private static var rewardObserver: NSObjectProtocol?
private static var isRewardObserverSetUp = false

public func definition() -> ModuleDefinition {
Name("BlinkEngage")

OnCreate {
BlinkEngageModule.sharedInstance = self

// Install the JS-backed theme exactly once. The Appearance wrapper holds
// a reference to our singleton; subsequent setTheme(...) calls mutate
// the singleton's dictionaries in-place, so we never reassign here.
//
// BlinkEngageSDK.shared.appearance is @MainActor-isolated under Swift 6
// strict concurrency, so we hop onto the main actor for the write.
Task { @MainActor in
BlinkEngageSDK.shared.appearance = Appearance(theme: JSBackedTheme.shared)
}

self.setupRewardObserver()
}

OnDestroy {
self.removeRewardObserver()
}

Events("onReward")

// ============================ Native View ===============================

View(OfferWallView.self) {
Prop("showFloatingAction") { (view: OfferWallView, value: Bool) in
view.showFloatingAction = value
}
}

// ============================ Theme =====================================
//
// Atomic full-replace. Unset keys revert to SDK defaults; unknown keys are
// dropped silently (the JS layer emits __DEV__ warnings for those cases).
AsyncFunction("setTheme") { (theme: [String: [String: String]], promise: Promise) in
let colors = theme["colors"] ?? [:]
let fontNames = theme["fontNames"] ?? [:]
let icons = theme["icons"] ?? [:]
JSBackedTheme.shared.apply(colors: colors, fontNames: fontNames, icons: icons)
promise.resolve(nil)
}

// ============================ User ======================================

Function("setUser") { (params: [String: String]) in
if let emailHash = params["emailHash"] { BlinkEngageSDK.shared.user.emailHash = emailHash }
if let phoneHash = params["phoneHash"] { BlinkEngageSDK.shared.user.phoneHash = phoneHash }
if let clientUserId = params["clientUserId"] { BlinkEngageSDK.shared.user.clientUserId = clientUserId }
}

Function("clearUser") {
BlinkEngageSDK.shared.user.emailHash = nil
BlinkEngageSDK.shared.user.phoneHash = nil
BlinkEngageSDK.shared.user.clientUserId = nil
}

// ============================ Offer Wall (modal) ========================
//
// Presents the SDK-provided OffersWallViewController inside a UINavigationController.
// Tapping "Scan Receipt" inside the wall starts a monetized scan via the delegate.

AsyncFunction("showOfferWall") { (options: [String: Any]?, promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}

let delegate = BlinkEngageModuleDelegate.shared
delegate.showFloatingAction = (options?["showFloatingAction"] as? Bool) ?? true

let offerWallVC = OffersWallViewController()
offerWallVC.delegate = delegate

let nav = UINavigationController(rootViewController: offerWallVC)
nav.modalPresentationStyle = .fullScreen
nav.isNavigationBarHidden = true

delegate.currentOfferWallNavController = nav
delegate.currentOfferWallViewController = offerWallVC

rootViewController.present(nav, animated: true) {
promise.resolve(true)
}
}
}

AsyncFunction("dismissOfferWall") { (promise: Promise) in
DispatchQueue.main.async {
if let nav = BlinkEngageModuleDelegate.shared.currentOfferWallNavController {
nav.dismiss(animated: true) {
BlinkEngageModuleDelegate.shared.currentOfferWallNavController = nil
BlinkEngageModuleDelegate.shared.currentOfferWallViewController = nil
promise.resolve(true)
}
} else {
promise.resolve(false)
}
}
}

// ============================ Monetized Scan ============================
//
// Standalone monetized scan — runs the BlinkEngage post-scan flow
// (ad loading, matched promotions, reward emission) without showing
// the offer wall first. Rewards arrive via the `onReward` event in
// exactly the same way as a scan launched from inside the offer wall.

AsyncFunction("startMonetizedScan") { (promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: rootViewController,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: BlinkEngageModuleDelegate.shared
)
promise.resolve(true)
}
}

// ============================ Standalone Scan ===========================
//
// Launches the BlinkReceipt camera with enableBlinkEngage = false.
// Returns the parsed scan results to JS via the resolved promise.

AsyncFunction("startReceiptScan") { (options: [String: Any]?, promise: Promise) in
DispatchQueue.main.async {
guard let rootViewController = self.getRootViewController() else {
promise.reject("E_ROOT_VIEW", "Could not find root view controller.")
return
}

BRScanManager.shared().enableBlinkEngage = false
BlinkEngageModuleDelegate.shared.standaloneScanPromise = promise

let scanOptions = BRScanOptions()
if let detectDuplicates = options?["detectDuplicates"] as? Bool { scanOptions.detectDuplicates = detectDuplicates }
if let countryCode = options?["countryCode"] as? String { scanOptions.countryCode = countryCode }

var cameraType: BRCameraType = .uxEnhanced
if let cameraTypeString = options?["cameraType"] as? String, cameraTypeString == "standard" {
cameraType = .uxStandard
}

BRScanManager.shared().startStaticCamera(
from: rootViewController,
cameraType: cameraType,
scanOptions: scanOptions,
with: BlinkEngageModuleDelegate.shared
)
}
}
}

// ============================ Helpers =======================================

private func getRootViewController() -> UIViewController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.windows.first?.rootViewController else {
return UIApplication.shared.delegate?.window??.rootViewController
}
var top = root
while let presented = top.presentedViewController { top = presented }
return top
}

// ============================ Reward Observer ===============================

private func setupRewardObserver() {
guard !BlinkEngageModule.isRewardObserverSetUp else { return }
BlinkEngageModule.isRewardObserverSetUp = true

BlinkEngageModule.rewardObserver = NotificationCenter.default.addObserver(
forName: Notification.Name("BlinkEngageReward"),
object: nil,
queue: .main
) { notification in
guard let userInfo = notification.userInfo,
let context = userInfo["context"] as? String,
let amount = userInfo["amount"] as? Double else { return }

// Payload shape matches RewardEvent in src/types.ts.
// `blinkReceiptId` is forwarded as a top-level field so the JS layer
// can correlate the reward with the originating receipt (analytics,
// client-side dedup, etc.).
var payload: [String: Any] = ["context": context, "amount": amount]
if let receiptId = userInfo["blinkReceiptId"] as? String {
payload["blinkReceiptId"] = receiptId
}

BlinkEngageModule.sharedInstance?.sendEvent("onReward", payload)
}
}

private func removeRewardObserver() {
if let observer = BlinkEngageModule.rewardObserver {
NotificationCenter.default.removeObserver(observer)
BlinkEngageModule.rewardObserver = nil
BlinkEngageModule.isRewardObserverSetUp = false
}
}
}

Then the delegate that handles the SDK's OffersWallViewControllerDelegate and BRScanResultsDelegate callbacks for both modal-mode and standalone scans:

class BlinkEngageModuleDelegate: NSObject, OffersWallViewControllerDelegate, BRScanResultsDelegate {
static let shared = BlinkEngageModuleDelegate()

weak var currentOfferWallNavController: UINavigationController?
weak var currentOfferWallViewController: OffersWallViewController?

var showFloatingAction: Bool = true
var standaloneScanPromise: Promise?

// OffersWallViewControllerDelegate

func offerWallShouldDisplayFloatingAction(_ vc: OffersWallViewController) -> Bool {
return showFloatingAction
}

func offerWallDidSelectFloatingAction(_ vc: OffersWallViewController) {
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: self
)
}

// BRScanResultsDelegate

func didFinishScanning(_ cameraVC: UIViewController, with scanResults: BRScanResults?) {
if let promise = standaloneScanPromise {
var result: [String: Any] = ["success": true, "cancelled": false]
if let results = scanResults {
result.merge(BRScanResultsSerializer.serialize(results)) { _, new in new }
}
cameraVC.dismiss(animated: true) {
promise.resolve(result)
self.standaloneScanPromise = nil
}
} else {
cameraVC.dismiss(animated: true)
}
BRScanManager.shared().enableBlinkEngage = true
}

func didCancelScanning(_ cameraVC: UIViewController) {
if let promise = standaloneScanPromise {
cameraVC.dismiss(animated: true) {
promise.resolve(["success": false, "cancelled": true])
self.standaloneScanPromise = nil
}
} else {
cameraVC.dismiss(animated: true)
}
BRScanManager.shared().enableBlinkEngage = true
}

func scanningErrorOccurred(_ error: Error) {
standaloneScanPromise?.reject("E_SCAN_ERROR", error.localizedDescription)
standaloneScanPromise = nil
BRScanManager.shared().enableBlinkEngage = true
}
}

Why a static delegate instance? SDK callbacks fire from OffersWallViewController instances that may outlive any single BlinkEngageModule instance (especially during fast refresh / Hermes reloads). A static let shared keeps the delegate alive for the entire process and avoids weak-reference races.

Reward event payload. Every onReward event carries context + amount, plus an optional top-level blinkReceiptId whenever the SDK supplies one (ScanFinished always; Promo / Boost / BarcodeCollection when they originate from a specific receipt). The 1.4.0 rewardCallback no longer receives a BRScanResults parameter, so the on-device event does not carry parsed receipt fields. If you need the full scan data inline with a reward, use the standalone scan flow's BRScanResultsDelegate (the BRScanResultsSerializer snippet in §3 of ios-react-native-additional-code-snippets.md shows the full serialization shape). Server-authoritative reward details for Promo / Boost / Missed Earnings come through the webhooks in Section 10.

Client-side dedup is up to you. The SDK can re-fire ScanFinished for the same receipt during retries / reconnects. Use the blinkReceiptId on the event to drop duplicates in JS (a small Set<string> of recently seen ids, or compared against your reward ledger). The native module does not dedup on your behalf so it can deliver every event the SDK emits unmodified.

6.3 Embedded Offer Wall — OfferWallView.swift

Create file: modules/blink-engage/ios/OfferWallView.swift

This is the iOS counterpart to Android's OfferWallView.kt. It embeds the SDK's OffersWallViewController directly into React Native's view hierarchy, so you can drop <OfferWallView style={{ flex: 1 }} /> into any layout.

Why this is non-trivial. React Native uses Yoga / Flexbox to compute view sizes. iOS UIViewControllers expect to be added to a view-controller hierarchy via addChild/didMove(toParent:), which Expo doesn't do automatically for arbitrary native views. We solve this by walking up the responder chain to find the host view controller, then adding the SDK's view controller as a child of that.

import ExpoModulesCore
import BlinkEngage
import BlinkReceipt
import UIKit

class OfferWallView: ExpoView {
private var offerWallViewController: OffersWallViewController?
private var hostViewController: UIViewController?
private var navController: UINavigationController?

/// Whether to show the floating "Scan Receipt" button.
var showFloatingAction: Bool = true {
didSet { embedOfferWallIfReady() }
}

required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
// Don't embed yet — wait for didMoveToWindow when props are guaranteed to be set.
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
embedOfferWallIfReady()
} else {
tearDownOfferWall()
}
}

private func embedOfferWallIfReady() {
guard window != nil, offerWallViewController == nil else { return }
embedOfferWall()
}

private func embedOfferWall() {
guard offerWallViewController == nil else { return }
guard let hostVC = findViewController() else { return }
self.hostViewController = hostVC

let delegate = OfferWallViewDelegate.shared
delegate.showFloatingAction = self.showFloatingAction
delegate.parentView = self

DispatchQueue.main.async { [weak self] in
guard let self = self, self.offerWallViewController == nil else { return }

let offerWallVC = OffersWallViewController()
offerWallVC.delegate = delegate
self.offerWallViewController = offerWallVC

let nav = UINavigationController(rootViewController: offerWallVC)
nav.isNavigationBarHidden = true // SDK renders header on child screens only
self.navController = nav
hostVC.addChild(nav)
nav.view.frame = self.bounds
nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.addSubview(nav.view)
nav.didMove(toParent: hostVC)
}
}

override func layoutSubviews() {
super.layoutSubviews()
navController?.view.frame = self.bounds
}

private func tearDownOfferWall() {
if let nav = navController {
nav.willMove(toParent: nil)
nav.view.removeFromSuperview()
nav.removeFromParent()
}
navController = nil
offerWallViewController = nil
hostViewController = nil
if OfferWallViewDelegate.shared.parentView === self {
OfferWallViewDelegate.shared.parentView = nil
}
}

private func findViewController() -> UIViewController? {
var responder: UIResponder? = self
while let next = responder?.next {
if let vc = next as? UIViewController { return vc }
responder = next
}
return nil
}
}

class OfferWallViewDelegate: NSObject, OffersWallViewControllerDelegate, BRScanResultsDelegate {
static let shared = OfferWallViewDelegate()
var showFloatingAction: Bool = true
weak var parentView: OfferWallView?

func offerWallShouldDisplayFloatingAction(_ vc: OffersWallViewController) -> Bool {
return showFloatingAction
}

func offerWallDidSelectFloatingAction(_ vc: OffersWallViewController) {
BRScanManager.shared().enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: vc,
cameraType: .uxEnhanced,
scanOptions: BRScanOptions(),
with: self
)
}

func didFinishScanning(_ cameraVC: UIViewController, with scanResults: BRScanResults?) {
cameraVC.dismiss(animated: true)
}
func didCancelScanning(_ cameraVC: UIViewController) {
cameraVC.dismiss(animated: true)
}
}

A few implementation details worth understanding:

  • didMoveToWindow instead of init — Expo sets props after the view is constructed but before it lands in a window. Embedding earlier than didMoveToWindow means we'd potentially embed with a stale showFloatingAction value.
  • UINavigationController wrapper — the SDK renders headers on child screens (offer details, post-scan). Even though we hide the nav bar at the root, the navigation controller is required for the SDK's push transitions to work.
  • autoresizingMask + layoutSubviews — together they keep the embedded VC's view sized to whatever the JS layout dictates (flex: 1, fixed height, whatever).
  • teardown on window == nil — when the React tree unmounts the <OfferWallView />, we explicitly remove the child view controller. Without this, the SDK can hold strong references to delegates that prevent the parent VC from deallocating.

6.4 JS-Backed Theme — JSBackedTheme.swift

Create file: modules/blink-engage/ios/JSBackedTheme.swift

This is the bridge between JS-driven theming and the SDK's Theme protocol. It's a singleton that holds three dictionaries — colors, font names, icons — and an atomic apply(...) method that the module's setTheme function calls.

The full key tables are long (over 100 color slots, plus font and icon mappings). Here's the structure of the file; the complete colorKey(from:), fontKey(from:), and iconKey(from:) mappings are in ios-react-native-additional-code-snippets.md §2:

import UIKit
import BlinkEngage

final class JSBackedTheme: NSObject, Theme {
static let shared = JSBackedTheme()
private override init() { super.init() }

var isRewardIconEnabled: Bool { true }
var isMerchantIconEnabled: Bool { true }
var globalFontMatrix: NSDictionary? { nil }

// Thread-safe storage using a concurrent queue with barrier writes.
// SDK UI may read theme keys from any thread; JS bridge writes from the module queue.
private let queue = DispatchQueue(
label: "com.blinkengage.theme.store",
attributes: .concurrent
)
private var _colors: [AppearanceColorKey: UIColor] = [:]
private var _fontNames: [AppearanceFontNameKey: String] = [:]
private var _icons: [AppearanceIconKey: UIImage] = [:]

func color(forKey key: AppearanceColorKey) -> UIColor? { queue.sync { _colors[key] } }
func fontName(forKey key: AppearanceFontNameKey) -> String? { queue.sync { _fontNames[key] } }
func image(forKey key: AppearanceIconKey) -> UIImage? { queue.sync { _icons[key] } }

func apply(
colors rawColors: [String: String],
fontNames rawFonts: [String: String],
icons rawIcons: [String: String]
) {
var parsedColors: [AppearanceColorKey: UIColor] = [:]
for (k, hex) in rawColors {
guard let key = Self.colorKey(from: k), let color = UIColor(hex: hex) else { continue }
parsedColors[key] = color
}

var parsedFonts: [AppearanceFontNameKey: String] = [:]
for (k, name) in rawFonts {
guard let key = Self.fontKey(from: k) else { continue }
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { parsedFonts[key] = trimmed }
}

var parsedIcons: [AppearanceIconKey: UIImage] = [:]
for (k, path) in rawIcons {
guard let key = Self.iconKey(from: k), let image = Self.loadImage(fromPath: path) else { continue }
parsedIcons[key] = image
}

queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self._colors = parsedColors
self._fontNames = parsedFonts
self._icons = parsedIcons
}
}

private static func loadImage(fromPath path: String) -> UIImage? {
if path.hasPrefix("file://") {
if let url = URL(string: path) { return UIImage(contentsOfFile: url.path) }
return nil
}
return UIImage(contentsOfFile: path)
}

// String → key mappings. Three switch statements (one per category) map
// the JS-facing string keys to the SDK's AppearanceColorKey /
// AppearanceFontNameKey / AppearanceIconKey enums. Together they're ~250
// lines of mechanical case statements; the complete file is in
// ios-react-native-additional-code-snippets.md §2.
private static func colorKey(from s: String) -> AppearanceColorKey? {
switch s {
case "offerWallHeaderBackground": return .offerWallHeaderBackground
case "offerWallHeaderTitleLabel": return .offerWallHeaderTitleLabel
// … ~100 more cases — see ios-react-native-additional-code-snippets.md §2
default: return nil
}
}
private static func fontKey(from s: String) -> AppearanceFontNameKey? { /* see snippets §2 */ return nil }
private static func iconKey(from s: String) -> AppearanceIconKey? { /* see snippets §2 */ return nil }
}

You'll also need a small UIColor(hex:) extension to parse "#RRGGBB" and "#RRGGBBAA" strings — it's in ios-react-native-additional-code-snippets.md §4. Add it to the bottom of BlinkEngageModule.swift (or a separate UIColor+Hex.swift file in the same target).

How the theme gets installed. Once at module creation time:

Task { @MainActor in
BlinkEngageSDK.shared.appearance = Appearance(theme: JSBackedTheme.shared)
}

Appearance holds a strong reference to the Theme it's constructed with. We never reassign appearance again — we mutate the singleton's internal dictionaries in place. The SDK's next color/font/icon lookup reads the new value.

Why this design? A naive "setTheme re-creates Appearance(theme: …) and reassigns" approach would cause the SDK to drop currently-rendered UI and restart its appearance pipeline. The singleton-mutation approach keeps the SDK's view controllers happy across theme changes.

6.5 BRScanResults serialization

Create file: modules/blink-engage/ios/BRScanResultsSerializer.swift

A pure helper that turns a BRScanResults instance into [String: Any] for JS. It's straightforward but verbose — ~120 lines mapping every confidence-wrapped field (results.merchantName?.value, results.total?.value, etc.) onto a flat dictionary.

The complete file is in ios-react-native-additional-code-snippets.md §3 — drop it in as-is. Your TypeScript ScanSuccess type in src/types.ts mirrors this shape one-for-one, so consumers get full type safety on the JS side.


7. JavaScript Bridge (Expo Modules API)

Now that the native Swift files are in place, you need 3 TypeScript files that connect your React Native code to the native module. These are standard Expo Modules API boilerplate.

7.1 Native Module Loader

Create file: modules/blink-engage/src/BlinkEngageModule.ts

import { requireNativeModule } from 'expo-modules-core';

// Loads the native module exposing both functions and the onReward event.
export default requireNativeModule('BlinkEngage');

The string 'BlinkEngage' must match the Name("BlinkEngage") in BlinkEngageModule.swift.

7.2 Native View

Create file: modules/blink-engage/src/OfferWallView.tsx

import { requireNativeViewManager } from 'expo-modules-core';
import React from 'react';
import { StyleProp, ViewStyle } from 'react-native';

export interface OfferWallViewProps {
/** Whether to show the floating "Scan Receipt" button. Default: true */
showFloatingAction?: boolean;
style?: StyleProp<ViewStyle>;
}

const NativeOfferWallView = requireNativeViewManager('BlinkEngage');

export function OfferWallView({
showFloatingAction = true,
style,
}: OfferWallViewProps) {
return (
<NativeOfferWallView
style={style}
showFloatingAction={showFloatingAction}
/>
);
}

export default OfferWallView;

The 'BlinkEngage' string must match the module name registering View(OfferWallView.self).

Cross-platform note: showFloatingAction is honored on iOS (the embedded OffersWallViewController reads it via the delegate). On Android the prop is currently a no-op — if you write cross-platform code, use setFloatingAction config 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.

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 email or phone must be set before displaying the offer wall. The offer wall will not load promotions without a registered user identity.

import * as BlinkEngage from 'blink-engage';

await BlinkEngage.setUser({
email: 'user@example.com',
phone: '+15551234567',
clientUserId: 'user-123',
});

BlinkEngage.clearUser(); // on logout

Email and phone are SHA-256 hashed on the JS side before crossing the bridge — plain text never reaches native.

Listening for Rewards

import * as BlinkEngage from 'blink-engage';

useEffect(() => {
const subscription = BlinkEngage.addRewardListener((event) => {
console.log(`Earned ${event.amount} (${event.context})`);
if (event.context === 'ScanFinished' && event.blinkReceiptId) {
console.log(`From receipt ${event.blinkReceiptId}`);
}
setTotalRewards((prev) => prev + event.amount);
});

return () => subscription.remove();
}, []);

Standalone (non-monetized) receipt scan

If you want a "Scan Receipt" feature outside the offer wall — pure receipt parsing with no ads, no promotion matching, no rewards — use startReceiptScan:

const result = await BlinkEngage.startReceiptScan({
detectDuplicates: true,
cameraType: 'enhanced',
});

if (result.success) {
console.log(`Merchant: ${result.merchantName}`);
console.log(`Total: ${result.total}`);
console.log(`Products: ${result.products?.length}`);
} else if (result.cancelled) {
// User backed out of the camera
} else {
console.error('Scan failed:', result.error);
}

The promise always resolves (even on cancel) with a discriminated-union result — see ReceiptScanResult in src/types.ts.


9. Handling Rewards (On-Device)

Important: The on-device SDK rewards flow is not the only reward channel. Promotional rewards, boost rewards, UGC rewards, and missed earnings rewards are calculated by Actual's backend and delivered to your server via webhooks. Both channels are required:

  • SDK rewards flow — for real-time in-app reward display
  • Webhooks — for reliable server-side tracking, auditability, and crediting users

See Section 10 for webhook details.

Rewards are emitted as native events through the Expo Modules event system. Each event contains:

FieldTypeDescription
contextstringThe reward type: "ScanFinished", "Promo", "Boost", or "BarcodeCollection"
amountnumberPoints earned for this reward
blinkReceiptIdstring?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 the scanResults parameter from rewardCallback, so the on-device reward event does not carry parsed receipt fields. If you need the full parsed BRScanResults inline with a scan, use startReceiptScan() (returns the parsed result via promise) or correlate by blinkReceiptId to your ReceiptProcessed webhook payload (see Section 10).

Reward types

TypeTriggerSource
ScanFinishedEvery successful receipt scanbaseReward in app.config.js → returned from rewardCallback for "ScanFinished"
Promo (Promotion)Scanned receipt matches a qualified promotionBackend calculates based on userPayoutPercentage and matched offer values
BoostUser watches a rewarded ad or redeems a CPA offer on the receipt summary screenSDK emits when the ad/CPA completes
BarcodeCollectionUser scans a qualifying barcode collectionSDK emits when barcode collection reward is triggered

Reward flow on iOS

User scans receipt


BlinkReceipt camera dismisses with BRScanResults


BlinkEngage SDK runs post-scan flow (ad loading → matched promotions → receipt summary)


SDK invokes rewardConfig.rewardCallback(context, rewardAmount, blinkReceiptId)
(1.4.0 signature: scanResults parameter was dropped)


AppDelegate's rewardCallback (injected by config plugin)
posts NotificationCenter.default.post(name: "BlinkEngageReward", userInfo: ...)


BlinkEngageModule's static observer receives the notification


Module forwards { context, amount, blinkReceiptId? } via sendEvent("onReward", payload)


JS addRewardListener callback fires

Multiple events typically arrive for a single scan: one ScanFinished plus zero or more of Promo / Boost / BarcodeCollection. Treat them as a stream, not a single fact.

Session lifecycle

Reward state is scoped per scan. When a new scan starts, the SDK does not reset your local accumulators for you — clear them yourself (e.g. setSessionRewards(0)) when entering the offer wall if you want a per-session counter.

Notes on deduplication

The SDK's reward pipeline can re-fire ScanFinished for the same receipt during retries / network reconnects. The native module passes every event through unmodified, so your JS listener may see the same blinkReceiptId more than once for ScanFinished. The simplest defense is a small in-memory Set<string> of recently-credited blinkReceiptIds; for a stronger guarantee, check against your reward ledger before crediting. Promo, Boost, and BarcodeCollection are server-authoritative and not expected to repeat per qualified action, but the same blinkReceiptId-based check works for them too.


10. Server-Side Rewards (Webhooks) (Backend)

Actual's backend sends webhook POST requests to your server when receipts are processed and rewards are calculated. This is the authoritative source for all backend-calculated rewards (promotions, boosts, UGC, missed earnings).

Setup

Provide the following to your Actual Account Management team:

  • Which API key to use (share securely or only share the last 3 characters)
  • Your app's bundle identifier (e.g. com.yourcompany.app)
  • Your webhook endpoint URL (e.g. https://api.yourcompany.com/webhooks/actual)
  • Which event types to subscribe to: ReceiptProcessed, RewardUpdate, or both
  • Any custom HTTP headers you want included in webhook requests (optional)

Your endpoint must be HTTPS with a valid SSL certificate.

Event Types

EventWhen It FiresWhat It Contains
ReceiptProcessedAfter a receipt is validated and processedReceipt data, promotion match results, fraud status, blink_receipt_id
RewardUpdateWhen a reward is calculated by the backendReward 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_id or blink_receipt_id + reward_type to deduplicate.
  • Store blink_receipt_id: This is the primary key that links ReceiptProcessed, RewardUpdate, and on-device scan context. The same id appears as event.blinkReceiptId on the on-device reward event.
  • No "all done" signal: There is no event indicating that all webhooks for a given receipt have been delivered. Missed earnings can add rewards hours or days later. Design for open-ended reward accrual per receipt.
  • Retry behavior: Webhooks retry 3 times over ~65 seconds, then go to a dead letter queue. Build monitoring for missing expected webhooks.

11. Receipt Validation

The SDK validates receipts automatically before processing rewards. No configuration is required. Validation checks include:

  • Duplicate detection — Receipts already submitted are rejected (cross-client, 14-day window).
  • Fraud detection — Suspicious receipts are flagged.
  • Receipt age — Receipts older than 14 days are rejected.
  • Receipt date — Receipts without a parseable date are rejected.

All validation is handled internally by the SDK. The BRScanResults returned from startReceiptScan() exposes isDuplicate / isFraudulent flags directly (standalone scan flow). For monetized scans, the validation outcome is reflected in the backend ReceiptProcessed webhook payload — correlate by blinkReceiptId.


12. Missed Earnings (Backend)

If a receipt scan does not initially match promotions (e.g., due to OCR quality or missing items), the user may be presented with a missed-earnings correction flow. This allows users to manually verify or correct scanned items to recover eligible rewards.

Missed-earnings rewards are delivered via RewardUpdate webhooks and may arrive hours or days after the original scan. Design your backend to handle open-ended reward accrual per receipt.


13. Theme System

The iOS SDK supports full theming — colors, font names, and icons — via a JS-driven API. The same call signature works on both platforms (Android theming is currently a no-op — calls succeed but have no effect).

Authoring a theme

import { createTheme, setTheme } from 'blink-engage';

const appTheme = createTheme({
colors: {
offerWallHeaderBackground: '#0B1F33',
offerWallHeaderTitleLabel: '#FFFFFF',
offerWallFloatingButtonBackground: '#FFC72C',
offerWallFloatingButtonLabel: '#000000',
// ... see BlinkEngageColorKey for the full list
},
fontNames: {
offerWallHeaderTitleLabel: 'Outfit-Bold',
offerRewardPointsLabel: 'Teko-SemiBold',
// ... see BlinkEngageFontKey for the full list
},
icons: {
postScanSuccessIcon: require('./assets/icons/success.png'),
// ... see BlinkEngageIconKey for the full list
//
// NOTE: `offerRewardIcon` is intentionally NOT a runtime icon slot
// (since SDK 1.4.0). Configure it via the prebuild `themeIcons` map
// in app.config.js — the plugin copies it into the Asset Catalog and
// the AppDelegate snippet passes it to BlinkEngageRewardConfig.currencyImage.
},
});

await setTheme(appTheme);

Applying the theme on app startup

The recommended pattern is to wrap your root component with a provider that calls setTheme once after fonts/assets are ready:

import { useEffect, useState } from 'react';
import { setTheme } from 'blink-engage';
import { appTheme } from './theme';

export function BlinkEngageThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [ready, setReady] = useState(false);

useEffect(() => {
(async () => {
await setTheme(appTheme);
setReady(true);
})();
}, []);

if (!ready) return null;
return <>{children}</>;
}

How theming actually works

  • Atomic full replace. Each setTheme(...) call replaces all three dictionaries (colors, font names, icons) in JSBackedTheme under a barrier write. There is no patch / merge — to update one key, spread the existing theme:
    setTheme({
    ...appTheme,
    colors: { ...appTheme.colors, offerWallHeaderTitleLabel: '#FFFFFF' },
    });
  • Unset keys fall back to SDK defaults. Don't worry about specifying every slot — the SDK has its own baked-in palette.
  • Unknown keys are dropped silently (with __DEV__ warnings on the JS side via themeValidation.ts).
  • Icon sources are resolved via expo-asset before crossing the bridge. Native receives plain filesystem paths, never require() 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 via expo-font (see app.config.js).

Theme vs. compile-time icons

There are two ways to ship custom icons:

ChannelWhen appliedWhat you doBest for
themeIcons plugin propBuild timePass image paths in app.config.js; plugin generates xcassetsStatic branding that ships with the app
setTheme({ icons })RuntimePass require('./icon.png') or { uri: '...' } from JSUser-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 OnDestroy block (when the module instance is torn down, e.g. on Hermes reload). The static isRewardObserverSetUp flag prevents duplicate registrations across re-creations.
  • OfferWallView — when the React tree unmounts the view, didMoveToWindow(window: nil) fires and the Swift class detaches its embedded UINavigationController via tearDownOfferWall(), breaking the strong reference cycle.
  • Modal offer wall — if you call showOfferWall and 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:

  1. Add the BlinkEngage Swift Package to project.pbxproj (six pbxproj sections).
  2. Set GADApplicationIdentifier in Info.plist.
  3. Inject the BlinkEngage SDK initialization block into AppDelegate.swift's application(_:didFinishLaunchingWithOptions:) (license keys, currency, reward callback, MobileAds.shared.start).
  4. Create Images.xcassets/<AssetName>.imageset/ directories for any themeIcons listed in app.config.js.

Use --clean when switching SDK versions. A non-clean prebuild applies plugins on top of an existing ios/ directory and will not re-resolve SPM packages whose versions have changed. npx expo prebuild --platform ios --clean deletes ios/ and regenerates from scratch — slower (~1 minute extra) but always correct. For day-to-day work the non-clean prebuild is fine.

Development Build

# Local simulator build
npm run build:ios:dev:sim

# Or via EAS
npm run build:ios:dev:sim:eas

Preview / Production

npm run build:ios:preview
npm run build:ios:prod

Verifying the Plugin Ran Correctly

After prebuild, you can inspect the generated native files:

# Check that BlinkEngage SPM package was added
grep "blinkengage-ios" ios/*.xcodeproj/project.pbxproj | head -3

# Check that AppDelegate has the SDK init
grep -A 2 "BRScanManager.shared().licenseKey" ios/*/AppDelegate.swift

# Check that Info.plist has GADApplicationIdentifier
plutil -extract GADApplicationIdentifier xml1 -o - ios/*/Info.plist

# Check that theme imagesets were created
ls ios/*/Images.xcassets/ | grep -E "Icon"

If any of these come up empty, the plugin didn't run — check that 'blink-engage/plugin' is registered correctly in app.config.js and that npm install completed without errors (the prepare script must have built the plugin).


16. Configuration Reference

Plugin Props

Props are grouped into platform blocks (ios, android) plus a shared block. Only iOS and shared props are listed here.

ios block

PropTypeRequiredDescription
licenseKeystringYesBlinkReceipt license key (from .env)
prodIntelKeystringNoBlinkReceipt Product Intelligence key (enables PI features when present)
sdkVersionstringNoSPM exact-version pin for the BlinkEngage package — pass an explicit value (e.g. "1.5.1") so prebuilds are reproducible
debugModeEnabledbooleanNoPassed 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)

PropTypeRequiredDescription
gadAppIdstringYesGoogle Ad Manager 360 iOS Application ID (from .env)
rewardCurrencyNamestringNoDisplay name for rewards (default: "points")
rewardCurrencyPerDollarnumberNoConversion rate, e.g. 100 = "100 points per $1" (default: 100)
userPayoutPercentagenumberNoPercentage of reward value shown to the user (default: 100)
baseRewardnumberNoReward returned for ScanFinished from the AppDelegate rewardCallback (default: 10)
themeIconsobjectNoPer-icon source paths; copied into Images.xcassets at prebuild time

Environment Variables

VariableDescription
IOS_BLINK_LICENSE_KEYBlinkReceipt license key
IOS_PROD_INTEL_KEYBlinkReceipt Product Intelligence key
GAD_APP_IDGoogle Ad Manager 360 Application ID

iOS Info.plist (written by plugin or app.config.js)

KeySourceRequiredNotes
GADApplicationIdentifierPlugin (gadAppId prop)YesSet to your Ad Manager 360 App ID
NSCameraUsageDescriptionapp.config.js ios.infoPlistYesRequired for the receipt camera; missing = crash on first launch
NSPhotoLibraryUsageDescriptionapp.config.js ios.infoPlistYesRequired if users can pick existing receipt images
ITSAppUsesNonExemptEncryptionapp.config.js ios.infoPlistRecommendedfalse for most apps — TestFlight asks if missing

Artifacts Generated by the Plugin (at prebuild time)

PathWritten byPurpose
ios/<App>.xcodeproj/project.pbxprojwithBlinkEngageSPMSPM package reference + product dependency on main target
ios/<App>/Info.plistwithBlinkEngageInfoPlistGADApplicationIdentifier
ios/<App>/AppDelegate.swiftwithBlinkEngageAppDelegateSDK init + reward callback inside didFinishLaunchingWithOptions
ios/<App>/Images.xcassets/*.imageset/withBlinkEngageIOSAssetsTheme icons

JS API Summary

MethodReturnsDescription
setUser(params)Promise<void>Set user identity (auto-hashes email/phone with SHA-256)
clearUser()voidClear 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)EventSubscriptionSubscribe to reward events; call .remove() to unsubscribe
setTheme(theme)Promise<void>Atomically replace colors / fonts / icons used by the SDK UI

Components

ComponentPropsDescription
OfferWallViewstyle?, showFloatingAction?Embedded offer wall as an ExpoView

17. Integration Checklist

Use this checklist before shipping to verify nothing was missed:

  • .env contains IOS_BLINK_LICENSE_KEY, IOS_PROD_INTEL_KEY (if using PI), and GAD_APP_ID
  • app.config.js has the blink-engage/plugin registered with correct nested props (ios, android, shared)
  • app.config.js ios.infoPlist has NSCameraUsageDescription and (if needed) NSPhotoLibraryUsageDescription
  • npx expo prebuild --platform ios runs without errors
  • project.pbxproj references the BlinkEngage SPM package (grep blinkengage-ios ios/*.xcodeproj/project.pbxproj)
  • Info.plist contains GADApplicationIdentifier
  • AppDelegate.swift contains BlinkEngageSDK.start(debugMode:), BRScanManager.shared().licenseKey = ..., and BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(...)
  • Theme imagesets created in ios/<App>/Images.xcassets/ (if themeIcons is set)
  • App boots without crashing — confirm in Xcode console that BlinkReceipt and BlinkEngage modules loaded
  • At least one user identifier set (emailHash or phoneHash) before showing the offer wall
  • OfferWallView renders correctly with style={{ 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 ReceiptProcessed and RewardUpdate events
  • app-ads.txt configured and publicly accessible at your domain root
  • ios.debugModeEnabled is false (or omitted) for production builds
  • If using setTheme, fonts referenced via fontNames are registered with expo-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.pbxproj contains both an XCSwiftPackageProductDependency and a PBXBuildFile for BlinkEngage. The "Failure modes" table in Section 5.1 tells you which of the six steps is missing for which symptom.

import BlinkEngage fails inside the module's own Swift files

The BlinkEngageExpoModule pod can't see SPM-built frameworks. Verify the pod's xcconfig includes both PODS_CONFIGURATION_BUILD_DIR and PODS_CONFIGURATION_BUILD_DIR/PackageFrameworks in FRAMEWORK_SEARCH_PATHS and SWIFT_INCLUDE_PATHS (see Section 6.1). Run pod install from ios/ and clean build the project.

AppDelegate code missing after prebuild

  • The plugin only injects when it can find return super.application(application, didFinishLaunchingWithOptions: launchOptions). If your app uses a custom AppDelegate template that doesn't include this call, the injection is skipped silently. Either restore the standard Expo template or extend the plugin.
  • If BRScanManager.shared() is already present (e.g. from a previous run), the plugin won't re-inject. After significant changes to plugin props, run expo prebuild --clean to 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 OfferWallView needs a non-zero size — make sure you pass style={{ 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

  • GADApplicationIdentifier may be missing from Info.plist — Google Mobile Ads silently disables itself when it's absent. Verify with plutil -extract GADApplicationIdentifier xml1 -o - ios/*/Info.plist.
  • The Activation backend may not be returning ad placements for your Google Ad Manager 360 configuration. Coordinate with Actual Account Management.
  • Ensure MobileAds.shared.start(...) is called — it is, by the AppDelegate injection. If you replaced the AppDelegate template, you may have lost it.

No rewards in JS after a successful scan

  1. Listener registered before the scan? Subscribe via addRewardListener once at app start (or screen mount), not after the scan starts.
  2. Reward observer alive? Check the Xcode console for 🔔 [BlinkEngageModule] Reward observer set up. If absent, the module never reached OnCreate — usually a sign the JS bundle hasn't loaded the module yet.
  3. enableBlinkEngage = true for monetized scans. If you call BRScanManager.shared().enableBlinkEngage = false and never reset it, the SDK skips the post-scan reward pipeline entirely.
  4. setUser called? Without a registered user identity, the offer wall does not load promotions and Promo rewards never fire.

Reward fires twice for the same receipt

ScanFinished events are deduplicated by blinkReceiptId natively. If you're still seeing duplicates, check that you're not adding multiple listeners (each useEffect without proper cleanup adds a new subscription). The standard pattern:

useEffect(() => {
const sub = BlinkEngage.addRewardListener(handler);
return () => sub.remove(); // <-- crucial
}, []);

"@MainActor" / Swift concurrency errors when building the module

BlinkEngageSDK.shared.appearance is @MainActor-isolated under recent Xcodes. The module's OnCreate must wrap the assignment in Task { @MainActor in ... } — see Section 6.2. If you're seeing this error, your BlinkEngageModule.swift is missing that wrap (or is calling BlinkEngageSDK.shared.appearance = ... from a non-main context).

Theme changes don't appear

  • setTheme(...) is a full replace, not a patch — to change one color, spread the previous theme:
    setTheme({
    ...appTheme,
    colors: { ...appTheme.colors, offerWallHeaderBackground: '#000' },
    });
  • Currently-rendered SDK screens may need to be re-presented to pick up the new theme. Themes apply on next SDK lookup, not retroactively.
  • Font-name overrides require the font file to be registered (via expo-font). If the font isn't loaded, the SDK silently falls back to its default.
  • Icon paths from setTheme must be absolute filesystem paths. If you skip expo-asset resolution and pass require() directly, the bridge will reject the call. Always use the wrapper setTheme from blink-engage/src/setTheme.ts.