Entitlements on mobile and web

Somebody buys Pro in your iOS app on Sunday. On Monday they open your web app on a laptop.

They are entitled. Nothing synced, no device involved, and no hosted service holding the purchase history. This page is why.

The one sentence

Entitlements is the gate that sentence describes, selling on the App Store is one rail that grants one, and refunds and lapses is what takes it away.

An entitlement is a row against a subject, not a fact about a device or a store.

The store told you a transaction happened. The transaction resolved to a subject. The subject holds a key. Every reader asks about the subject.

Worked through

sequenceDiagram
    autonumber
    participant App as Your iOS app
    participant Store as Apple
    participant W as Your Worker
    participant DB as Your D1
    App->>Store: The sheet, carrying an unguessable account token
    Store-->>App: A signed transaction
    App->>W: Submit it
    W->>W: Verify locally and offline
    W->>DB: One idempotent write, keyed on rail + transaction id
    Note over DB: Entitlement rows re-derived in the same batch
    W-->>App: Entitled, in the purchase flow
    Store->>W: Server notification, moments later
    W->>DB: The identical row, through the same writer
    Note over W,DB: A replay changes nothing

1. The purchase. Your iOS app presents the sheet with an unguessable account token your server minted for that subject. The buyer pays.

2. The submission. The app posts the signed transaction. Verification is local and offline — StoreKit’s transaction arrives already signed by Apple — so the buyer sees the entitlement in the purchase flow rather than a second later.

3. The projection. One idempotent write keyed on the rail and the store’s own transaction id. The entitlement rows are re-derived in the same batch as the purchase, from the purchases table rather than from a value the writer computed — so there is no window in which a purchase is stored and its entitlement is not.

4. The notification. Apple’s server notification arrives moments later and produces the identical row through the same writer. A replay changes nothing.

5. Monday. The web app calls the entitlements route with the same person’s session. The row is there.

Nothing in step 5 knows an iPhone was involved.

Why the account token has to be unguessable

The token is the only hook from a store purchase back to a subject for a notification that arrives before the app has submitted anything.

So a guessable one is a way to aim at a specific account: somebody who can work it out could make one real purchase carrying it and claim the link first.

Under organization billing the target is a whole company, so a derivable token is worth more to an attacker rather than less.

Three things narrow it, and none substitutes for randomness. The token is set by the app, which may put anything in it, so it ranks below both a purchase already projected for the subject an authenticated caller was acting for and the subscription family a renewal descends from. A binding is only written once a purchase has actually projected, so claiming one costs a real purchase. And a binding is never rebound — first pairing wins, and a collision is audited.

Restore Purchases

The store knows what somebody bought; your database knows who they are. Restore is the route that rebinds one to the other.

It matters in three ordinary situations: a new device, a reinstall, and somebody who bought before they made an account.

Call it when the user asks, and on a fresh install before showing a paywall.

What a lapse looks like from the client

Lapsed rows come back with their flag false rather than filtered out.

So a paywall can say your Pro ended on the 4th rather than you are not subscribed — a better sentence, and a better conversion.

A canceled subscription still grants. Turning off auto-renew forfeits the next period, not the one already paid for. A subject is entitled while some purchase granting that key is active, in grace, or canceled with time left.

Three states worth handling separately

StateWhat to show
Never boughtThe paywall
LapsedYour Pro ended on the 4th, and the way back
In graceNothing about billing. A failed renewal inside the retry window still grants

That last one is a real decision. Nagging somebody whose card is being retried is how you lose a customer who was never leaving.

The client never names a SKU

It names an entitlement key, or it renders whatever the entitlements route returns.

That is what makes a fifth store a catalog edit rather than a client release — and it is the same rule as the server-side gate.

Sandbox never leaks into production

Every purchase carries its store environment, and a mismatch is refused outright.

The environment is an input from this deployment’s own var and is never inferred from the payload — inferring it from what the store said is exactly the hole.

Only a Worker deployed to production is production. The asymmetry is deliberate: treating production as sandbox loses a purchase reconciliation repairs; the other way round hands out entitlements for test transactions.

Testing it end to end

The honest version needs two devices and two stores, and there is no shortcut. What you can do cheaply:

  • Buy in one store’s sandbox, and read the entitlements route as the same user in a browser
  • Refund it, and watch the entitlement go
  • Let a sandbox subscription lapse, and check the row comes back with its flag false and its date
  • pithy payments reconcile --env staging --subject user:<id> runs the same steps the cron runs, narrowed to one person — which is also the answer to my subscription isn’t showing up
ESC