Apple in-app purchase
How an iOS app sells subscriptions and unlocks with StoreKit 2, and how the server proves the purchase is real. Read it before you add a product in App Store Connect or touch the purchase flow.
How it works
- Step 1 to 5. The
appAccountTokenties the Apple transaction to our user. Every later notification carries it. Without it, a renewal arrives and we cannot tell whose it is. - Step 8 to 10. The app's signed transaction only tells UPG which purchase to look up. UPG asks Apple for the real state.
- Step 12 and 13. Renewals, refunds and expiry never come through the app. They come from Apple to the server.
The store rules
As of October 2026. Check App Review Guidelines section 3.1 before each release.
| Topic | Rule |
|---|---|
| Digital goods used in the app | Must use in-app purchase (3.1.1) |
| Physical goods, real-world services | Must not use in-app purchase (3.1.3(e)) |
| Linking to the website to buy | Not allowed, except where Apple grants an entitlement for that storefront. Rules differ by country; in India and the GCC there is none |
| Commission | 30%, or 15% in the Small Business Program and for subscriptions after a year |
| Bought on the web, used in the app | Allowed for some app types (3.1.3). The app must not steer users to buy outside |
When a free user hit a limit, the app showed a neutral message: no price, no link, no "buy" word. A remote-config flag, off by default, could show an "Open website" button that opens the system browser, never a WebView. Members who bought on the web simply got their limits in the app, because the app was reading their account. Steering copy is a legal review, not an engineering decision.
How to build it
Before any code: the account
These block everything and take days. Start them on day one of the project. See Long lead-time accounts.
- The Paid Applications Agreement must be active. Only the Account Holder can accept it. It needs tax forms and a bank account whose name matches the legal entity exactly. Until it is active, StoreKit returns no products, even in sandbox.
- Create an App Store Connect API key with in-app purchase access. It is a different key type from the APNs push key.
- Products belong to one app. Two apps cannot share products.
- Product ids are burn-once. A deleted id can never be used again. Use a naming
scheme like
com.example.app.pro.monthlyand never test with production ids.
Server: verify the notification, then the contents
Use Apple's library, @apple/app-store-server-library, and its SignedDataVerifier.
It checks the full certificate chain (x5c) back to Apple's root.
const verifier = new SignedDataVerifier(
appleRootCAs, // Apple Root CA, G2 and G3, shipped in the image
true, // online revocation checks
Environment.PRODUCTION,
bundleId,
appAppleId,
);
const n = await verifier.verifyAndDecodeNotification(signedPayload);
// The transaction and renewal info are separately signed. Verify each one.
const tx = await verifier.verifyAndDecodeTransaction(n.data.signedTransactionInfo);
const renewal = await verifier.verifyAndDecodeRenewalInfo(n.data.signedRenewalInfo);
Map Apple's fields carefully
| Apple field | What we learned |
|---|---|
originalTransactionId | The subscription's key. transactionId changes on every renewal |
price | In milliunits: 1000 = one unit. Convert with the currency's exponent, or the ledger is wrong by 10x for 3-decimal currencies |
appAccountToken | Must be a UUID. Issue it from the server, one per user and app, stable once issued |
DID_RENEW with subtype BILLING_RECOVERY | A recovery after failed billing, not a new purchase |
DID_FAIL_TO_RENEW / billing retry | on_hold or in_grace, not expired |
EXPIRED, GRACE_PERIOD_EXPIRED | Access ends |
REFUND | Revoke immediately. REFUND_REVERSED restores |
offerType | 1 is an introductory offer. A paid intro offer is not a free trial; do not mark it as one |
inAppOwnershipType = FAMILY_SHARED | A family member's access, not a purchase by this user |
Sandbox
- Sandbox and production notifications go to separately configured URLs in App Store Connect. Set both. With only one, one environment silently receives nothing.
- Production must refuse sandbox notifications. Anyone can make a sandbox account, so accepting them hands out free entitlements.
- Sandbox subscriptions renew on a fast clock (a month takes minutes) and stop after a few renewals. Plan test runs around it.
Restore and shared Apple IDs
"Restore purchases" sends the signed transactions again. The server validates them like
a new purchase. If the Apple ID's purchase is already linked to a different user, return
a conflict (account_token_mismatch). Do not move the subscription silently.
Rules
Ship Apple's root certificates (Root CA, G2, G3) in the server image, checked against Apple's published SHA-256 fingerprints. Our first production image had none, so every notification would have failed verification.
Set appAccountToken on every purchase from the first release. It cannot be added to
past purchases. A release without it is a release blocker.
Verify signedTransactionInfo and signedRenewalInfo on their own. Decoding them from
a verified wrapper would accept a real notification carrying forged contents.
A notification's environment must match its connection: production notifications on the live connection, sandbox on the test one. Reject the rest.
Mistakes we made
- iOS review rejection while nothing was for sale. The website showed prices and paid-subscription terms, but the app had no purchase yet. Apple asked business-model questions (Guideline 2.1) and rejected the build. Launch prices and purchase together.
- Products did not load for weeks. The Paid Applications Agreement was waiting for tax forms. StoreKit returns an empty list without an error. Check the agreement first when products are missing.
- Expired subscriptions stayed active.
EXPIRED,DID_FAIL_TO_RENEWandGRACE_PERIOD_EXPIREDwere not mapped, so rows never leftactive. Map every notification type, and log unknown ones loudly. - Our user id was not a UUID. Apple's
appAccountTokenmust be one, so notifications could not be matched to users. UPG now issues a random UUID per user. - A paid intro offer was treated as a free trial. Check
offerTypeand the price, not only "is this an offer".
Checklist
- Paid Applications Agreement active, bank and tax done, by the Account Holder.
- Product ids follow the naming scheme; test products are not production ids.
- Production and sandbox notification URLs are both set.
-
appAccountTokenfrom the server on every purchase. - Server verifies with
SignedDataVerifier, root certs in the image. - Every notification type is mapped; unknown types alert.
- Production refuses sandbox notifications.
- The app has no price, link or "buy" wording that points outside the store.