Skip to main content

iOS Activation Integration

This framework extends the BlinkReceipt SDK to enable rewards and monetization functionality. You must first install the BlinkReceipt framework before proceeding.

Requirements

  • iOS: 15.0+
  • Swift: 5.9+
  • Xcode: 16.4+
  • Dependencies: Google-Mobile-Ads-SDK, BlinkReceipt SDK
  • Complete the BlinkReceipt integration for receipt scanning: BlinkReceipt Integration

Installation

  1. In Xcode, go to File > Add Package Dependencies
  2. Enter: https://github.com/BlinkReceipt/blinkengage-ios
  3. Select latest version and add BlinkEngage product

CocoaPods

pod 'BlinkEngage', '~> 1.8.0'

Then run: pod install

App Delegate Setup

Configure BlinkEngage in application(_:didFinishLaunchingWithOptions:). Keep initialization clean by separating scan manager and SDK configuration into dedicated methods.

import BlinkEngage
import BlinkReceipt
import GoogleMobileAds

class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Call before any BRScanManager.shared() calls
#if DEBUG
BlinkEngageSDK.start(debugMode: true)
#else
BlinkEngageSDK.start(debugMode: false)
#endif
configureScanManager()
configureRewardsAndAppearance()
MobileAds.shared.start()
return true
}

private func configureScanManager() {
BRScanManager.shared().licenseKey = "YOUR-BLINKRECEIPT-LICENSE-KEY"
BRScanManager.shared().prodIntelKey = "YOUR-BLINKRECEIPT-PRODINTEL-KEY"
}

private func configureRewardsAndAppearance() {

// User identity — manage via SessionManager (see below)
BlinkEngageSDK.shared.user.emailHash = "hashed_email_string"
BlinkEngageSDK.shared.user.phoneHash = "hashed_phone_string"
BlinkEngageSDK.shared.user.clientUserId = "your_client_user_id"

// Reward configuration — see Reward Currency for full options
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: "points",
currencyPerDollar: 100.0,
userPayoutPercentage: 0.6,
rewardCallback: { context, rewardAmount, blinkReceiptId in
switch context {
case "ScanFinished":
return NSNumber(value: 10.0)
case "Promo", "Boost", "BarcodeCollection":
print("User earned \(rewardAmount?.doubleValue ?? 0) points (receipt: \(blinkReceiptId ?? "nil"))")
return nil
default:
return nil
}
}
)

// Theming — see Theming Guide for full options
BlinkEngageSDK.shared.appearance = Appearance(theme: BlinkEngageTheme())
}
}

BlinkEngageSDK.start(debugMode:) must be called before any BRScanManager.shared() calls. Pass true during development — it registers the device as a GAM test device and relaxes on-device receipt checks. Pass false for App Store and customer-facing builds.

User Session Management

Tie SDK user registration to your own auth flow. BlinkEngage attributes offers and rewards to the registered identity — always keep it in sync with your app's login state.

import BlinkEngage
import Foundation

class SessionManager {

static let shared = SessionManager()

private(set) var isLoggedIn = false

func logIn(emailHash: String, phoneHash: String, userId: String? = nil) {
BlinkEngageSDK.shared.user.emailHash = emailHash
BlinkEngageSDK.shared.user.phoneHash = phoneHash
BlinkEngageSDK.shared.user.clientUserId = userId
isLoggedIn = true
}

func logOut() {
BlinkEngageSDK.shared.user.emailHash = nil
BlinkEngageSDK.shared.user.phoneHash = nil
BlinkEngageSDK.shared.user.clientUserId = nil
isLoggedIn = false
}

func switchUser(emailHash: String, phoneHash: String, userId: String? = nil) {
logOut()
logIn(emailHash: emailHash, phoneHash: phoneHash, userId: userId)
}
}
  • On login: call SessionManager.shared.logIn(...) after your app authenticates the user. The SDK registers the identity automatically.
  • On logout: call logOut() — setting both hashes to nil ensures the SDK doesn't attribute subsequent activity to the previous user.
  • On account switch: switchUser clears the previous identity before registering the new one, so rewards are never cross-attributed.

