Skip to main content

Android Activation Integration

SDK Initialization​

When the Activation SDK is on the classpath, BlinkReceiptSdk.initialize() automatically detects it and configures the ActivationClient with deviceId, clientUserId, and appBundleId. Set user identity in the onComplete callback.

Kotlin​

import com.microblink.BlinkReceiptSdk
import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.RewardCurrency
import com.actualplatform.activation.RewardPoint
import com.actualplatform.activation.ScanReward
import okio.ByteString.Companion.encodeUtf8

BlinkReceiptSdk.initialize(context, object : InitializeCallback {
override fun onComplete() {
// ActivationClient is now configured with device identity.

// 1. Configure user identification (at least one required)
ActivationClient.instance.apply {
hashedEmail = userEmail.encodeUtf8().sha256().hex()
hashedPhone = userPhone.encodeUtf8().sha256().hex() // optional
}

// 2. Configure in-app currency
ActivationClient.instance.rewardCurrency = RewardCurrency.default(
currencyName = "Points",
userPayoutPercentage = 1.0, // 0–1 scale, clamped to 0.4…1.0
currencyPerDollar = 100.0,
)

// 3. Configure base scan reward
ActivationClient.instance.scanReward = ScanReward(reward = RewardPoint(10.0))
}

override fun onException(throwable: Throwable) {
Log.e("Init", "SDK init failed", throwable)
}
})

Java​

BlinkReceiptSdk.initialize(context, new InitializeCallback() {
@Override
public void onComplete() {
ActivationClient client = ActivationClient.getInstance();
String hash = ByteString.encodeUtf8(email).sha256().hex();
client.setHashedEmail(hash);
}

@Override
public void onException(@NonNull Throwable throwable) {
Log.e("Init", "SDK init failed", throwable);
}
});

The SDK debounces property changes. Rapid sequential sets (e.g., the receipts SDK setting deviceId followed by the host app setting hashedEmail) coalesce into a single registration call.

important

At least one of hashedEmail or hashedPhone must be set before displaying the offer wall. The offer wall will not load promotions without a registered user.

Registration State​

Setting hashedEmail or hashedPhone only starts registration — it does not confirm it. ActivationClient.registrationState exposes the outcome as a StateFlow<RegistrationState>, so you can gate the offer wall on a real registration instead of assuming the debounce-and-network flow above has already completed.

import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.RegistrationState

lifecycleScope.launch {
ActivationClient.instance.registrationState.collect { state ->
when (state) {
is RegistrationState.Idle,
is RegistrationState.Registering -> showLoading()
is RegistrationState.Registered -> showOfferWall() // state.clientId is available here
is RegistrationState.Exception -> handle(state)
}
}
}
StateMeaning
IdleNo registration has been attempted yet — identity or device configuration is still missing.
RegisteringA registration call is in flight.
Registered(clientId)Registration succeeded; the offer wall can be presented.
Exception(...)Registration did not complete. Carries an ActivationException.Registration.

For a one-shot check outside a coroutine — for example, to decide whether to enable an "Earn Rewards" entry point — read the synchronous isRegistered:

if (ActivationClient.instance.isRegistered) {
earnRewardsButton.isEnabled = true
}

Registration exceptions​

RegistrationState.Exception carries an ActivationException.Registration. Not all of them are errors worth showing a shopper:

ExceptionWhat it means
MissingIdentityNeither hashedEmail nor hashedPhone has been set yet.
MissingDeviceConfigurationThe SDK has not yet received its device configuration.
FailedA real registration failure; wraps the underlying error.
note

MissingIdentity and MissingDeviceConfiguration describe the normal pre-login state and reach the flow during ordinary startup. Do not surface them to shoppers — treat them as "not ready yet" and keep waiting. Only Failed represents an error worth logging or displaying.

Added in 1.1.2

registrationState and isRegistered are new in 1.1.2. See Activation Android SDK 1.1.2 for release details.

Presenting the Offer Wall​

The Offer Wall is the primary entry point for user engagement. There are two integration options depending on your app's navigation approach.

OffersWall renders the promotions offer wall as an embeddable composable that fits within your existing navigation stack. The host app retains full control over the navigation chrome (toolbar, back handling) and the scan experience.

When the user taps "Scan Receipt", the onScanReceipt callback fires and the host app launches its own camera flow. Set .activation(true) on ScanOptions to let the SDK handle the post-scan experience (loading screen, receipt summary, rewards) inside the camera activity.

