Blog

Buy on iOS, be entitled on the web

2026-07-30

An isometric illustration of a bank card — a flat rounded rectangle with a chip and a raised number band

A customer buys Pro in your iOS app. Then they open your website and it does not know who they are paying for.

That is the problem. It sounds like a data-sync issue and it is actually a modeling one: you have been storing purchases, and what you needed to store was entitlements.

A product is not an entitlement

pro_monthly and pro_annual, across four different store catalogs, are four SKUs. They grant one key: pro.

Your gating code names the key. Never the SKU.

That single rule is what makes cross-rail work possible. Ask “does this user have pro” and it does not matter which store sold it, on which platform, under which product id. Ask “does this user have com.yourapp.pro.monthly.ios” and you have hardcoded a rail into a permission check, and you will do it again in eleven other places.

Four rails

Apple, Google, Stripe and Lemon Squeezy, resolving to that one entitlement in your own Worker and your own D1. No hosted data plane holds your purchase history.

Lemon Squeezy is worth calling out specifically because it is a merchant of record. On that rail, global sales tax, EU VAT, invoicing and dunning are their problem rather than yours. That is a genuinely large amount of work you do not do, and it is a reason to pick a rail that has nothing to do with fees.

One projection, and why that is the whole design

flowchart LR
    A["App Store"] --> P["One projection"]
    G["Google Play"] --> P
    S["Stripe"] --> P
    D["Paddle"] --> P
    P --> R["Entitlement rows,<br/>against a subject"]
    R --> W["Your web app"]
    R --> N["Your native app"]
    R --> V["Your server-side gate"]

The same purchase reaches you three ways: the client tells you, the provider’s webhook tells you, and a reconciliation pass tells you. Those arrive in any order, sometimes more than once, sometimes not at all.

Every write converges on one idempotent projection, keyed on (rail, provider transaction id).

So all three paths produce the identical row:

  • A dropped client call costs nothing — the webhook produces the same row.
  • A replayed webhook changes nothing — it is the same key.
  • A reconciliation pass finds what both missed and writes, again, the same row.

You stop having to reason about ordering or delivery, which is the thing that makes payment integrations rot.

Monotonic on the provider’s clock

There is a subtler failure hiding in “any order”, and it is the one that costs you a customer.

Providers do not guarantee delivery order. A subscription expired event can arrive after the renewed event that superseded it. Apply them in arrival order and you have just revoked a paying subscriber, silently, and they find out when the app locks them out.

So the projection is monotonic on the provider’s own event time, not on when you received it. A stale event that arrives late does not overwrite a newer one that arrived early.

Sandbox stays sandbox

Sandbox purchases are tracked as sandbox and never grant a production entitlement.

This is the kind of thing that seems obvious until you consider that the easiest implementation treats a test purchase and a real one identically, and that the difference is a flag somebody has to remember to check on every read.

Webhooks rot, so something re-verifies

A cron Cloudflare Workflow re-verifies what the webhooks missed.

The reason is in the catalog and it is worth quoting: webhook-only systems rot silently. Endpoints get misconfigured, deliveries fail and stop retrying, a provider has an incident during which nothing arrives. None of that raises an alarm — your system simply believes an increasingly out-of-date version of who has paid you.

A periodic re-verification is how you find out. It runs as a Workflow because it is long, resumable work that must survive a restart.

What it requires, and why

auth, because every route belongs either to an authenticated purchaser or to a machine proving its authenticity. There are no public routes here at all.

secrets, and this one is a hard requirement — the four rails’ credentials are read through it, so payments will not compose without it. Four rails means four sets of provider credentials, and there is exactly one place they come from.

Ledger fulfillment is opt-in per product. Most products never touch a balance — a subscription grants an entitlement and that is the end of it. If you sell coin packs, that is when the ledger becomes relevant.

What it does not do

It does not do checkout UI. The rails have their own, and on Apple and Google you are required to use it.

It does not do pricing experiments, or dunning on the rails where dunning is yours.

And it does not make store review easier. Four rails means four sets of platform rules, and the modeling above helps you not care which one paid — it does not help you get approved.