Presenting the Offer Wall

Wrap the offer wall in a UINavigationController before presenting it. Tapping a store tile or a carousel section's "Show more" pushes a filtered offer list onto that stack — without a navigation controller those screens cannot appear.

class YourViewController: UIViewController {

func showOfferWall() {
let offerWall = OffersWallViewController(offerWallViewType: .all)
offerWall.delegate = self
offerWall.title = "Offers"
offerWall.navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .close,
target: self,
action: #selector(dismissOfferWall)
)
let nav = UINavigationController(rootViewController: offerWall)
configureNavigationBar(nav)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}

@objc private func dismissOfferWall() {
dismiss(animated: true)
}

private func configureNavigationBar(_ nav: UINavigationController) {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(named: "BrandPrimary") ?? .systemBlue
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
nav.navigationBar.standardAppearance = appearance
nav.navigationBar.scrollEdgeAppearance = appearance
nav.navigationBar.tintColor = .white
}
}

extension YourViewController: OffersWallViewControllerDelegate {

// Triggered when the user taps the floating "Scan Receipt" button
// Also fires from store and carousel-group offer lists pushed onto the stack
func offerWallDidSelectFloatingAction(_ viewController: OffersWallViewController) {
let scanOptions = BRScanOptions()
scanOptions.enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: self,
cameraType: .standard,
scanOptions: scanOptions,
with: self
)
}

func offerWallShouldDisplayFloatingAction(_ viewController: OffersWallViewController) -> Bool {
return true
}

// Called on initial load and whenever the user clips or unclips an offer
func offerWall(_ viewController: OffersWallViewController, didUpdateClippedOffersCount count: Int) {
tabBarItem.badgeValue = count > 0 ? "\(count)" : nil
}
}

Tab Bar Embedding

Embed "Offers" and "Saved" tabs in a UITabBarController. The didUpdateClippedOffersCount delegate keeps the Saved badge current without polling.

import BlinkEngage
import BlinkReceipt
import UIKit

class MainTabBarController: UITabBarController, OffersWallViewControllerDelegate {

override func viewDidLoad() {
super.viewDidLoad()

let allOffers = OffersWallViewController(offerWallViewType: .all)
allOffers.delegate = self
allOffers.tabBarItem = UITabBarItem(title: "Offers", image: UIImage(systemName: "tag"), tag: 0)

let clippedOffers = OffersWallViewController(offerWallViewType: .clipped)
clippedOffers.delegate = self
clippedOffers.tabBarItem = UITabBarItem(title: "Saved", image: UIImage(systemName: "heart"), tag: 1)

let homeVC = UIViewController()
homeVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: "house"), tag: 2)

// Wrap each offer wall tab in its own UINavigationController so store
// and carousel-group offer lists can push onto the stack.
viewControllers = [
homeVC,
UINavigationController(rootViewController: allOffers),
UINavigationController(rootViewController: clippedOffers),
]
}

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

func offerWall(_ viewController: OffersWallViewController,
didUpdateClippedOffersCount count: Int) {
if let savedTab = viewControllers?.first(where: { $0.tabBarItem.tag == 1 }) {
savedTab.tabBarItem.badgeValue = count > 0 ? "\(count)" : nil
}
}
}

Offer wall view types:

  • .all — Show all available offers.
  • .clipped — Show only offers the user has clipped/saved.

Push onto a Navigation Stack

If the offer wall lives within an existing navigation flow, push it directly — store tiles and carousel-group "Show more" actions push onto the same stack.

let offerWall = OffersWallViewController(offerWallViewType: .all)
offerWall.delegate = self
navigationController?.pushViewController(offerWall, animated: true)

Receipt Scanning

Start a scan from a button tap or from the offer wall's floating action delegate:

func scanReceipt() {
let scanOptions = BRScanOptions()
scanOptions.enableBlinkEngage = true
BRScanManager.shared().startStaticCamera(
from: self,
cameraType: .standard,
scanOptions: scanOptions,
with: self
)
}