onScanReceipt is a suspend function, but there is nothing to await: launch your camera flow and return. The camera owns the scan session for the scan it performs.

import com.actualplatform.activation.OffersWall

@Composable
fun PromotionsScreen(onBack: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
contract = CameraRecognizerContract(),
) { result ->
// With .activation(true) the camera has already shown loading, any review screen,
// the receipt summary and rewards — there is nothing to forward from here.
if (result is CameraRecognizerResults.Exception) {
Log.e("Scan", "Error: ${result.exception}")
}
}

OffersWall(
onScanReceipt = {
// Launch and return. Do NOT await the result — see the warning below.
launcher.launch(cameraRecognizerOptions)
},
onDismiss = onBack,
)
}

OffersWall Parameters​

ParameterTypeDescription
modifierModifierApplied to the root container.
onScanReceiptsuspend () -> UnitCalled when the user taps "Scan Receipt". Launch your camera flow and return — do not await its result.
onDismiss(() -> Unit)Called on back navigation. When null, the SDK header is hidden.
onNavigationStateChanged(OffersWallNavigationState) -> UnitEmits Browsing and Scanning states for analytics or UI coordination.
Known issue in 1.2.0

Do not hold the scan session open across the camera launch. If onScanReceipt awaits the camera result, a device-tier review request fails with "Scan session not found" — the session is minted on device and the backend has not yet seen it. Launch the camera and return immediately, as above, so the camera owns the session. Addressed in a later release. See Scan sessions.

When to use

You have an existing navigation stack and want to embed the offer wall as one destination, control the toolbar and back button, and use .activation(true) on ScanOptions.

Option B: OffersWallFlow​

OffersWallFlow is a full-screen, self-contained composable that manages the entire promotions experience: offer wall, ads loading, and receipt summary. The SDK owns all internal navigation between these screens.

The key difference: onScanReceipt is a suspend function that must return a ScanReceiptResult (or null if cancelled). The SDK uses this result to drive the internal loading and receipt summary screens.

Only relevant if you're upgrading to 1.2.0+

onResumeScan and onScanAccepted are new in 1.2.0 and both default to null. If your app is on an Activation SDK version earlier than 1.2.0, or you aren't upgrading yet, your existing OffersWallFlow call with only onScanReceipt, onContinue, onException, and onDismiss is unaffected and needs no changes. Add them when you upgrade, to pick up Error Flows' retake and session-finalization behavior — see Activation Android SDK 1.2.0.

import com.actualplatform.activation.OffersWallFlow
import com.actualplatform.activation.ScanReceiptResult

@Composable
fun FullPromotionsFlow(onFinish: () -> Unit) {
OffersWallFlow(
// Suspend until your camera produces a ScanReceiptResult (or null if cancelled).
onScanReceipt = { launchCameraAndAwaitResult() },
// Add another photo to THIS scan, keeping its blinkReceiptId.
onResumeScan = { current -> resumeCameraAndAwaitResult(current) },
// No further photo will be requested for this scan — release the session.
onScanAccepted = { result -> finalizeRecognizerSession() },
onContinue = onFinish,
onException = { exception: ActivationException -> Log.e("Activations", "${exception.message}") },
onDismiss = onFinish,
)
}
Keep the camera inside this composition

onScanReceipt and onResumeScan are suspend functions: the SDK awaits them while OffersWallFlow stays composed. If you push your camera screen onto a navigation back stack that replaces OffersWallFlow, the flow leaves composition, its awaiting coroutine is cancelled, and the result never arrives. Show the camera as an overlay within the same composable, or launch it as a separate Activity via rememberLauncherForActivityResult, which also keeps this composition alive.

Additional OffersWallFlow Parameters​

ParameterTypeDescription
onScanReceiptsuspend () -> ScanReceiptResult?Must return scan results or null if cancelled.
onResumeScan(suspend (current: ScanReceiptResult) -> ScanReceiptResult?)?Called instead of onScanReceipt when the user taps "Retake photo" in a review screen. Resume the open recognizer session and return the updated result for the same scan. Defaults to null.
onScanAccepted((ScanReceiptResult) -> Unit)?Called once a completed scan is accepted for processing and no further photo will be requested for it. Defaults to null.
onContinue() -> UnitCalled when the user taps "Continue" on the receipt summary screen.
onException(ActivationException) -> UnitCalled on errors with an ActivationException.
onResumeScan preserves the receipt identity

