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
| State | What to show |
|---|---|
| Never bought | The paywall |
| Lapsed | Your Pro ended on the 4th, and the way back |
| In grace | Nothing 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