With enableBlinkEngage = true on BRScanOptions, BlinkEngage intercepts scan results automatically. The didFinishScanning:withScanResults: delegate fires but scanResults is always nil — BlinkEngage handles the post-scan flow (loading screen, backend call, receipt summary).

Receipt Details

Use loadReceiptDetails(forBlinkReceiptId:completion:) to reopen the receipt summary for a previously completed scan. Pass the blinkReceiptId from your reward callback — the SDK fetches the stored receipt and returns a ReceiptDetailsViewController ready to present.

Missed earnings corrections are available from receipt details within 12 hours of the original scan.

If the original scan was flagged as a duplicate, the receipt details screen mirrors the live receipt summary behavior: the "We've seen this" status card appears above the merchant name, total rewards show zero, and missed earnings is hidden. The View original and Request review action buttons are not available in the reopened view — only on the live receipt summary. See Duplicate Receipts for details.

BlinkEngageSDK.shared.loadReceiptDetails(forBlinkReceiptId: blinkReceiptId) { [weak self] viewController, error in
guard let self, let viewController else { return }

viewController.onClose = { [weak self] in
self?.dismiss(animated: true)
}

self.present(viewController, animated: true)
}
note

ReceiptDetailsViewController cannot be instantiated directly — always use loadReceiptDetails(forBlinkReceiptId:completion:).

Push Navigation

To open receipt details within an existing navigation flow instead of modally, push the view controller and pop on close:

BlinkEngageSDK.shared.loadReceiptDetails(forBlinkReceiptId: blinkReceiptId) { viewController, error in
guard let viewController else { return }

viewController.onClose = { [weak self] in
self?.navigationController?.popViewController(animated: true)
}

navigationController?.pushViewController(viewController, animated: true)
}

Error Handling

Failures return nil for the view controller and an NSError in the completion block:

SituationError
Empty or whitespace receipt IDThe receipt ID is invalid.
No registered userThe user is not registered.
No saved session for this IDNo receipt was found for this ID.
Corrupt or incomplete dataThe receipt data is incomplete or could not be read.

Store the blinkReceiptId from your reward callback if you want to support reopening receipts.

Styling (Theme)

Control colors, fonts, and images across the SDK by implementing the Theme protocol:

class BlinkEngageTheme: NSObject, Theme {
var isMerchantIconEnabled: Bool { true }

var globalFontMatrix: NSDictionary? {
[
NSNumber(value: UIFont.Weight.regular.rawValue): "YourFont-Regular",
NSNumber(value: UIFont.Weight.medium.rawValue): "YourFont-Medium",
NSNumber(value: UIFont.Weight.semibold.rawValue): "YourFont-SemiBold",
NSNumber(value: UIFont.Weight.bold.rawValue): "YourFont-Bold",
] as NSDictionary
}

func color(forKey key: AppearanceColorKey) -> UIColor? {
switch key {
case .offerWallFloatingButtonBackground: return UIColor(named: "BrandAccent")
case .offerWallFloatingButtonLabel: return .white
case .offerClipButtonBackground: return UIColor(named: "BrandAccent")
case .offerClippedButtonBackground: return UIColor(named: "BrandPrimary")
case .postScanHeaderBackground: return UIColor(named: "BrandPrimary")
case .postScanFooterButtonTitle: return .white
case .errorModalBackButtonLabel: return UIColor(named: "BrandPrimary")
default: return nil
}
}

func fontName(forKey key: AppearanceFontNameKey) -> String? {
switch key {
case .postScanSuccessTitleLabel: return "YourFont-Black"
default: return nil
}
}

func image(forKey key: AppearanceIconKey) -> UIImage? {
switch key {
case .offerWallFloatingButtonIcon: return UIImage(systemName: "barcode.viewfinder")
case .postScanReceiptButtonIcon: return UIImage(systemName: "doc.text.magnifyingglass")
default: return nil
}
}

func text(forKey key: AppearanceTextKey) -> String? {
return nil // return a String to override SDK defaults
}
}