onResumeScan must resume your still-open recognizer session and return a result for the same scan — same blinkReceiptId, with media.filePaths carrying every frame captured so far. Leave it null and the flow silently falls back to onScanReceipt, which starts a fresh scan and changes the blinkReceiptId; the retake then reads as a second, unrelated receipt to your backend and to webhooks. Both parameters are new in 1.2.0 — see Error Flows.

Finalizing the recognizer session​

onScanAccepted is the point at which the SDK guarantees it will not ask for another photo of that scan, so it is where a custom camera UI releases its session. With RecognizerView, terminate() tears down the shared recognizer session:

private fun finalizeRecognizerSession() {
// Safe here: onScanAccepted means no further frame will be requested for this scan.
// Only terminate the view that owns the live session — the underlying Recognizer is a
// process-wide singleton, so terminating a stale view poisons an unrelated session.
recognizerView?.terminate()
recognizerView = null
}

If you launch the camera through CameraRecognizerContract instead, the camera Activity owns its own session lifecycle and there is nothing to release — onScanAccepted is then just a signal you can use to clear your own pending state or emit analytics.

See RecognizerView lifecycle for the full session contract, including the ordering rules that avoid RecognizerNotInitializedException.

OffersWallFlow requires you to build ScanReceiptResult yourself

ScanReceiptResult takes com.actualplatform.activation.models.ScanResults, a Boolean validity flag, and com.actualplatform.activation.models.Media. The Paper Receipt camera's CameraRecognizerResults.Success returns different types — com.microblink.core.ScanResults and com.microblink.Media — and does not expose a validity flag at all. The Activation SDK ships no public mapper between them, and CameraRecognizerOptions cannot carry an existing session, so a re-launch through CameraRecognizerContract always starts a new blinkReceiptId and can never satisfy onResumeScan.

In practice that means OffersWallFlow fits hosts that already produce Activation-model ScanResults from their own scanning pipeline. If you are integrating the Paper Receipt camera, use Option A with .activation(true) — the camera Activity performs this mapping internally, presents the review screens, and handles retakes on the same receipt with no host-app code. Contact the Actual team before building against OffersWallFlow with the Paper Receipt camera.

When to use

You want a dedicated full-screen promotions experience with no host app navigation chrome. Do not set .activation(true) on ScanOptions with this option (OffersWallFlow handles post-scan itself).

Starting a Receipt Scan​

When using OffersWall with the Paper Receipt Camera UI, enable the activation post-scan flow on ScanOptions and launch the camera straight from onScanReceipt:

val launcher = rememberLauncherForActivityResult(
contract = CameraRecognizerContract(),
) { result ->
when (result) {
is CameraRecognizerResults.Success -> {
// With .activation(true) the camera already ran the post-scan flow.
// Read result.scanResults only if you also want the raw scan data.
}
is CameraRecognizerResults.Exception -> {
Log.e("Scan", "Error: ${result.exception}")
}
CameraRecognizerResults.Cancelled -> {
// User cancelled the scan
}
}
}

// Build scan options with the activation post-scan flow enabled
val scanOptions = ScanOptions.newBuilder()
.activation(true) // Enables post-scan activation flow
.build()

OffersWall(
onScanReceipt = {
launcher.launch(
CameraRecognizerOptions.Builder()
.options(scanOptions)
.characteristics(cameraCharacteristics)
.build()
)
},
onDismiss = onBack,
)
warning

Do not await the launcher's result inside onScanReceipt. Holding the scan session open across the camera launch makes a device-tier review request fail with "Scan session not found" — see Scan sessions.

When .activation(true) is set on ScanOptions, the camera fragment automatically:

  • Scans the receipt
  • Maps ScanResults to the Activation SDK model
  • Runs the on-device receipt checks and, if something is wrong, presents the matching review screen — see Error Flows
  • Handles "Retake photo" by resuming its own capture session, so the blinkReceiptId is preserved with no host-app code
  • Displays the ads loading screen with promotions
  • Shows the receipt summary with matched offers and rewards
  • Returns to the host app when the user taps "Continue"

Both integration options present the same error-flow screens; they differ only in who handles the retake:

IntegrationWho presents the review screensRetake handling
OffersWall + .activation(true)The camera activityAutomatic — the camera resumes its own capture session
OffersWallFlowThe flow composableYou implement onResumeScan; left null, a retake starts a fresh scan
Runtime safety

If the Activation SDK is not present at runtime, the .activation(true) flag on ScanOptions is silently ignored and the camera returns results normally.

Hosting Activity requirements​

The review screens introduced in 1.2.0 contain text fields, and they inset themselves for the software keyboard from the window's IME insets. Declare adjustResize on whichever Activity hosts the offer flow, or the keyboard will cover the form's input and submit button:

AndroidManifest.xml
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize" />

Activation Theme and Appearance​

The Activation SDK lets you brand every screen it renders — the offer wall, loading, receipt summary, and missed earnings — to match your app, with no Compose wrapping required. Two global hooks work together:

  • Activation.theme — the SDK-wide look: 20 semantic color roles (light + dark palettes), typography, corner-radius shapes, and icons. Set your brand roles once and every SDK screen follows.
  • Activation.appearance — per-screen overrides layered on top: customisable text labels (e.g. the scan-receipt FAB wording) and fine-grained per-element color overrides (e.g. just the receipt-summary header band), each falling back to the theme when unset.

For the full token catalogue and worked examples, see Android Theme Customization; for per-screen labels and per-element color tables, see Android Appearance Customization.

Theming the Camera UI​

While .activation(true) on ScanOptions enables the post-scan flow, the CameraCharacteristics you pass alongside it accepts a custom theme for the camera scanning screen. Define a camera theme that echoes your Activation palette so the hand-off from the offer wall into the camera reads as one continuous experience rather than a separate SDK screen.

Define a style that extends the SDK's BlinkRecognizerStyle, then pass it via .style(...):

val cameraCharacteristics = CameraCharacteristics.Builder()
.style(R.style.BlinkRecognizerStyle_YourBrand) // Apply your brand colors to the camera
.build()

The camera's buttons and tooltip are styled per component and don't inherit from Activation.theme — you set their colors explicitly in the camera theme. See Step 3 — Brand the buttons and tooltip for the pattern.

For the full list of themeable colors, icons, buttons, and tooltips — plus a complete working example — see Camera Theme Customization.

Reward Callback Reference​

On-Device Rewards (SDK)​

The ActivationClient exposes a rewards SharedFlow that emits reward events as they occur during a scan session:

import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.Rewards

LaunchedEffect(Unit) {
ActivationClient.instance.rewards.collect { reward ->
when (reward) {
is Rewards.ScanFinished -> {
Log.d("Rewards", "Base scan reward: ${reward.amount.value}")
}
is Rewards.Promotion -> {
Log.d("Rewards", "Promotion earned: ${reward.amount.value}")
}
is Rewards.Boost -> {
Log.d("Rewards", "Boost earned: ${reward.amount.value}")
}
is Rewards.BoostCreditEarned -> {
Log.d("Rewards", "Boost credit banked: ${reward.amount.value}")
}
is Rewards.BoostCreditApplied -> {
Log.d("Rewards", "Boost credit settled: ${reward.amount.value}")
}
}
totalRewards += reward.amount.value
}
}
Reward TypeWhen It FiresDescription
Rewards.ScanFinishedReceipt scan completes successfullyamount contains the base scan reward configured via ScanReward.
Rewards.PromotionReceipt items match a promotional offeramount contains the points earned.
Rewards.BoostUser completes a rewarded ad (CPA boost)amount contains the points earned; pays out immediately.
Rewards.BoostCreditEarnedA rewarded ad completes under hard currency modeamount contains the credit banked, pending settlement on a later scan.
Rewards.BoostCreditAppliedCredit banked earlier lands on this receiptamount contains the credit settled against this scan.
Added in 1.1.2

Rewards.BoostCreditEarned and Rewards.BoostCreditApplied are new subtypes of the sealed Rewards type, added for hard currency mode. Hard currency mode is enabled per app by the Actual team and requires no host-app configuration — but the new subtypes affect every app: an exhaustive when (reward) over Rewards will not compile until it handles both. Add branches for them (an else -> {} is sufficient if your app will never run in hard currency mode). See the 1.1.2 upgrade notes.

Session lifecycle

Reward state is scoped to a scan session. When a new scan session starts, the SDK starts a new session internally. Clear your local reward accumulators when starting a new flow.

