Apple IAP with the App Store Server API
Apple in-app purchases with server-side verification: Anythink verifies StoreKit 2 transactions and syncs renewals, refunds and expiry from Apple.
Last updated
AnythinkPay verifies Apple in-app purchases on the server and folds them into the same subscription and entitlement model as Stripe. Your app doesn't need its own receipt-verification code or a separate Apple access check.
Verification happens on the server using Apple's own signed data and the App Store Server API, so a purchase can't be faked from the client. For the product-level picture, see the Anythink subscriptions overview. For the shared concepts, see Create plans and manage subscriptions, Offer codes, free trials and entitlements and How payments and subscriptions work.
App Store Connect setup
You need these from App Store Connect:
| Value | Where to find it |
|---|---|
| App bundle ID | Your app's bundle identifier, such as com.yourcompany.app. It must match the app making purchases. |
| Default environment | Sandbox for TestFlight and App Review, Production for live. Anythink detects the real environment for each purchase, so this is only a fallback. |
| App Store Connect Issuer ID | Users and Access › Integrations › App Store Connect API. It's the account-level Issuer ID, a UUID shown above the keys list. The same value applies to In-App Purchase keys. |
| App Store Connect Key ID and Private key (.p8) | Generate a key under Users and Access › Integrations › In-App Purchase, which needs the Admin role. Download the .p8 file when you create it, because Apple only offers it once. |
You also create the auto-renewing subscription products in App Store Connect, then paste each product's identifier into the matching plan's Apple product ID. See Create plans and manage subscriptions. The key is separate from the products. It lets Anythink look up purchases and keep subscription status in sync.
In the Anythink dashboard
- Open Settings › Payments and choose the Apple tab.
- Enter the App bundle ID, Default environment, App Store Connect Issuer ID and App Store Connect Key ID, and paste the contents of the
.p8file into Private key (.p8). - Select Save Apple settings. The tab shows Configured once all four values and the key are saved.
- Copy the URL shown under App Store Server Notifications (required). In App Store Connect, open your app, then App Information › App Store Server Notifications, and set that URL for both Production and Sandbox.
- Under Test ASSN delivery, select Test sandbox or Test production. This asks Apple to send a test notification to the URL registered for that environment. Passing one doesn't validate the other, so test both.
With the CLI
anythink fetch /integrations/anythinkpay/apple-iap/credentials
anythink fetch /integrations/anythinkpay/apple-iap/credentials --method PUT --body \
'{"bundle_id":"com.example.app","environment":"sandbox","asc_issuer_id":"...","asc_key_id":"...","asc_private_key_pem":"-----BEGIN PRIVATE KEY-----..."}'
anythink fetch /integrations/anythinkpay/apple-iap/test-notification --method POST
GET credentials returns has_private_key, is_configured and the notification_url rather than the key itself. The private key is stored encrypted and never read back. On a PUT, leaving out asc_private_key_pem keeps the stored key, which suits editing only the issuer or key id, and an empty string clears it. environment is sandbox or production.
test-notification needs every value saved, including the key. It returns a token, and the result is checked with POST .../apple-iap/test-notification/{token}/status. To test one environment, add ?environment=sandbox or ?environment=production to both calls.
With an AI assistant (MCP)
Note: The Apple settings include a private key, which shouldn't be typed into a chat prompt. Set them in the dashboard or the CLI rather than asking an assistant to enter them.
Verify transactions on the server
Anythink never trusts what the app says it bought. Your app sends Apple's signed proof, and the server checks it before any subscription is created or updated.
- The app sends the transaction. After a StoreKit 2 purchase or restore, the app posts the transaction's signed JWS (
signed_transaction), or just Apple'soriginal_transaction_idfor a restore, to the verify endpoint as the signed-in user. - The signature is checked against Apple's certificate chain. For a
signed_transaction, the server validates the JWS against Apple's root certificate (Apple Root CA - G3) and confirms it belongs to your app bundle ID. It tries production first and falls back to sandbox, so TestFlight and App Review purchases verify without a switch. - A transaction ID is looked up with the App Store Server API. For an
original_transaction_id, the server calls Apple's App Store Server API with your saved issuer ID, key ID and private key, then verifies the signed transaction Apple returns in the same way. - The result is recorded. Anythink creates or refreshes the subscription, links it to the signed-in user, matches it to your plan by Apple product ID, and returns
has_access.
Status then stays current without your app doing anything. Apple sends App Store Server Notifications V2 to the notification URL from Settings › Payments › Apple. Each notification is a signed payload, and its signature is verified against the same certificate chain before it changes anything. Renewals, expiry, refunds and grace periods all arrive this way. See Keep status current with server notifications.
The raw request:
POST /org/{project_id}/integrations/anythinkpay/subscriptions/apple/verify
Authorization: Bearer <user's Anythink token>
Content-Type: application/json
{ "signed_transaction": "<JWS from a StoreKit 2 purchase>" }
The same call from TypeScript, with a plain fetch and the user's token from your sign-in flow (the SDK handles sign-in, not payments):
const res = await fetch(
`${apiUrl}/org/${projectId}/integrations/anythinkpay/subscriptions/apple/verify`,
{
method: "POST",
headers: {
Authorization: `Bearer ${userToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ signed_transaction: jws }),
},
);
const { has_access, status, expires_at } = await res.json();
Pass the JWS from the StoreKit 2 transaction (jwsRepresentation) and gate your feature on has_access. To check access later without a purchase in hand, call the entitlement endpoint, described under Mapping to subscriptions and entitlements.
Request details
The body takes either field. For a StoreKit 2 purchase or Transaction.currentEntitlements, send the JWS as signed_transaction. For a restore where you only have Apple's identifier, send:
{ "original_transaction_id": "1000000123456789" }
The call needs the anythink_subscription_plans:read permission, and the app bundle ID must be saved.
- The
signed_transactionpath checks the JWS signature directly and needs only the bundle ID. - The
original_transaction_idpath looks the transaction up with Apple's App Store Server API, so it also needs the issuer id, key id and private key saved.
A successful verify returns:
{
"subscription_id": "b6b8a2c4-0000-0000-0000-000000000000",
"bound_user_id": 61,
"provider": "apple",
"product_id": "com.example.app.pro_monthly",
"plan_name": "Pro monthly",
"plan_id": 8,
"status": "active",
"expires_at": "2026-11-04T17:49:06Z",
"has_access": true,
"matched_known_plan": true
}
Verify derives status from the transaction's expiry: active while it hasn't expired and expired once it has. Later notifications refine it.
matched_known_plan is false when the transaction's product id isn't on any plan's Apple product ID. The subscription is still recorded, but treat it as an unrecognised product and don't unlock a feature on has_access alone. When a plan matches, the subscription takes the plan's name, and the plan's Apple subscription group ID is filled in from the purchase if you left it blank.
Ownership
Verify links the transaction to the signed-in user's Anythink account.
- The first verify for a transaction links the subscription to the calling user.
- A transaction already linked to a different user returns
409, with the message that the purchase is already linked to a different account. Whoever verifies a transaction first keeps it. The same person verifying again, including a restore on a new device, resolves as already linked. - If your app sets StoreKit's
appAccountTokenon the purchase, it must be the signed-in user's Anythink account GUID. A different value returns403. If you don't set one, only the first-verify rule applies.
Verifying again is safe
Verifying the same transaction twice, such as a retry after a timeout or a call on every launch, returns the same subscription rather than a duplicate.
- If the calling user already has the subscription linked, Anythink doesn't link it again.
- The status, expiry and plan details are re-applied from the fresh verification either way, so a repeat verify also picks up any change since the first call.
- If the user buys the same Apple product again under a new original transaction, their earlier subscriptions for that product are marked
supersededand aplan_changedevent is added to the new one.
Keep status current with server notifications
Register the notification URL from Settings › Payments › Apple in App Store Connect. AnythinkPay maps Apple's App Store Server Notifications V2 to a subscription status and a timeline event:
| Apple notification | Subtype | Resulting status | Event |
|---|---|---|---|
SUBSCRIBED |
none | active |
created |
DID_RENEW |
none | active |
renewed |
OFFER_REDEEMED |
none | active |
offer_redeemed |
EXPIRED |
none | expired |
expired |
GRACE_PERIOD_EXPIRED |
none | expired |
expired |
REFUND |
none | revoked |
refunded |
REVOKE |
none | revoked |
revoked |
DID_FAIL_TO_RENEW |
GRACE_PERIOD |
grace_period |
grace_period |
DID_FAIL_TO_RENEW |
any other | in_billing_retry |
renewal_failed |
DID_CHANGE_RENEWAL_STATUS |
AUTO_RENEW_DISABLED |
cancelled |
cancelled |
DID_CHANGE_RENEWAL_STATUS |
any other | active |
resumed |
DID_CHANGE_RENEWAL_PREF |
UPGRADE |
unchanged | plan_changed |
DID_CHANGE_RENEWAL_PREF |
DOWNGRADE |
unchanged | plan_change_pending |
An EXPIRED or REVOKE notification ends access straight away. Other notification types are acknowledged without changing anything. SUBSCRIBED, DID_RENEW and OFFER_REDEEMED can also add a payment to AnythinkPay › Payments. Payments are matched on Apple's original transaction id, so the same charge isn't recorded twice.
Notifications show on the subscription's Events timeline with the source assn. A TEST notification from the Test ASSN delivery buttons isn't tied to a subscription. It's recorded so the dashboard can show the test result.
Note: If a SUBSCRIBED notification arrives before your app has called verify for the same transaction, the handler returns 503 with Retry-After: 60, and Apple retries, which gives verify time to land first. Any other notification for a transaction Anythink hasn't seen is acknowledged and ignored, because there's nothing yet to update.
Mapping to subscriptions and entitlements
Apple and Stripe subscriptions share one subscription shape and one access rule. See the status table in Create plans and manage subscriptions. Your app's entitlement call, GET /subscriptions/me/entitlement, doesn't need to know which provider a subscriber used. For an Apple subscription that matches a plan, product_id is the plan's name.
Limits
Limit: Apple notifications update subscriptions and their history, but they don't start workflows. Workflow events come from Stripe only.
Limit: Apple in-app purchases are iOS only. Android and web go through Stripe checkout. See Take payments with Stripe.
Limit: Ownership is first-wins and there's no self-service way to move a purchase. If a transaction is linked to the wrong user, for example on a shared test device, a project administrator reassigns it with Relink / manage users. See Create plans and manage subscriptions.
FAQ
What's the difference between sandbox and production?
Sandbox covers TestFlight and App Review purchases, and production covers live ones. Anythink detects the real environment of each purchase and notification, trying production first and then sandbox, so the default environment you save is only a fallback. Register the notification URL for both environments in App Store Connect.
What happens when a purchase is refunded?
Apple sends a REFUND notification. The subscription becomes revoked, access ends straight away and the timeline records a refunded event.
Do I need my own server?
No. Your app talks to Anythink with the signed-in user's token and Anythink talks to Apple. You don't write receipt-verification code or run a notification endpoint.
How does subscription status reach my app?
Apple notifies Anythink, which updates the subscription. Your app calls GET /subscriptions/me/entitlement (see Offer codes, free trials and entitlements) or the verify response and reads has_access, so it never interprets Apple's statuses itself.
Which Apple credentials do I need?
The bundle ID is enough to verify a signed_transaction. Restores by original_transaction_id also need the issuer ID, key ID and .p8 private key. See App Store Connect setup.
Permissions
| To | You need |
|---|---|
| Read or save the Apple settings, send test notifications | Project administrator |
| Call verify as the signed-in user | anythink_subscription_plans:read |