Skip to main content

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 appAccountToken ties 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.

TopicRule
Digital goods used in the appMust use in-app purchase (3.1.1)
Physical goods, real-world servicesMust not use in-app purchase (3.1.3(e))
Linking to the website to buyNot allowed, except where Apple grants an entitlement for that storefront. Rules differ by country; in India and the GCC there is none
Commission30%, or 15% in the Small Business Program and for subscriptions after a year
Bought on the web, used in the appAllowed for some app types (3.1.3). The app must not steer users to buy outside
What anti-steering looked like in our app

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.monthly and 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 fieldWhat we learned
originalTransactionIdThe subscription's key. transactionId changes on every renewal
priceIn milliunits: 1000 = one unit. Convert with the currency's exponent, or the ledger is wrong by 10x for 3-decimal currencies
appAccountTokenMust be a UUID. Issue it from the server, one per user and app, stable once issued
DID_RENEW with subtype BILLING_RECOVERYA recovery after failed billing, not a new purchase
DID_FAIL_TO_RENEW / billing retryon_hold or in_grace, not expired
EXPIRED, GRACE_PERIOD_EXPIREDAccess ends
REFUNDRevoke immediately. REFUND_REVERSED restores
offerType1 is an introductory offer. A paid intro offer is not a free trial; do not mark it as one
inAppOwnershipType = FAMILY_SHAREDA 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​

Rule

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.

Rule

Set appAccountToken on every purchase from the first release. It cannot be added to past purchases. A release without it is a release blocker.

Rule

Verify signedTransactionInfo and signedRenewalInfo on their own. Decoding them from a verified wrapper would accept a real notification carrying forged contents.

Rule

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_RENEW and GRACE_PERIOD_EXPIRED were not mapped, so rows never left active. Map every notification type, and log unknown ones loudly.
  • Our user id was not a UUID. Apple's appAccountToken must 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 offerType and 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.
  • appAccountToken from 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.