warning

The Android SDK supports base scan rewards via ActivationClient.instance.scanReward = ScanReward(reward = RewardPoint(10.0)), configured during initialization. The Rewards.ScanFinished event emits through the rewards SharedFlow when a receipt scan completes, returning the configured base reward amount (a RewardPoint; read the raw number via .value).

Server-Side Rewards (Webhooks)​

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 the Webhook documentation for the full event reference, and RewardUpdate for reward payloads.

Scan sessions​

A scanSessionId identifies one scan — one trip to the camera and the screens that follow it. Read the current id from ActivationClient.scanSessionId, or observe it via ActivationClient.scanSession.

The camera owns the session. With OffersWall + .activation(true), the camera opens a session when it starts — but only if no session is already open — and closes it when it exits. That ownership is what lets the camera register the session with Actual's backend before any review screen can reference it.

Behavior
Two receipts scanned in one visit to the wallTwo different scanSessionIds, with no session open in between
Receipts per sessionAt most one blinkReceiptId ever appears under one scanSessionId
While the shopper is only browsingNo session exists — the offer-wall fetch carries no scanSessionId
A retake that resumes the same receiptStays in the same session; only a fallback to a genuinely new receipt rotates it

One rule follows from this: never hold a scan session open across the camera launch. Concretely, that means both of these:

  • Do not await the camera result inside OffersWall.onScanReceipt. Launch your camera flow and return.
  • Do not call startScanSession() / endScanSession() yourself when using OffersWall or OffersWallFlow. Those methods remain supported only for hosts that drive scanning with no offer wall at all.

Either one leaves a session open that the camera then rides along on instead of owning, and a session the camera does not own is never registered with the backend.

Known issue in 1.2.0

A scan session is minted on device, and the backend only learns of it from the first request that carries it. Device-tier review requests — a missing date or total, an unreadable photo, a receipt outside the rewards window — are filed before the receipt is submitted for processing, so if the host is holding the session, that review request is the first thing to reference it and fails with "Scan session not found". Nothing throws and nothing reaches onException; the shopper simply cannot submit the review. Letting the camera own the session avoids this. Addressed in a later release.

ActivationClient.close() also ends any open session, so an id never carries over into the next initialize().

Changed in 1.2.0

Before 1.2.0 a session was scoped to the offer wall's lifetime, so every scan in one visit shared an id. See Scan sessions are scoped to one scan.

Error Flows​

A receipt the SDK can't accept no longer ends the flow or reaches your app as an error. Each problem raises its own screen explaining what happened and offering to send the receipt to a human reviewer; dismissing it returns the shopper to the offer wall. Checks run in two tiers — on device before the scan is submitted, and on the server after.

ProblemDetectedWhat the SDK showsRetake offered?
Photo can't be readDevice — from ScanReceiptResult.isValidReceiptInvalid-receipt screenYes
Date or total missingDevice — from ScanResultsMissing-fields form; continues to processing on submitYes
Store name missing aloneDeviceNothing — treated as a complete scan—
Purchase date outside the rewards windowDevice (receiptMaxAgeDays) or serverReceipt-too-old screenNo
Submission limit reachedServerFrequency-cap screen naming each limit—
DuplicateServerReceipt Summary, duplicate banner, rewards suppressed—
Actionable fraud flagServerReceipt Summary, invalid banner, rewards suppressed—

Three things this changes for your integration:

  • Nothing arrives on onException. Receipt verdicts are not errors. Only unrecoverable failures — CameraScan, Submit, ReceiptSummary, and the Registration hierarchy — still reach it. The four exception variants that used to carry these verdicts were removed in 1.2.0.
  • The scan reward is deferred for any flow that ends at a review request; the backend grants it only if a reviewer accepts, arriving later as a RewardUpdate webhook. The missing-fields flow is the exception — it continues to processing, so its reward lands immediately.
  • A correction the shopper types is never merged into the scan. It travels to the reviewer only; the data submitted for processing is always exactly what the scanner read, so a field that was missing at scan time is still missing on the Receipt Summary.

Configure the rewards window with ActivationClient.receiptMaxAgeDays (default and minimum 14). The device-side age check is skipped entirely while TestOptions.Test is active, so fixture receipts of any age still scan.

ActivationClient.instance.receiptMaxAgeDays = 21

