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, add the SPM package, and write the SDK config to Info.plist (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 ← 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.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 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 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"],
"appDelegateSubscribers": ["BlinkEngageAppDelegate"]
},
"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.
  • ios.appDelegateSubscribers — registers BlinkEngageAppDelegate (in ios/BlinkEngageAppDelegate.swift) as an ExpoAppDelegateSubscriber. Expo invokes its application(_:didFinishLaunchingWithOptions:) from the app's generated AppDelegate, so the module bootstraps the SDK without any edits to the host AppDelegate.swift (see Section 5.3).
  • 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 three things:

  1. Adds the BlinkEngage Swift Package to project.pbxproj (six pbxproj sections — see Section 5.1)
  2. Writes the SDK config to Info.plistGADApplicationIdentifier, BlinkReceipt license / prod-intel, and the reward currency settings (name, conversion, payout, display styling). These are read at launch by the BlinkEngageAppDelegate subscriber (Section 5.3); the plugin does not edit AppDelegate.swift.
  3. 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.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:

SectionPurpose
XCRemoteSwiftPackageReferenceDeclares the package URL + version requirement (e.g. exactVersion 1.7.0)
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.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 keySource propRead by
GADApplicationIdentifierios.gadAppIdGoogle Mobile Ads SDK
BlinkReceiptLicenseKeyios.licenseKeyBlinkEngageAppDelegate
BlinkReceiptProdIntelKeyios.prodIntelKeyBlinkEngageAppDelegate
BlinkEngageDebugModeEnabledios.debugModeEnabledBlinkEngageAppDelegate
BlinkEngageRewardCurrencyNamerewardCurrencyNameBlinkEngageAppDelegate
BlinkEngageRewardCurrencyCodecurrencyCodeBlinkEngageAppDelegate
BlinkEngageRewardCurrencyCodePositioncurrencyCodePositionBlinkEngageAppDelegate
BlinkEngageRewardCurrencyPerDollarrewardCurrencyPerDollarBlinkEngageAppDelegate
BlinkEngageRewardUserPayoutPercentageuserPayoutPercentageBlinkEngageAppDelegate
BlinkEngageRewardLabelStylerewardLabelStyleBlinkEngageAppDelegate
BlinkEngageRewardMessagingStylerewardMessagingStyleBlinkEngageAppDelegate
BlinkEngageRewardRoundingrewardRoundingBlinkEngageAppDelegate
BlinkEngageRewardImageLocationsrewardImageLocationsBlinkEngageAppDelegate
BlinkEngageBaseRewardbaseRewardBlinkEngageAppDelegate

How the amount is rendered — pick one style, not a sequence. rewardLabelStyle selects a single presentation for the reward amount; the three values are alternatives, not a fallback chain:

StyleRenders asUses
CurrencyImage (default)1,234 + coin iconrewardImageLocations controls which surfaces show the icon
CurrencyName1,234 pointscurrencyName
CurrencyCode$1,234 / 1,234€currencyCode + currencyCodePosition

currencyCode and currencyCodePosition are only drawn under the CurrencyCode style — and CurrencyCode falls back to CurrencyName when no currencyCode is set (you can't render a code that doesn't exist). rewardMessagingStyle styles 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 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 SDK Initialization (AppDelegate Subscriber)

