You need: payments composed, an Apple Developer account, and an app in App Store Connect.
Time: an hour, plus one thing you can only download once.
Apple has the least network in it
Selling on Google Play is the sibling rail, native iOS is the client half, and entitlements across mobile and web is why your gate never names either.
A StoreKit 2 transaction arrives already signed by Apple, so a client submission verifies offline against a certificate chain that ships with the package — no round trip, no credential, no store outage in the path.
What Apple’s credentials buy is the other two things: calling the server API on the reconciliation pass, and proving a signed payload is about your app rather than somebody else’s.
1. Your bundle id is load-bearing
Apple signs every developer’s notifications and transactions with the same certificate chain.
So a valid signature proves Apple signed the payload and says nothing about which app it came from. Without a bundle check, another developer’s transaction verifies here — and any product id of theirs that happens to match your catalog becomes a free entitlement.
So it is checked twice on a notification: the envelope’s and the nested transaction’s own, because a notification assembled around somebody else’s transaction would pass a check on either one alone.
A notification for another app is refused with 401, and a submitted transaction from one with 400.
2. Create the products
Under your app’s monetization section. A subscription belongs to a subscription group; a consumable or non-consumable stands alone.
payments({
billingSubject: "user",
rails: { apple: true },
products: {
pro_monthly: {
type: "subscription",
name: "Pro",
entitlements: ["pro"],
apple: { productId: "com.acme.pro.monthly" },
},
},
}),Match the type to what you created. It decides how a renewal and a restore behave — and whether the reconciliation pass can ask Apple about the purchase at all, because Apple’s subscription endpoint speaks for auto-renewables and nothing else.
3. Create an App Store Connect API key
Users and Access → Integrations → App Store Connect API, with In-App Purchase access. That key type is scoped to exactly this work, which is why it is the one to use rather than a team key with a broader role.
Note three things, and the first is available only once:
- The private key, as a
.p8file. One download. - The Key ID, on the key.
- The Issuer ID, at the top of the same page, shared across every key in the account.
Store the .p8 verbatim, header and footer included. It is imported as PKCS#8 and anything else is refused with a message saying so. Escaped newline sequences are accepted, since that is how a JSON secret holds a multi-line value.
Nothing about client submissions or webhooks needs this key. It is the credential for the server API, which reconciliation uses to ask Apple what a subscription is doing now. A project that never provisions it still verifies receipts and accepts notifications — it simply cannot repair the subscription nothing arrived about.
4. Point Apple at the notification endpoint
Under App Information → App Store Server Notifications. There are two URLs and both matter: production, and sandbox pointing at your staging host.
Version 2 only. The package reads the V2 payload and nothing else; a Version 1 URL delivers a shape it will refuse.
The path follows your base path, so a project that moved its mount moves these URLs with it.
Then use Request a Test Notification. It is recorded and answered 200 with nothing projected — a test notification is authentic and concerns no transaction. A non-2xx here means the URL, the host or the deployment is wrong, and it is much cheaper to find out now than on somebody’s first renewal.
5. The account token, and why it must be unguessable
Before presenting the purchase sheet, set the app account token:
let result = try await product.purchase(options: [
.appAccountToken(accountTokenFromYourServer)
])Apple requires a UUID.
Make it a random value your server minted for the subject that will hold the purchase, and never a value anybody else can derive. Not the subject’s id, not an email hash, not anything a third party could compute.
Here is why it matters: the token is the only hook from an App Store purchase back to a subject for a notification that arrives before the app has submitted anything. A guessable token is a way to aim at a specific account — somebody who can work out that subject’s token could make one real purchase carrying it and claim the link first.
Under organization billing the target is a whole company rather than one person, so a derivable token is worth more to an attacker, not less.
Three things narrow that, and none of them substitutes for an unguessable token. The token is set by the app, which may put anything in it, so it is never treated as a holder on its own — 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 on this deployment’s own environment rather than a free sandbox receipt. And a binding is never rebound — the first pairing wins, and a collision is audited rather than silently accepted.
6. Your app has two obligations
Submit the transaction at least once. The link from a purchase to a subject is written when your app submits, not by the notification.
There are two fallbacks for a notification whose token resolves nothing — a purchase already projected under the same transaction id, and the subscription family the renewal descends from — and both need somebody to have submitted the first purchase. An app that sets no token and submits nothing produces notifications with no subject to project them against.
Submitting is also what makes the purchase feel instant: the transaction is a payload Apple signed, so verification is local and offline, and the buyer sees their entitlement in the purchase flow rather than a second later. Nothing about correctness rests on it — the notification produces the identical row through the same idempotent writer.
Finish the transaction. Pithy does not call it, and your app must: an unfinished consumable is re-delivered to the app forever. Finish it once your own server has confirmed the purchase.
7. Store the credentials
All four values inside one typed secret, alongside any other rail’s block:
pithy secrets create payments-provider-credentials --env stagingThe bundle id, the key id, the issuer id, and the private key with its newlines escaped.
The secret is environment-scoped. Apple’s sandbox and production are separate, and so are your secrets.
Sandbox never grants production
Every purchase carries its store environment, and a mismatch is refused outright.
The environment is an input from this deployment’s own environment var, never inferred from the payload — inferring it from what the store said is exactly the hole this closes.
Only a Worker deployed to production is production. Staging, dev and an unset var are all sandbox, because the failure directions are not symmetric: treating production as sandbox loses a purchase reconciliation repairs, and the other way round hands out entitlements for test transactions.
Check it worked
- The test notification returns 200
- A sandbox purchase submits and shows its entitlement in the app immediately
- The purchase carries the sandbox environment and grants nothing in production
- A renewal notification projects against the right subject
pithy payments reconcile --env staging --dry-runreports no drift