# Pay once, unlock forever

> Source: https://cross-deck.com/docs/pay-once-unlock-forever/ · Crossdeck developer docs (generated from the published page).

Guide
Lifetime unlock from a non-consumable
·
~6 min read
·
Updated June 13, 2026

A lifetime unlock — remove-ads, unlock-pro-forever, a paid app feature bought once and kept — is the simplest entitlement to ship on Crossdeck. You create one non-consumable in-app purchase, map your entitlement to it once, and every buyer holds that capability for life. No grant-from-purchase API call, no webhook handler to write. This page is the whole recipe, rail-agnostic, plus exactly how a refund takes it back.

## What this gives you

The goal is a single capability the user pays for once and keeps forever, restorable on every device they sign in on. In Crossdeck that capability is an **entitlement** — `pro`, `ads_removed`, `lifetime` — the same atomic key your SDK already asks about with `isEntitled()`. The only difference from a subscription is the *source* that grants it: a one-time, **non-consumable** purchase instead of a renewing subscription.

The mechanism is identical either way. A non-consumable mirrors into Crossdeck as a Product just like a subscription SKU does; you attach an entitlement to that Product once; and the resolver materialises the grant for every buyer. The grant carries **no expiry** — it's durable until a refund pulls the purchase out from under it.

No grantEntitlement() bridge needed.

You do not write a checkout-webhook handler that calls `grantEntitlement()` to turn a purchase into access. That manual bridge is for capabilities with *no* rail purchase behind them — gift codes, comps. For a rail-tracked non-consumable, the Product &rarr; Entitlement mapping does it automatically.

## The recipe

Four steps, two of which Crossdeck does for you. It is rail-agnostic: the IAP can live on the App Store, Google Play, or Stripe — the mapping and the resulting grant are identical.

### 1. Create the non-consumable IAP on your rail

In your rail's console, create a one-time, non-consumable product:

- **App Store Connect** — an In-App Purchase of type *Non-Consumable*. (See [One-off purchases on Apple](https://cross-deck.com/docs/apple-one-off-purchases/index.html) for the capture details and sandbox testing.)

- **Google Play** — a one-time product (managed product). On Google Play and Stripe, Crossdeck defaults a one-time product to `consumable`; mark this one as **non-consumable** in the Crossdeck Products page so it grants durably.

- **Stripe** — a one-time Price (not a recurring subscription). As with Google, set its product type to non-consumable in Crossdeck.

The product type is what makes it durable.

Apple tells Crossdeck the type directly, so Apple non-consumables are classified for you. On Google Play and Stripe there's no equivalent signal, so a one-time product arrives as a `consumable` by default — which grants nothing. If your lifetime unlock isn't unlocking, this is the first thing to check: confirm the product reads **non-consumable** on the Products page.

### 2. It mirrors into Crossdeck as a Product

The first time Crossdeck sees the IAP — from a catalog sync or the first purchase — it mirrors in as a **CD Product** under `projects/{p}/catalog`. You author nothing here; the rail SKU is the source and Crossdeck holds the mirror. The Product is the rail-agnostic concept your entitlement will hang off.

### 3. Map your entitlement to the Product

This is the one manual step, and it's the same one you make for any subscription product. On the dashboard:

- Open **Products** and pick the non-consumable (it's already there from the mirror).

- Confirm its type reads **non-consumable**.

- Under *Grants entitlement*, select the entitlement key your app checks — e.g. `ads_removed`. Create the key first on the Entitlements page if it doesn't exist yet.

That mapping is journaled with operator UID and rationale, exactly like the subscription case — so the auditor's question "why does this customer have `ads_removed`?" answers from the ledger: an active `PAID` non-consumable purchase &rarr; this Product &rarr; this entitlement.

The entitlement key is a literal, case-sensitive string.

`isEntitled("AdsRemoved")` against a mapped key of `ads_removed` returns `false` indistinguishably from a genuine non-grant — there's no fuzzy match and no "unknown key" warning. Pin the key in one shared constant and type it identically in the dashboard and your code.

### 4. Gate your app on the entitlement

Nothing about gating changes for a one-off. The buyer's entitlement resolves through the SDK cache and you check it synchronously:

```
if (crossdeck.isEntitled("ads_removed")) {
  hideAds();
}
```

As with any entitlement, treat `false` as authoritative only after your first `getEntitlements()` has resolved — on a fresh install or a new device the cache is briefly empty, and a paying customer reads `false` in the same shape a free user does. Call `getEntitlements()` after a purchase completes to pull the new grant in immediately. See [Entitlements — client-side resolution](https://cross-deck.com/docs/entitlements/index.html#client-resolution) for the first-paint pattern.

## Refunds revoke it — automatically

A lifetime grant that couldn't be taken back would be a liability. It can. The grant is sourced to the **real rail and the originating purchase id** — not `source.rail: "manual"` — and the resolver only counts purchases in a `PAID` state. So when the rail reports a refund or dispute, the purchase leaves the `PAID` set, drops out of the next recompute, and the entitlement revokes on its own.

This is the opposite of a manual grant.

A `grantEntitlement()` grant sticks until a human revokes it — which is correct for a gift code, and a footgun for a refundable sale. Because a non-consumable grant tracks the purchase, no operator has to remember to revoke when the money goes back, and no projection bug can silently keep a refunded buyer entitled. Permanent access that still tracks the money.

## The fixed-term variant: non-renewing

A **non-renewing** one-off — a 30-day pass that doesn't auto-renew — uses this exact recipe, with one difference: instead of granting for life, it grants until the purchase's known expiry, then lapses on the next projection. Map the entitlement the same way; mark the product **non-renewing** rather than non-consumable. If a non-renewing purchase carries no usable expiry from the rail, Crossdeck does *not* guess a lifetime grant for it — an unbounded "temporary" pass is the one outcome worse than no grant.

## What this is NOT for: consumables

This recipe is for durable unlocks only. A **consumable** — credits, coins, gems, a one-time render — is bought, spent, and re-bought. Crossdeck records it as **revenue** and grants *nothing*; your app owns the balance, because only your app knows when a credit is used.

Never map a consumable to a permanent entitlement.

Do that and a user who buys 100 credits once is marked entitled *forever*. Crossdeck keeps consumables revenue-only by design so this can't happen by accident — if you want a consumable to confer durable access, that's a deliberate decision, not a default. A verifiable consumable balance ledger is on the roadmap; until then, capture and attribution are Crossdeck's job and the balance is yours.

## Verifying it end-to-end

Make a real test purchase where the rail is genuinely in the loop (Apple Sandbox / TestFlight, Stripe test mode, or a Google internal-testing track — local StoreKit testing alone won't reconcile), then:

- Open the buyer in Crossdeck — the purchase appears in their **Revenue** record, attributed to them.

- `isEntitled("ads_removed")` reads `true` for that customer, with no expiry. If it stays `false`, the product has no entitlement mapped (or is still typed `consumable`) — the symptom is a paid buyer showing zero entitlements. Fix the mapping on the Products page and refresh entitlements on the customer.

- Refund the test purchase on the rail and confirm the entitlement **revokes** on the next projection — the buyer reads `false` again.

If all three hold, your lifetime unlock is wired end-to-end: buyers get it for life, refunds take it back, and the whole thing is one mapping rather than a webhook handler you have to keep correct.

A non-consumable grant resolves through the Product &rarr; Entitlement mapping with `source.rail` set to the real rail and `source.purchaseId` set to the originating purchase, so only `PAID` purchases keep it alive. Code references: `backend/src/lib/entitlement-resolver.ts`, `backend/src/lib/purchase-mapping.ts`, `backend/src/catalog/spine.ts`.