This changed. Earlier versions of the plugin injected the SDK bootstrap directly into the host AppDelegate.swift via withAppDelegate. That string-surgery approach was brittle (it had to locate didFinishLaunchingWithOptions and splice code into it) and put module concerns into the app's source. The plugin no longer touches AppDelegate.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:

  1. Registrationexpo-module.config.json lists the subscriber under the iOS platform:

    {
    "platforms": ["ios", "android"],
    "ios": {
    "modules": ["BlinkEngageModule"],
    "appDelegateSubscribers": ["BlinkEngageAppDelegate"]
    }
    }
  2. Configuration — the subscriber reads the values the plugin wrote to Info.plist (Section 5.2) instead of having them hardcoded. At launch it calls BlinkEngageSDK.start(debugMode:) (one-shot guarded, required since 1.4.0), sets BRScanManager.shared().licenseKey / .prodIntelKey, assigns BlinkEngageSDK.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. 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 is no longer set globally here. As of SDK 1.7.0 it moved off BRScanManager onto each BRScanOptions instance, so it is set per scan at every camera launch (see Section 6.2 / 6.3) — true for monetized scans, false for standalone.
  • 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 prop is expressed as a percent (0..100); the subscriber divides by 100 after reading it from Info.plist.
  • 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.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.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 => '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_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"] ?? [:]
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:

  • 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 + 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 the UINavigationControllerDelegate, hide it on the offer-wall root and show it on child screens. Doing the toggle in willShow (snap with animated: 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).
  • 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 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.swift stays stock. Expo links the subscriber automatically from expo-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 in app.config.js, re-prebuild, done.
  • enableBlinkEngage is intentionally not set here. As of 1.7.0 it's a per-scan BRScanOptions flag (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: 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.

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:

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)


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

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: {
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) in JSBackedTheme under 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 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. Write the SDK config to Info.plistGADApplicationIdentifier, BlinkReceipt license / prod-intel, and the reward currency / payout / base-reward / display-styling keys (read at launch by the BlinkEngageAppDelegate subscriber).
  3. 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 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

PropTypeRequiredDescription
licenseKeystringYesBlinkReceipt license key (from .env)
prodIntelKeystringNoBlinkReceipt Product Intelligence key (enables PI features when present)
gadAppIdstringYesGoogle Ad Manager 360 iOS Application ID (from GAD_APP_ID)
sdkVersionstringNoSPM exact-version pin for the BlinkEngage package — pass an explicit value (e.g. "1.7.0") 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
rewardCurrencyNamestringNoDisplay name for rewards (default: "points")
currencyCodestringNoOptional symbol/code (e.g. "$", "€"). Only rendered under a CurrencyCode style. Omit → null.
currencyCodePositionstringNo"Leading" ($1,234) or "Trailing" (1,234€). Only relevant when a code is shown. (default: "Leading")
rewardCurrencyPerDollarnumberNoConversion rate, e.g. 100 = "100 points per $1" (default: 100)
userPayoutPercentagenumberNoPercentage of reward value shown to the user (default: 100)
rewardLabelStylestringNo"CurrencyImage" (default), "CurrencyName", or "CurrencyCode" — how the amount renders
rewardMessagingStylestringNoScan-toast copy style: "CurrencyName" (default), "CurrencyCode", or "NoAmount"
rewardRoundingstringNo"Whole" (default) or "Decimal"
rewardImageLocationsstring[]NoSurfaces showing the currency image. Subset of "OfferWallItem", "ReceiptTotalReward", "ReceiptBoost", "ReceiptTask". Omit → all.
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 (ios.gadAppId prop)YesSet to your iOS 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 + BlinkReceipt license/prod-intel + reward config keys (read by the BlinkEngageAppDelegate subscriber)
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, BlinkReceiptLicenseKey, and the BlinkEngageReward* config keys
  • BlinkEngageAppDelegate is registered under ios.appDelegateSubscribers in expo-module.config.json (it bootstraps the SDK at launch — the host AppDelegate.swift is not modified)
  • 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.

SDK not initializing after prebuild

  • The SDK is bootstrapped by the BlinkEngageAppDelegate subscriber, not by editing AppDelegate.swift. Confirm it is registered under ios.appDelegateSubscribers in expo-module.config.json and that the module is linked (npm install ran its prepare script).
  • 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 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 runs in the BlinkEngageAppDelegate subscriber at launch. If the subscriber isn't registered in expo-module.config.json (ios.appDelegateSubscribers), the SDKs never start.

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. As of 1.7.0 this is set per-scan on the BRScanOptions passed to startStaticCamera(...) (not globally on BRScanManager). A monetized scan must pass scanOptions.enableBlinkEngage = true; a standalone scan sets it false and runs no post-scan reward pipeline.
  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, 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 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.