Crossdeck Docs
Dashboard

Pay once, unlock forever

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 entitlementpro, 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 → 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:

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:

  1. Open Products and pick the non-consumable (it's already there from the mirror).
  2. Confirm its type reads non-consumable.
  3. 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.

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 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:

  1. Open the buyer in Crossdeck — the purchase appears in their Revenue record, attributed to them.
  2. 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.
  3. 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 → 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.