I shipped a small iOS app recently and hit the point every subscription app hits: the client says the user is subscribed, and you have to decide whether to believe it. You should not. So you need a server that verifies the purchase with Apple and stores the result.

The obvious move is to reach for one of the subscription platforms. They are genuinely good products. They also generally price as a percentage of your subscription revenue once you cross a free tier, forever, for every app you ever ship. And when I actually sat down and read what server-side StoreKit 2 requires, the thing I would be renting looked a lot like a signature check, two API calls, and a webhook handler.

That is not a knock on those companies. If you ship on iOS and Android, run experiments on paywalls, and want revenue analytics your finance person can open, buy the product. But most of my apps are not complicated. One tier. Pro or not pro. A handful of product IDs. Paying an ongoing cut of revenue for the rest of an app’s life, on every app, felt like the wrong trade for that. Especially since the bill grows exactly as the app succeeds, which is the one time you least want a new variable cost.

So I wrote it, then cleaned it up and open sourced it: storekit-cloudflare-workers , MIT licensed.

Why Cloudflare makes this an easy decision now

A few years ago the answer would have been “spin up a small server”, and then you own a server. Patching, monitoring, a database, a bill that exists whether anyone subscribes or not.

That calculation has changed. Cloudflare’s free tier is genuinely generous, and more importantly the shape of the workload fits the platform almost suspiciously well.

Subscription verification is rare. A client syncs after a purchase, after a restore, and on launch. Apple pushes a notification when something changes: a renewal, a cancellation, a refund. For a subscriber, that is a handful of events per year. There is no polling, no fan-out, no background job that has to run every minute.

The data is tiny too. Subscription state is one row per subscriber. Not per transaction, per subscriber. Even an app with tens of thousands of paying users is a table you could hold in memory, touched a few times a year per row. D1 is the part people are most skeptical about, usually right up until they work out how few rows they are actually storing.

That leaves the Worker, which is the boring part in the best sense. No cold start worth discussing, no server to patch, and it sits at the edge next to whatever else your app already talks to. The only real gotcha is that Apple’s official server library needs Node built-ins, so you need the nodejs_compat flag. That is the whole platform story.

What server-side StoreKit 2 actually involves

StoreKit 2 made this much better than the old receipt-blob era. The client gets a signed transaction, and your server verifies that signature against Apple’s certificate chain. No shared secret, no opaque base64 receipt to post back to a validation endpoint.

The flow is small enough to draw:

  iOS app                Your Cloudflare Worker              Apple
  ---------              ----------------------              -----
  purchase()
      |  signed transaction JWS
      +------------------------>  authenticate the caller
                                  verify JWS + cert chain
                                  check bundle, env, product
                                  |  Get Transaction Info
                                  +------------------------->
                                  |  Get All Subscription Statuses
                                  +------------------------->
                                  resolve entitlement
                                  write projection --> D1
      <------------------------+  entitlement snapshot
  finish()

                                  <-- Notifications V2 webhook --+
                                  verify, dedupe, reconcile, write

Three routes total. A sync endpoint the client calls, an entitlement read, and the Apple webhook. The client never tells you what it bought or when it expires. It hands over signed material, and every claim your server acts on comes out of something Apple signed.

The one piece the library cannot supply is authentication. A StoreKit transaction proves that a purchase happened. It never proves who it belongs to, because only your app knows that. So you implement one function:

const storekit = createStoreKitHandler<Env>({
  authenticate: async (request, env) => {
    const session = await mySessionFrom(request, env);
    return session ? { accountId: session.userId } : null;
  },
});

export default {
  async fetch(request, env, ctx) {
    return (
      (await storekit.fetch(request, env, ctx)) ?? myRoutes(request, env, ctx)
    );
  },
};

It returns null for anything that is not a StoreKit route, so it drops into a Worker you already have.

The parts that actually bite

If it were all as tidy as that snippet, it would not have been worth open sourcing. Here is what I got wrong first, and what I would have wanted someone to tell me before I started.

Billing grace periods will silently cut off paying customers. When a renewal fails, Apple does not immediately expire the subscription. It keeps serving the customer while it retries the card. But the transaction’s own expiresDate has already passed. If you gate access on expiresDate, which is the obvious field, every customer in a grace period loses access until their payment goes through. The real deadline lives in gracePeriodExpiresDate on the signed renewal info, which is a separate JWS you have to decode on purpose. This is the bug I care most about having fixed, because it costs you money and goodwill from the exact users who are trying to keep paying you.

Notifications arrive out of order. Apple does not guarantee delivery order and retries failures for days. A delayed DID_RENEW landing after an EXPIRED will happily rewind your state if your write is a plain upsert. The fix is to guard writes on Apple’s signing time rather than your server clock, because a late-arriving old event has a newer server clock reading. Refunds are exempt from the guard, since a revocation is terminal and has to land regardless.

A free trial is not the same as an introductory offer. offerType == 1 covers pay-up-front and pay-as-you-go offers too. If you use it to mean “trial”, you misreport revenue and mis-gate trial-only features. The field that actually distinguishes them is offerDiscountType.

Refunds carry no subscription status. If your handler only reacts to data.status, a REFUND notification does nothing and the user keeps their access.

None of these are exotic. They are the kind of thing you find in production three months in, from a support email. That is where most of the value sits: not the happy path, which really is easy, but the handful of Apple behaviours that surprise you once.

It came out at roughly 2,900 lines with another 3,200 of tests, which is more than I expected when I started. I still think it is the right trade. That is a one-time cost that does not grow when the app does.

What it does not do

Auto-renewable subscriptions, non-consumables, refunds, grace periods, billing retry, and Notifications V2 are covered. There is no dashboard, no analytics, no paywall tooling, and no Google Play. If you want those, buy the SaaS, and I mean that without any edge.

One limit worth stating plainly, because it is the sort of thing README files usually bury: CI proves the module still agrees with the Apple SDK. It does not prove the SDK still agrees with Apple’s servers. Sandbox testing before you ship is not optional, and the docs say so in those words rather than implying a green badge covers it.

Use it, or tell me where it is wrong

The repository is github.com/burakdede/storekit-cloudflare-workers . Copy one directory into a Worker, apply the D1 schema, set four Apple secrets, mount the handler. The README has the full setup, and the docs cover configuration, the security model, and an operations runbook for reconciling after a webhook outage.

If you find something wrong, especially in the entitlement rules, I would genuinely like the issue. That is the part where being wrong is expensive and quiet, and where more eyes help most. Contributions are welcome, and the contributing guide is explicit about which invariants matter and why.

The broader point is not really about StoreKit. It is that the default reflex to add another SaaS with a recurring cut deserves a second look now that the platform underneath got this good. Sometimes the boring answer is that the problem is smaller than the pricing page suggests.