// Apply before presenting any SDK UI:
BlinkEngageSDK.shared.appearance = Appearance(theme: BlinkEngageTheme())

Return nil from any method to use the SDK default for that key. See the Theming Guide for the full reference of all color, font, and icon keys.

Production Reward Handling

Analytics Logging

The reward callback is your integration point for crediting users and feeding analytics. The context values map to distinct user actions:

ContextDirectionWhat to do
"ScanFinished"SDK asks host app — return NSNumberReturn the base scan reward amount
"Promo"SDK informs host app — rewardAmount is setCredit rewardAmount to the user
"Boost"SDK informs host app — rewardAmount is setCredit rewardAmount to the user
"BarcodeCollection"SDK informs host app — rewardAmount is setCredit rewardAmount to the user
"BoostCreditEarned"SDK informs host app (hard currency mode only)Log or track; return nil
"BoostCreditApplied"SDK informs host app (hard currency mode only)Credit rewardAmount to the user; return nil

"BoostCreditEarned" and "BoostCreditApplied" only fire when the app is configured for hard currency mode by a BlinkEngage admin. See Reward Callback Contexts for details.

rewardCallback: { context, rewardAmount, blinkReceiptId in
switch context {
case "ScanFinished":
let reward = 10
Analytics.log("scan_reward", properties: [
"amount": reward,
"receipt_id": blinkReceiptId ?? "unknown"
])
return NSNumber(value: reward)

case "Promo":
Analytics.log("promo_reward", properties: [
"amount": rewardAmount?.doubleValue ?? 0,
"receipt_id": blinkReceiptId ?? "unknown"
])
return nil

case "Boost":
Analytics.log("boost_reward", properties: [
"amount": rewardAmount?.doubleValue ?? 0,
"receipt_id": blinkReceiptId ?? "unknown"
])
return nil

case "BarcodeCollection":
Analytics.log("barcode_reward", properties: [
"amount": rewardAmount?.doubleValue ?? 0,
"receipt_id": blinkReceiptId ?? "unknown"
])
return nil

default:
return nil
}
}

Server-Authoritative Rewards

For server-determined scan rewards, delegate the decision to a reward service:

rewardCallback: { context, rewardAmount, blinkReceiptId in
switch context {
case "ScanFinished":
let scanReward = RewardService.shared.scanRewardForCurrentUser()
RewardService.shared.credit(amount: scanReward, source: context, receiptId: blinkReceiptId)
return NSNumber(value: scanReward)

case "Promo", "Boost", "BarcodeCollection":
if let earned = rewardAmount {
RewardService.shared.credit(amount: earned.intValue, source: context, receiptId: blinkReceiptId)
}
return nil

default:
return nil
}
}

The callback runs on the main thread — keep it fast. Defer heavy work (network calls, database writes) to a background queue.

Client Events

Subscribe to SDK-side events for analytics and debugging. Set eventCallback once during app launch — it fires for the lifetime of the app session.

BlinkEngageSDK.shared.eventCallback = { eventName, metadata in
MyAnalytics.track(eventName, properties: metadata)
}
Event nameWhen it firesMetadata keys
scan_session_startedA BlinkReceipt scan session begins
ad_loading_startedLoading screen is shown
post_scan_viewedReceipt summary screen is shown
offer_wall_viewedAll-offers wall is shown (.all type only; once per app session)
offer_detail_viewedOffer detail screen is opened (once per unique offer per app session)offer_title
offer_clippedUser clips an offeroffer_title

metadata is a [String: Any] dictionary. For events with no metadata the dictionary is empty. For offer_detail_viewed and offer_clipped, metadata["offer_title"] is a String.

Objective-C

BlinkEngageSDK.shared.eventCallback = ^(NSString *eventName, NSDictionary *metadata) {
[MyAnalytics track:eventName properties:metadata];
};

Use the string constants on BlinkEngageSDK for type-safe comparisons:

if ([eventName isEqualToString:BlinkEngageSDK.eventOfferWallViewed]) {
// ...
}

NSString *offerTitle = metadata[BlinkEngageSDK.eventMetadataOfferTitle];