Apple App Store
PickLayer accepts signed StoreKit 2 transactions from your iOS app and uses them to fund a billing plan. There is no separate payment processor in the loop: your app makes the purchase through StoreKit, posts the resulting JWS to PickLayer, and PickLayer activates the user’s plan after verifying the signature against Apple Root CA G3 and confirming the transaction with App Store Server API.
For Stripe-based subscriptions on web and Android, see Stripe. For the cross-cutting usage and quota model, see the billing overview.
Prerequisites
- An App Store Connect account with the app set up and at least one build on TestFlight or the App Store.
- One or more in-app purchase products configured in App Store Connect — non-consumables, auto-renewing subscriptions, or non-renewing subscriptions, depending on what your billing plan represents.
- An App Store Server API key, generated under Users and Access → Integrations → App Store Server API → Keys. You will need three values from this key:
issuerId— UUID shown at the top of the Keys page.keyId— the 10-character ID shown next to the key after download.privateKey— the PEM contents of the.p8file you downloaded (the file is single-download; store it carefully).
Setup in the PickLayer admin UI
-
Open the Billing page in the PickLayer console.
-
Scroll to External Integrations and click Add integration.
-
Set Provider to Apple App Store. Give it a Display name (free-text label, e.g.
iOS production). -
Paste the credential JSON into the Credentials JSON / token field. Use these field names:
{ "issuerId": "xxxx-xxxx", "keyId": "ABC123", "privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", "environment": "production" }environmentis optional; valid values areproduction(default) andsandbox. Usesandboxfor TestFlight and Xcode StoreKit testing.- The verifier also accepts the snake_case aliases
issuer_id,key_id,private_key, andenv. - Embed
\nin the PEM string; do not paste raw line breaks inside the JSON value.
-
(Optional) Set a Webhook secret. If you provide one, every request to
/external-checkout/applemust include anX-Apple-Signatureheader containingHMAC-SHA256(secret, JSON.stringify(body))in hex. Requests without a valid header are rejected with401. If you leave the field blank, only the JWS-level Apple signature is checked. -
Set Status to Active and click Create. Credentials are stored encrypted at rest and used only to validate billing events.
-
On a Billing Plan, attach the integration by adding a funding mode of type
BUILDER_CUSTOM_CHECKOUT(orUSER) that references this integration. The plan’s funding mode is what links an Apple transaction to a specific plan.
iOS-side flow
-
The user taps the purchase button in your iOS app.
-
Your app calls StoreKit 2 (
Product.purchase()) and unwraps the resultingTransaction. Each transaction carries ajwsRepresentation— a signed JWS string. -
Before kicking off the StoreKit purchase, your app should have created a plan funding intent through the standard PickLayer billing API and obtained an
intentId(UUID). Pass thatintentIdas the StoreKitappAccountTokenso it ends up inside the signed payload. -
Your app POSTs the JWS to PickLayer:
POST https://<your-picklayer-host>/external-checkout/apple Content-Type: application/json { "signedPayload": "<jwsRepresentation>" } -
PickLayer:
- Verifies the ES256 signature on the JWS and walks the
x5ccertificate chain to the pinned Apple Root CA G3. - Extracts
appAccountToken(intent ID) andtransactionIdfrom the payload. - Calls Apple’s App Store Server API with a JWT signed by your integration credentials to fetch the authoritative transaction record.
- Looks up the matching
PlanFundingIntent, marks itverified, credits the user’s plan wallet, and flips the user-plan assignment tofunding_status = 'funded'.
- Verifies the ES256 signature on the JWS and walks the
-
Your app polls
GET /v1/billing/readiness(or watches your usual entitlement state) to confirm activation. Expectfunded: trueonce verification completes.
Endpoint reference
POST /external-checkout/apple
Public endpoint — no Authorization header. The Apple JWS signature is the auth: it cannot be forged without Apple’s signing key.
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type: application/json | yes | |
X-Apple-Signature | only if the integration has a webhookSecret configured | HMAC-SHA256(webhookSecret, JSON.stringify(body)) in hex. Defense-in-depth on top of the JWS check. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
signedPayload | string (JWS) | usually | The jwsRepresentation from StoreKit 2. When present, intentId and transactionId are extracted from the decoded payload (data.appAccountToken and data.transactionId / data.originalTransactionId) and the body fields below are ignored. |
intentId | string (UUID) | only if no signedPayload | The PlanFundingIntent ID. |
transactionId | string | only if no signedPayload | The Apple transaction ID. |
In production you should always send signedPayload — the intentId / transactionId-only path exists for debugging and bypasses JWS verification.
Successful response (200)
{
"code": 200,
"type": "success",
"message": "Apple checkout verified",
"data": {}
}Error responses
| Status | When |
|---|---|
400 | Body parsed but intentId and transactionId could not be resolved, or the funding intent is not configured for Apple verification (wrong fundingType, missing externalCheckoutIntegrationId). |
401 | JWS signature invalid, certificate chain does not anchor to Apple Root CA G3, missing X-Apple-Signature when one is required, or HMAC mismatch. |
404 | intentId does not match any funding intent, or the integration referenced by the intent no longer exists. |
500 | Apple’s App Store Server API returned a non-2xx response, or an internal error occurred during settlement. |
Sandbox vs production
The environment field on the integration determines which App Store Server API base URL the verifier hits:
environment | StoreKit environment | Base URL |
|---|---|---|
production (default) | App Store and TestFlight production builds | https://api.storekit.itunes.apple.com |
sandbox | Xcode StoreKit testing, sandbox tester accounts | https://api.storekit-sandbox.itunes.apple.com |
If your iOS build can target both (e.g. you need the same backend to serve TestFlight production and sandbox testers), create two separate integrations and attach them to separate funding modes.
Troubleshooting
| Symptom | Likely cause |
|---|---|
401 Invalid Apple signed payload | The JWS you posted failed verification. Either the signature is wrong (truncated payload, re-encoded by your network layer), the x5c chain doesn’t anchor to Apple Root CA G3, or the leaf cert has expired. Verify the exact jwsRepresentation string was posted unmodified. |
401 Missing x-apple-signature header | The integration has a webhookSecret set but your request didn’t include X-Apple-Signature. Either add the HMAC header or clear the secret on the integration. |
401 Invalid webhook signature | HMAC mismatch. The signature is HMAC-SHA256(webhookSecret, JSON.stringify(body)) in hex — make sure you serialize the same JSON your app sends and don’t double-encode. |
400 Intent ID and transaction ID are required | The decoded JWS payload had no appAccountToken (intent ID) or no transactionId. Pass the PickLayer intentId as the StoreKit appAccountToken when initiating the purchase. |
404 Funding intent not found | The intentId (from appAccountToken) doesn’t match any row. Either the intent expired and was cleaned up, or you used a stale intent from a previous attempt — create a fresh intent for each purchase. |
400 Intent is not configured for Apple verification | The funding mode behind this intent has fundingType other than USER or BUILDER_CUSTOM_CHECKOUT. Reconfigure the plan’s funding mode in the admin UI. |
400 Intent is missing Apple integration configuration | The funding intent has no externalCheckoutIntegrationId. The funding mode wasn’t bound to an external integration when the intent was created. |
500 Apple verification failed: 401 ... | The JWT PickLayer signed for App Store Server API was rejected. Check that the keyId, issuerId, and privateKey in your integration JSON match a current, non-revoked key in App Store Connect, and that you generated an App Store Server API key (not an In-App Purchase key). |
500 Apple verification failed: 404 ... | Apple has no record of the transaction — usually environment mismatch. A sandbox transaction posted to a production integration (or vice-versa) returns 404. |