Skip to main content
Every purchase triggered by an Encore offer runs through a PurchaseController your app registers. The SDK has no built-in billing and never runs purchase code you did not write: your controller owns product lookup, receipt validation, restore handling, and entitlement bookkeeping, exactly as it does for every other purchase in your app. Encore controls when a purchase happens; you own how.

Definition

JavaScript can’t implement a native protocol, so the bridge owns the native controller conformance and forwards each request to your function, correlating requests by id so a late reply can never land on a live flow.

Binding a controller

Pass your controller to configure(). Binding it is part of configuring, so the SDK is never configured without the purchase path it was meant to have.
EncoreProvider forwards its purchaseController prop into the configure() call it makes on mount, so these are one binding written two ways. Pick whichever matches where your app starts up.
Binding is build-time wiring, not user state. It survives reset(), so logging a user out does not require binding again.

Omitting the controller

Omitting the controller is a supported configuration, not a broken one. The SDK passes no controller to the native layer, never attempts a purchase, keeps its own not_attempted record and sdk_iap_no_purchase_controller diagnostic, and every presentation resolves publisher: 'not_attempted'. That is a different and more accurate fact than a failed the publisher never caused.

Replacing the controller

Encore.setPurchaseController() swaps the controller bound at configure(). The controller the native SDK holds is the bridge itself, and the bridge always forwards to whichever handler is currently bound, so the swap is a JavaScript-side concern that takes effect on the next purchase request.
Reach for it when the controller genuinely has to change at runtime, such as a billing stack that only exists once a user signs in. The bridge keeps exactly one native listener across swaps, so a replacement can never run a single purchase request through both handlers. It resolves { success: false }, and logs why, when configure() ran without a purchase path: the native SDKs take the controller as a configure() argument and Android has no setter, so no controller can be added to an SDK that is already configured. Pass yours to configure() instead.

PurchaseResult

Throw for a real failure. A throw is recorded as publisher: 'failed' with your error message, and the flow continues.
pending is a first-class result, not a soft failure. It means Ask to Buy (parental approval) on iOS, or SCA or a pending Play transaction on Android: the user has neither bought nor abandoned, and the store’s eventual webhook is the source of truth.The 1.x boolean handler could not express this state, so it reported every deferred purchase as a failure. When you map your billing layer onto PurchaseResult, returning 'cancelled' for a deferred purchase silently misreports revenue: a sale still in flight is recorded as a decline, on the presentation record and in every analytics surface downstream. Route your billing library’s deferred branch to 'pending'.
Returning anything else, including a bare true (the 1.x shape) or forgetting to return, is rejected rather than guessed at: the bridge fails that request with a message naming what it received. Failing loudly beats silently reporting a purchase that never happened.

PurchaseRequest

What your controller receives when Encore needs a purchase.
Android’s native controller also hands over the foreground Activity. The bridge absorbs it rather than leaking it to JavaScript: it has no JavaScript representation, it is valid only for the duration of the call, and a React Native host’s billing library resolves the current Activity itself. One contract therefore covers both platforms.

Implementations