See Error Flows for how the checks are ordered and when rewards land, and Error Flows Summary for what the shopper sees in each case.

Added in 1.2.0

Error Flows are new in 1.2.0, along with OffersWallFlow.onResumeScan and onScanAccepted. See Activation Android SDK 1.2.0 for the breaking changes and upgrade steps.

Missed Earnings​

When a receipt scan does not initially match promotions (e.g., due to OCR quality or missing items), the SDK presents a missed-earnings correction flow so users can verify or correct scanned items and recover eligible rewards. The flow is built into the offer wall and managed by the SDK, so no integrator setup is required. See the Missed Earnings user journey for the end-to-end experience.

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

Ad Monetization Setup (Google Ad Manager)​

Actual Activation uses Google Ad Manager (GAM) to serve ads within the SDK experience. This includes ads shown during receipt processing and rewarded ads that trigger boost rewards. See the GAM MCM Setup guide for account configuration.

What GAM Is Used For​

  • Serve ads during the loading screen (while receipts are processing)
  • Serve rewarded ads that users can watch to earn additional rewards (boosts)
  • Enable monetization through advertiser demand integrated into the SDK

Ad rendering and placement are handled by the SDK. No UI implementation is required on your side.

What You Need to Provide​

  • An active Google Ad Manager account
  • Complete MCM (Multiple Customer Management) onboarding if applicable
  • Configure your app-ads.txt file with the required publisher ID
  • Provide required account details to the Actual team during onboarding

Your team may need to coordinate with internal stakeholders (e.g. finance or IT) to access or verify your existing GAM account.

What Actual Manages​

  • Ad demand sourcing and optimization
  • Ad placement within the SDK experience
  • Reward logic tied to ad completion (e.g. boost rewards)
  • Integration between ad events and reward generation

No direct integration with GAM APIs is required from your app.

Debug Mode​

warning

These options are intended for development and testing only. Do not enable them in production builds.

Test Options​

import com.actualplatform.activation.networking.TestOptions

ActivationClient.instance.testOptions = setOf(
// Enable test mode: returns test promotions from the backend and registers
// this device as a Google Ad Manager test device, so ad units serve test creatives.
TestOptions.Test,
)

Debug Placements​

Force specific offer wall layouts to test different UI configurations:

import com.actualplatform.activation.models.PlacementLayout

ActivationClient.instance.placements = setOf(
PlacementLayout.OFFER_WALL_CAROUSEL,
PlacementLayout.OFFER_WALL_LIST,
PlacementLayout.OFFER_WALL_GRID,
)

Logging​

Enable SDK debug logging by adding the following to your project's local.properties:

logcat.state=enabled

For a native Android app, local.properties lives at the root of your project, alongside settings.gradle — i.e. ./<root>/local.properties. If the file doesn't exist yet, create it.

This activates verbose logging across the Paper Receipt and Activation SDKs, including network requests, scan session lifecycle, reward events, and receipt validation results.

Cleanup​

Close the ActivationClient when your application terminates:

Kotlin​

override fun onTerminate() {
BlinkReceiptSdk.terminate()
ActivationClient.instance.close()
super.onTerminate()
}

Java​

@Override
public void onTerminate() {
BlinkReceiptSdk.terminate();
ActivationClient.getInstance().close();
super.onTerminate();
}

View System Integration​

If your app uses traditional XML layouts rather than Compose, embed the offer wall via ComposeView:

findViewById<ComposeView>(R.id.compose_offers_wall).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MaterialTheme {
OffersWall(
onScanReceipt = { /* launch camera */ },
onDismiss = { /* handle back */ },
)
}
}
}

Add the ComposeView to your XML layout:

<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_offers_wall"
android:layout_width="match_parent"
android:layout_height="match_parent" />

