Skip to Content
BillingApple App Store

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 .p8 file you downloaded (the file is single-download; store it carefully).

Setup in the PickLayer admin UI

  1. Open the Billing page in the PickLayer console.

  2. Scroll to External Integrations and click Add integration.

  3. Set Provider to Apple App Store. Give it a Display name (free-text label, e.g. iOS production).

  4. 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" }
    • environment is optional; valid values are production (default) and sandbox. Use sandbox for TestFlight and Xcode StoreKit testing.
    • The verifier also accepts the snake_case aliases issuer_id, key_id, private_key, and env.
    • Embed \n in the PEM string; do not paste raw line breaks inside the JSON value.
  5. (Optional) Set a Webhook secret. If you provide one, every request to /external-checkout/apple must include an X-Apple-Signature header containing HMAC-SHA256(secret, JSON.stringify(body)) in hex. Requests without a valid header are rejected with 401. If you leave the field blank, only the JWS-level Apple signature is checked.

  6. Set Status to Active and click Create. Credentials are stored encrypted at rest and used only to validate billing events.

  7. On a Billing Plan, attach the integration by adding a funding mode of type BUILDER_CUSTOM_CHECKOUT (or USER) that references this integration. The plan’s funding mode is what links an Apple transaction to a specific plan.

iOS-side flow

  1. The user taps the purchase button in your iOS app.

  2. Your app calls StoreKit 2 (Product.purchase()) and unwraps the resulting Transaction. Each transaction carries a jwsRepresentation — a signed JWS string.

  3. 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 that intentId as the StoreKit appAccountToken so it ends up inside the signed payload.

  4. Your app POSTs the JWS to PickLayer:

    POST https://<your-picklayer-host>/external-checkout/apple Content-Type: application/json { "signedPayload": "<jwsRepresentation>" }
  5. PickLayer:

    • Verifies the ES256 signature on the JWS and walks the x5c certificate chain to the pinned Apple Root CA G3.
    • Extracts appAccountToken (intent ID) and transactionId from 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 it verified, credits the user’s plan wallet, and flips the user-plan assignment to funding_status = 'funded'.
  6. Your app polls GET /v1/billing/readiness (or watches your usual entitlement state) to confirm activation. Expect funded: true once 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

HeaderRequiredNotes
Content-Type: application/jsonyes
X-Apple-Signatureonly if the integration has a webhookSecret configuredHMAC-SHA256(webhookSecret, JSON.stringify(body)) in hex. Defense-in-depth on top of the JWS check.

Request body

FieldTypeRequiredNotes
signedPayloadstring (JWS)usuallyThe 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.
intentIdstring (UUID)only if no signedPayloadThe PlanFundingIntent ID.
transactionIdstringonly if no signedPayloadThe 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

StatusWhen
400Body parsed but intentId and transactionId could not be resolved, or the funding intent is not configured for Apple verification (wrong fundingType, missing externalCheckoutIntegrationId).
401JWS signature invalid, certificate chain does not anchor to Apple Root CA G3, missing X-Apple-Signature when one is required, or HMAC mismatch.
404intentId does not match any funding intent, or the integration referenced by the intent no longer exists.
500Apple’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:

environmentStoreKit environmentBase URL
production (default)App Store and TestFlight production buildshttps://api.storekit.itunes.apple.com
sandboxXcode StoreKit testing, sandbox tester accountshttps://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

SymptomLikely cause
401 Invalid Apple signed payloadThe 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 headerThe 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 signatureHMAC 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 requiredThe 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 foundThe 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 verificationThe 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 configurationThe 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.
Last updated on