Pay once, unlock forever
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.
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 → 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 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.
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 → this Product → this entitlement.
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 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.
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.
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")readstruefor that customer, with no expiry. If it staysfalse, the product has no entitlement mapped (or is still typedconsumable) — 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
falseagain.
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 → 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.