Testing Checklist​

  • Paper Receipt SDK initialized and Activation SDK detected automatically
  • At least one user identifier set (hashed email or hashed phone)
  • Currency name and payout percentage configured
  • Offer wall gated on ActivationClient.registrationState (or isRegistered), not assumed
  • Reward flow collecting events via ActivationClient.instance.rewards, including BoostCreditEarned/BoostCreditApplied
  • Offer Wall presenting correctly (OffersWall or OffersWallFlow)
  • Receipt scan completing and showing reward summary
  • Boost ads displaying (use TestOptions.Test for test ad units)
  • Webhook endpoint receiving ReceiptProcessed and RewardUpdate events
  • Missed earnings correction flow accessible
  • Error-flow screens reachable: unreadable photo, missing date/total, receipt outside the rewards window
  • onResumeScan implemented and verified to preserve blinkReceiptId across a "Retake photo"
  • onScanAccepted firing once per accepted scan, before ads loading
  • OffersWall.onScanReceipt launches the camera and returns — it does not await the launcher result
  • A device-tier review (missing date or total) submits successfully — no "Scan session not found"
  • Two receipts scanned back-to-back report two different scanSessionIds
  • Server verdicts (duplicate, submission limit, fraud) exercised with test mode off — TestOptions.Test accepts everything
  • android:windowSoftInputMode="adjustResize" set on the hosting Activity, and review-form input visible with the keyboard up
  • Debug/test options disabled for production build
  • ActivationClient.close() called in onTerminate()
  • app-ads.txt configured and publicly accessible (see GAM MCM Setup)

Integration Pitfalls to Avoid​

These are the most frequent issues encountered during integration. Reviewing this list before launch can save significant debugging time.

PitfallWhy It HappensRecommended Approach
Using ReceiptProcessed to calculate rewardsReceiptProcessed contains promotion match data, so it looks like the right sourceUse RewardUpdate webhooks for all backend-calculated rewards. Use the SDK rewards flow only for real-time display.
Assuming one webhook per receiptSeems logical that one scan = one notificationA single receipt can trigger separate webhooks for promo, boost, UGC, and missed earnings. Handle multiple events per blink_receipt_id.
Not designing for idempotencyAssumes each webhook is delivered exactly onceThe same webhook can be delivered more than once during retries. Use reward_id or blink_receipt_id + reward_type to deduplicate.
Not storing blink_receipt_idTreating it as a transient valueblink_receipt_id is the primary key that links ReceiptProcessed, RewardUpdate, and on-device scan context. Store it.
Assuming reward finality after scanUser sees rewards on screen, so it feels completeMissed earnings can add rewards hours or days later. Design for open-ended reward accrual per receipt.
Treating the SDK callback as the only reward channelThe callback is immediate and feels authoritativeThe SDK handles real-time display only. Promo, boost, UGC, and missed earnings come exclusively via webhook.
Not handling webhook failuresAssumes delivery is guaranteedWebhooks retry 3 times over ~65 seconds, then go to a dead letter queue. Build monitoring for missing expected webhooks.
Assuming registration has completed before showing the offer wallSetting hashedEmail/hashedPhone looks synchronous, but registration debounces and runs over the networkCollect ActivationClient.registrationState (or check isRegistered) and wait for Registered before presenting the offer wall.
Treating Rewards.BoostCreditEarned as a payoutIt arrives on the same rewards flow as Rewards.Boost and looks like a completed rewardIt's a pending balance under hard currency mode — bank it, and only credit the user when the matching Rewards.BoostCreditApplied arrives.
Leaving onResumeScan null on OffersWallFlowIt's optional and the flow still works, so nothing looks brokenA retake silently becomes a brand-new scan with a new blinkReceiptId, so one receipt reaches your backend as two. Implement it and resume the open recognizer session.
Waiting on onException for receipt problemsIt was the channel for too-old, duplicate, invalid, and limit-reached before 1.2.0Those variants were removed. Each verdict now has its own screen inside the SDK; onException only carries unrecoverable failures.
Expecting a shopper's typed correction on the Receipt SummaryThey just typed it into the SDK's own form, so it looks like corrected dataCorrections are evidence for a reviewer and are never merged into the scan. The field stays missing until a reviewer acts, and the adjustment arrives by webhook.
Bracketing startScanSession/endScanSession around a scanIt was required before 1.2.0The camera opens a session per scan and closes it on every way out. A host-held session is one the camera rides along on instead of owning, and an unowned session is never registered with the backend.
Awaiting the camera result inside OffersWall.onScanReceiptThe callback is a suspend function, so awaiting looks like the intended contractIt holds the scan session open across the camera launch, and a device-tier review request then fails with "Scan session not found". Launch the camera and return — see Scan sessions.
Assuming one scanSessionId covers a whole offer-wall visitIt did before 1.2.0Each scan now gets its own session, and browsing has none. Correlate by blink_receipt_id, not by session.