> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PurchaseController

> The purchase function your app registers so Encore offers purchase through your billing code

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

```tsx theme={null}
type PurchaseResult = 'purchased' | 'cancelled' | 'pending';

type PurchaseController = (
  request: PurchaseRequest,
) => Promise<PurchaseResult> | PurchaseResult;
```

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()`](./configure). Binding it is part of configuring, so the SDK is never configured without the purchase path it was meant to have.

<CodeGroup>
  ```tsx Direct theme={null}
  await Encore.configure('pk_...', { purchaseController });
  ```

  ```tsx EncoreProvider theme={null}
  <EncoreProvider apiKey="pk_..." purchaseController={purchaseController}>
    <YourApp />
  </EncoreProvider>
  ```
</CodeGroup>

`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.

<Note>
  Binding is build-time wiring, not user state. It survives [`reset()`](./reset), so logging a user out does not require binding again.
</Note>

### 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.

```tsx theme={null}
await Encore.setPurchaseController(controllerForSignedInUser);
```

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

| Value         | Meaning                                                                                              |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `'purchased'` | The purchase completed                                                                               |
| `'cancelled'` | The user backed out. Map your billing layer's "user cancelled" error to this rather than throwing it |
| `'pending'`   | The store **deferred** the purchase and it may complete later                                        |

Throw for a real failure. A throw is recorded as `publisher: 'failed'` with your error message, and the flow continues.

<Warning>
  **`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'`.
</Warning>

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.

```tsx theme={null}
interface PurchaseRequest {
  productId: string;
  placementId?: string;
  promoOfferId?: string;
  basePlanId?: string;
}
```

| Property       | Type      | Description                                                                                                                                 |
| -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `productId`    | `string`  | The store product identifier to purchase, for example `com.app.monthly_premium`                                                             |
| `placementId`  | `string?` | Which placement triggered this purchase, if any                                                                                             |
| `promoOfferId` | `string?` | App Store Connect promotional offer identifier. iOS only; `undefined` elsewhere                                                             |
| `basePlanId`   | `string?` | Google Play subscription base plan id, when the product exposes several. Android only; `undefined` on iOS and for single-base-plan products |

<Note>
  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.
</Note>

## Implementations

<Tabs>
  <Tab title="RevenueCat">
    ```tsx theme={null}
    import Purchases from 'react-native-purchases';
    import type { PurchaseController } from '@encorekit/react-native';

    const purchaseController: PurchaseController = async ({ productId }) => {
      try {
        await Purchases.purchaseProduct(productId);
        return 'purchased';
      } catch (error) {
        if (error.userCancelled) return 'cancelled';
        // RevenueCat surfaces a deferred store transaction as its
        // payment-pending error code. Check the constant your version exports.
        if (isPaymentPending(error)) return 'pending';
        throw error;   // a real failure — Encore records it as publisher: 'failed'
      }
    };
    ```
  </Tab>

  <Tab title="react-native-iap">
    ```tsx theme={null}
    import * as RNIap from 'react-native-iap';
    import type { PurchaseController } from '@encorekit/react-native';

    const purchaseController: PurchaseController = async ({ productId, basePlanId }) => {
      try {
        const purchase = await RNIap.requestSubscription({
          sku: productId,
          ...(basePlanId ? { subscriptionOffers: [{ sku: productId, offerToken: basePlanId }] } : {}),
        });
        // Play returns a deferred transaction in the pending purchase state
        // rather than a completed one; it settles through the store later.
        if (isPending(purchase)) return 'pending';
        return 'purchased';
      } catch (error) {
        if (isUserCancelled(error)) return 'cancelled';
        if (isDeferredPayment(error)) return 'pending';
        throw error;
      }
    };
    ```
  </Tab>

  <Tab title="Custom">
    ```tsx theme={null}
    import type { PurchaseController } from '@encorekit/react-native';

    const purchaseController: PurchaseController = async (request) => {
      // request.productId    - store product to purchase
      // request.placementId  - which placement triggered this (optional)
      // request.promoOfferId - iOS promotional offer (optional)
      // request.basePlanId   - Play base plan to select (optional)
      const outcome = await yourBilling.purchase(request.productId);

      if (outcome.userCancelled) return 'cancelled';
      if (outcome.deferred) return 'pending';
      return 'purchased';
    };
    ```
  </Tab>
</Tabs>

## Related

* [configure()](./configure), which binds the controller.
* [PresentationResult](./presentation-result), where the controller's answer lands as `publisher`.
* [Present offers](../quickstart/present-offers) for the end-to-end walkthrough.
