Crossdeck Docs
Dashboard

Connect Apple App Store — two keys, both verified with Apple before anything is stored

Setup App Store Connect API Key + In-App Purchase Key upload · ~10 min read · Updated May 18, 2026

Stripe connects to Crossdeck through OAuth. Apple has no OAuth, so connecting Apple as a payment rail means uploading two App Store Connect keys — an In-App Purchase Key and an App Store Connect API Key — along with four identifiers you copy from App Store Connect. Crossdeck verifies both keys directly with Apple before a single field is persisted: if either is rejected, nothing is stored and you get a precise error. There is never a half-connected state. Once verified, your product catalog mirrors in, a silent migration reconstructs your existing subscriber base, and one Server Notification URL goes into App Store Connect to start the live event feed.

TL;DR

Why Apple needs two keys

Apple does not expose one unified server API. It splits the surface Crossdeck needs across two separate APIs, each with its own key:

Apple API What it gives Crossdeck The key you generate
App Store Server API Transactions, authoritative subscription status, and the ~180-day notification history. This is the API Crossdeck reconciles every webhook against — the rail API is the truth. An In-App Purchase Key
App Store Connect API Your product catalog — every in-app purchase and every auto-renewable subscription you've defined. This is Apple's only "list my products" surface. An App Store Connect API Key (a Team Key)

Crossdeck's rail adapter hides this split — once connected, Apple behaves exactly like Stripe: products mirror in, webhooks reconcile, the dashboard treats every rail the same. But to connect, you generate both keys. They're both created in the same place (App Store Connect → Users and Access → Integrations) and share one team-level Issuer ID, so this is two short trips, not two separate setups.

Before you start

You do not rebuild or resubmit your app to set this up.

Connecting Apple is a server-side integration: you upload API keys, and Crossdeck talks to Apple's servers. Nothing here needs a new app build, a new archive, a TestFlight upload, or an App Store submission. The one thing that does need a build — exercising a real purchase event end-to-end — is covered under What happens after you connect, and even that is only a sandbox build, never an App Store submission.

Every value must match Apple exactly.

The Bundle ID, Apple ID, Issuer ID, and Key IDs you enter into Crossdeck must be copied verbatim from App Store Connect — no trimming, no guessing, no reformatting. Crossdeck's webhook receiver verifies Apple's signature against the bundle ID you registered; a mismatched or mistyped Bundle ID means every live notification fails verification. Copy, don't retype.

Find your IDs in App Store Connect

Three of the four identifiers exist before you generate any key. Collect them first.

Bundle ID and Apple ID — on the App Information page

In App Store Connect, open Apps, select your app, then in the sidebar under General click App Information. Both identifiers are in the General Information section:

Apple's label What it looks like What it is
Bundle ID com.yourcompany.app The reverse-DNS identifier for your app. It must match the bundle ID set in your Xcode project. Apple does not let you change it after you upload a build.
Apple ID 1234567890 A numeric identifier (around 10 digits) Apple generates automatically for your app. It can't be edited. It's also the number after id in your app's App Store URL.
"Apple ID" here means your app's Apple ID — not your Apple Account login.

Apple uses the term "Apple ID" for two unrelated things. The one Crossdeck needs is the numeric identifier on your app's App Information page (e.g. 1234567890). It is not the email address you sign in to App Store Connect with. Crossdeck never asks for, and never needs, your Apple Account credentials.

Issuer ID — on the Integrations page

From the App Store Connect home, open Users and Access, then click the Integrations tab. The page opens with App Store Connect API selected, and the Issuer ID is shown on that page. It's a UUID, like 57246542-96fe-1a63-e053-0824d011072a. One Issuer ID covers your whole team — the same value is used for both keys you're about to generate.

The Issuer ID is not your Team ID.

On the Users and Access page, your account shows a Team ID — a 10-character code like 8LAB5VPTNK. That is a different thing. Crossdeck does not use the Team ID anywhere. The value Crossdeck needs is the Issuer ID — the UUID on the Integrations tab. If a field in the connect form is labelled "Issuer ID", paste the UUID, never the 10-character Team ID.

Generate the In-App Purchase Key

This is the key that authenticates the App Store Server API. Apple generates it as an In-App Purchase Key — that's the exact name of the key type in App Store Connect.

  1. From the App Store Connect home, open Users and Access, then click the Integrations tab.
  2. In the sidebar, under Keys, click In-App Purchase.
  3. Click Generate In-App Purchase Key. (This button only appears for the Account Holder or an Admin.)
  4. Enter a name for the key — this is for your own reference only and is not part of the key. Crossdeck is a fine name.
  5. Click Generate.
  6. Click Download to save the key. It downloads as a file named AuthKey_<KeyID>.p8 — for example AuthKey_2X9R4HXF34.p8.
  7. Copy the key's Key ID from the list — it's the 10-character code in the filename (2X9R4HXF34 in the example). You'll need it in the connect form, though Crossdeck auto-fills it from the filename when you upload the .p8.
The .p8 file can be downloaded only once.

Apple lets you download a key's private .p8 file a single time. Save it somewhere safe the moment it downloads. If you lose it, you can't re-download — you revoke the key in App Store Connect and generate a new one. (A maximum of 50 active keys is allowed per account.)

Generate the App Store Connect API Key

This is the second key — it authenticates the App Store Connect API, which is how Crossdeck reads your product catalog. It's a separate key from the In-App Purchase Key, generated as a Team Key.

  1. Still in Users and Access → Integrations, make sure App Store Connect API is selected (it's the default view).
  2. Under Team Keys, click the plus (+) button to generate a new key.
  3. Enter a name (for example Crossdeck Catalog).
  4. Set the key's Access to Admin or App Manager. App Manager is sufficient — Crossdeck only reads the catalog with this key.
  5. Click Generate.
  6. Click Download — again an AuthKey_<KeyID>.p8 file, downloadable once.
  7. Note this key's Key ID. It is a different Key ID from the In-App Purchase Key.
You now have two .p8 files and two Key IDs.

Keep them straight: one Key ID belongs to the In-App Purchase Key, one to the App Store Connect API Key. The Issuer ID is shared — it's the same UUID for both. The connect form has a labelled slot for each, and because Apple names every file AuthKey_<KeyID>.p8, Crossdeck fills in the matching Key ID for you when you pick the file.

Connect the rail in Crossdeck

In the Crossdeck dashboard, open the Payment rails page. On the Apple App Store card, click Connect Apple App Store. A form opens with these fields:

Field What to enter
Environment Production for your live App Store data, or Sandbox for App Store sandbox testing. See Sandbox vs production below.
Bundle ID Your app's Bundle ID from the App Information page, e.g. com.yourcompany.app.
Apple ID Your app's numeric Apple ID from the App Information page, e.g. 1234567890. Digits only.
Issuer ID The team Issuer ID (UUID) from the Integrations tab.
In-App Purchase Key Its Key ID, plus the .p8 file — click the file picker and choose the AuthKey_….p8 you downloaded for the In-App Purchase Key. Crossdeck reads the file in your browser and auto-fills the Key ID from the filename.
App Store Connect API Key Its Key ID, plus the .p8 file for the App Store Connect API Key.

Click Verify & connect. Crossdeck contacts Apple to verify both keys (this takes a few seconds) and, on success, the Apple card flips to Verified.

Crossdeck never asks for your Apple Account password.

The connect form takes API keys and identifiers only. API keys are independently revocable from App Store Connect — Users and Access → Integrations — without touching your Apple Account. The .p8 files are read in your browser and sent over the authenticated call straight into Google Cloud Secret Manager; they are never written to Firestore and never persisted in the dashboard.

The verification handshake

This is exactly what happens when you click Verify & connect, as it executes in backend/src/auth/apple-connect.ts. Nothing about your rail is stored until every step below passes.

1. Shape checks. Crossdeck confirms every field is present, that the Apple ID is all digits, and that each uploaded file is a PEM private key (it must contain a BEGIN PRIVATE KEY block). A wrong file is caught here, before any network call, with an error naming which file is wrong.

2. Proof of the In-App Purchase Key. Crossdeck builds an App Store Server API client from the key, Key ID, Issuer ID, and Bundle ID, then calls Apple's requestTestNotification endpoint. That single call proves all four values work together — there's no need for an existing transaction. As a side effect, Apple dispatches a test notification to your Server Notification URL (if you've set one).

3. Proof of the App Store Connect API Key. Crossdeck makes a live, authenticated read of /v1/apps on the App Store Connect API. Success proves that key, its Key ID, and the Issuer ID are valid and can read your account's catalog.

4. Both keys go to Secret Manager. Only once both proofs pass, the two .p8 private keys are written to Google Cloud Secret Manager, one secret each:

apple-signing-key-<projectId>          // the In-App Purchase Key
app-store-connect-key-<projectId>     // the App Store Connect API Key

5. The non-secret config is written to your project.

projects/{projectId}.paymentRails.apple = {
  bundleId:           "com.yourcompany.app",
  appStoreAppId:      "1234567890",        // your app's Apple ID
  keyId:              "2X9R4HXF34",        // In-App Purchase Key ID
  ascKeyId:           "7K3P9WL2D8",        // App Store Connect API Key ID
  issuerId:           "57246542-96fe-…",
  env:                "production" | "sandbox",
  verificationStatus: "verified",
  connectedAt:        <timestamp>,
}
There is never a half-connected state.

If Apple rejects either key — a wrong Issuer ID, a mistyped Bundle ID, a corrupted .p8 — the handshake stops and nothing is written: no secret, no project record. The error tells you which key failed and includes Apple's HTTP status. A verified record only ever exists after Apple has confirmed both keys.

Register the Server Notification URL

The handshake above gets Crossdeck talking to Apple. To get live events flowing the other way — renewals, cancellations, refunds — you register one URL in App Store Connect so Apple's App Store Server Notifications V2 reach Crossdeck.

After connecting, Crossdeck shows your project's Server Notification URL in the connect form and on the Apple rail card. It looks like this, with your own project ID:

https://api.cross-deck.com/appleWebhook/<projectId>

Copy it, then in App Store Connect:

  1. Open Apps and select your app.
  2. In the sidebar, under General, click App Information.
  3. Scroll to General Information, then to App Store Server Notifications.
  4. Under Production Server URL, click Set Up URL.
  5. Paste your Crossdeck Server Notification URL.
  6. Choose Version 2not Version 1 (deprecated). Crossdeck's receiver only handles V2.
  7. Click Save.
  8. If you test in sandbox, repeat under Sandbox Server URL with the same URL.
One URL is safe for both environments.

If you set only the Production Server URL, Apple sends both production and sandbox notifications to it. Crossdeck handles that fine — the receiver reads the environment from each signed payload and stamps records accordingly, so sandbox and production never cross-pollute. Setting the Sandbox URL as well is optional and only changes where sandbox events land, not whether Crossdeck processes them correctly.

You can connect the rail before setting this URL — the handshake doesn't depend on it. But until the URL is registered, live notifications won't reach Crossdeck, so renewals and cancellations won't update in real time. Set it as soon as the rail is verified.

What happens after you connect

The moment paymentRails.apple reaches verificationStatus: "verified" on your project, two things run on their own — you don't click anything.

Your catalog mirrors in

Crossdeck calls the App Store Connect API with the key you uploaded and mirrors your full catalog — every in-app purchase (consumable, non-consumable, non-renewing) and every auto-renewable subscription — into the dashboard's Products page. A product mirrors as active only when App Store Connect reports its state as APPROVED; anything in review, rejected, or removed from sale still appears in the catalog (so you can map entitlements ahead of approval) but is marked inactive.

Apple has no catalog webhook — nothing pushes a product change to Crossdeck. Catalog sync is therefore pull-based: it runs at connect, and you can re-pull any time with Refresh from rail on the Products page.

Do you need to rebuild or resubmit your app for the catalog to pull through? No.

The catalog comes from the App Store Connect API, which reads the products you've defined in App Store Connect — product records that live on Apple's servers, independent of any app binary. The only precondition for a product to appear in Crossdeck is that it exists in App Store Connect (your app → In-App Purchases / Subscriptions); defining a product there is a website setting, not an app build. If you've defined no products, the catalog pulls in empty — that is "nothing to mirror", not a fault.

What does need a build is exercising a real purchase: testing an actual subscription or transaction event flowing through the webhook requires a StoreKit purchase against Apple's sandbox, which runs from a TestFlight or development build. Even that is never an App Store submission or public review. Connecting the rail and pulling the catalog — the setup steps in this guide — need none of it.

What Crossdeck reports for your Apple revenue — the scope contract

From the moment your webhook is verified, every Apple transaction — subscribe, renew, billing retry, refund, revoke — flows through the App Store Server API as it happens. We pull authoritative state per event and reconcile every known customer's full chain on a daily cron. This is bank-grade for everything that happens from connect-date forward.

What Crossdeck does not do — and we are explicit, because doing it badly is worse than not doing it. We don't reconstruct your pre-connect historical customer base as a bulk surface. Apple has no “list all customers for an app” endpoint; the only way to read a customer's chain is via their originalTransactionId, which Apple hands us only when a notification fires for that customer. For periods before your webhook was registered, Apple did not attempt to deliver notifications to you — the notification history retrieval is scoped to delivery attempts on your URL — so there are no transactionIds for us to walk.

The honest framing is forward-only with self-healing. Here's how each cohort fills in:

CohortHow they land in CrossdeckCoverage
New customers from connect-date forward Webhook fires → handler hits getTransactionInfo → writes authoritative state into purchases/* or subscriptions/*. Bank-grade, 100%
Pre-connect customers who renew, refund, or come back through the SDK with appAccountToken The post-connect event hands us their otid → Path B walks getTransactionHistory for their full chain (including any pre-connect purchases on that otid). Self-healing, monotonic — the gap shrinks as your app keeps running.
Notifications Apple actually delivered in the ~180-day notification-history window post-connect Discovery replays the notification history at connect time, extracts each unique otid, and Path B walks the chain for every customer that surfaces. Captured at connect — whatever was in Apple's retained history.
Pre-connect customers who paid once and have not generated any post-connect event Not reachable via Apple's API without their transactionId. If you have these otids in your own data (server logs, App Store Connect financial reports), POST them to /v1/migration/users and each one becomes a chain-walk that pulls the customer's full purchase history. Out of scope by Apple's design — opt-in via otid upload only.
The Stripe-grade contract: we optimise for the dollar that lands from now on.

Historical reconstruction isn't promised because Apple's API doesn't let us deliver it honestly. Everything you see on the Revenue tab for Apple is dated from connect-date forward — the "Lifetime (Crossdeck-tracked)" tile stamps the exact connect date in its sub-line so the boundary is auditable, not hand-wavy. A returning paying customer reads isEntitled("pro") === true the moment they show up; their pre-connect purchases populate into purchases/* retroactively via Path B. The window only matters for customers who paid once years ago and have never returned, and Apple won't tell us anything about them without their transactionId.

Backfill is idempotent — it keys on Apple's originalTransactionId, so re-running converges on the same state with no duplicates. Typical small accounts populate within a few minutes; established apps see a long tail of self-healing fills as old customers come back through the SDK or generate webhook events. For bulk-historical otid uploads see Migration.

Tying Apple chains to your app users — the appAccountToken contract

Apple's transaction record includes an opaque field called appAccountToken that you, the developer, set at purchase time. The contract: the value you stamp there must be a UUID that uniquely identifies the purchasing entity for as long as that subscription chain lives. Crossdeck uses it to join an Apple subscription to the user in your own app — without it, the only thing linking an Apple chain to a real human is the developer-supplied identifier the SDK happens to be sending at the moment of purchase, and that identifier is mutable across a user's life (anonymous → logged in, account merges, SSO upgrades). Apple's transaction record is not mutable; the token stays forever.

The Swift SDK ships a helper that handles this for you. Call it before the StoreKit purchase:

let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [
    .appAccountToken(token)
])

The helper returns a non-optional UUID — the exact type Product.PurchaseOption.appAccountToken(_:) wants. No nil check, no force-unwrap, no UUID(uuidString:) dance on your side.

What it does: mints a fresh UUID() on first call, persists it under the storage key crossdeck.apple_app_account_token, returns the same value forever within the install/sign-in session. Identity mutations (anonymous → identified, traits updated, crossdeckCustomerId resolved from the server) do not change the token. Crossdeck.reset() (sign-out) wipes it — the next user on the same device mints a fresh one. Uniqueness-per-purchasing-entity is the property that makes the server-side attribution join correct.

What about purchases before the user signs up? The helper is safe to call from an anonymous session. It lazy-mints the token on first call — the purchase still gets a stable, Apple-immutable token stamped on it. The token is not derived from the SDK's anonymous identifier; it's a fresh random UUID. When the user later signs up and you call Crossdeck.identify(userId), the SDK forwards the existing token alongside the alias request, and the server attaches every prior purchase in that chain to the new userId. No re-purchase, no manual reconciliation.

Server-side: when your app calls Crossdeck.identify(userId), the SDK attaches the persisted token to the alias request. Crossdeck records the binding appAccountToken → developerUserId at that moment. When Apple's ASSN V2 webhook arrives later carrying that same token, Crossdeck resolves the customer via the recorded binding — not via the older implicit assumption that appAccountToken == developerUserId.

Do not roll your own UUID, do not derive from your user ID, do not skip the helper.

Apple's transaction record is permanent. A token that goes stale never recovers — every renewal in that chain carries the wrong token forever, and the subscription orphans silently. The Swift SDK shipped a derivation-from-developerUserId path in v1.4.x that caused exactly this trap; v1.5.0+ replaces it with the helper above. Upgrade and use the helper at every purchase site.

What if a customer slipped through with a mismatched token (older SDK install, manual syncPurchases call without the helper, etc.)? They surface in Settings → Identity → Conflicts as an Apple unbound token row. The operator reviews each: confirm the implicit resolution was correct, mark the customer standalone (rail-only with no app user expected), or claim the subscription into the right app user via the customer detail page's merge flow.

How webhooks are handled

Once your Server Notification URL is registered, every Apple event — subscribe, renew, billing retry, grace period, expire, refund, revoke — arrives at Crossdeck's receiver as a JWS-signed payload. Each one runs through the same pipeline:

  1. Signature verification. The JWS certificate chain is verified against Apple's bundled root certificates. A payload that fails verification is rejected with 401 and Apple retries.
  2. Idempotency. The notification's notificationUUID is claimed once; Apple's at-least-once redeliveries become no-ops.
  3. Reconciliation. The webhook tells Crossdeck which subscription changed; Crossdeck then fetches the authoritative current state from the App Store Server API. A delayed or out-of-order notification therefore cannot persist a stale state — the webhook is the nudge, the rail API is the truth.
  4. Projection & audit. The subscription record and entitlements are updated, and every decision (applied, no_op, rejected) is written to the audit log, visible on the Rails page's per-rail health pill.

You don't write any of this. For the full V2 notification taxonomy — every type Crossdeck maps and the state transition it drives — see Rail webhooks → Apple.

Sandbox vs production

Apple runs production and sandbox as separate environments on separate API hosts. The Environment toggle in the connect form pins your rail to one of them:

The keys you generate in App Store Connect work for both environments; the toggle tells Crossdeck which host to query. On the inbound side, the webhook receiver reads the environment from each signed notification, so even if Apple sends sandbox events to your production URL, they're stamped sandbox and kept separate — a sandbox test purchase can never light up a production entitlement.

Disconnecting

Click Disconnect on the Apple rail card on the Payment rails page. Crossdeck replaces the connection record with a disconnected stub: from that point, inbound Apple webhooks are ignored, and subscription reconciliation and catalog sync stop.

Your two .p8 keys are intentionally left in Secret Manager — inert, with no project record pointing at them — so a later reconnect can reuse the slots without an irreversible delete. Your historical data (customers, subscriptions, entitlements) stays intact in Crossdeck; only new events stop ingesting.

To revoke access fully on Apple's side, revoke the keys themselves in App Store Connect → Users and Access → Integrations. Apple's side is then authoritative — a revoked key can't authenticate, regardless of what's stored anywhere.

Troubleshooting

"Apple rejected these credentials — verify the .p8 key, key ID, issuer ID, and bundle ID"

The In-App Purchase Key handshake failed — Apple rejected the App Store Server API request. Re-check all four values against App Store Connect. The two most common causes: a mistyped Bundle ID, and an Issuer ID that's actually the 10-character Team ID instead of the UUID. Confirm the Key ID belongs to the In-App Purchase Key, not the App Store Connect API Key.

"Apple rejected the App Store Connect API key — verify the .p8 key, key ID, and issuer ID"

The catalog-key handshake failed. Confirm the .p8, Key ID, and Issuer ID for the App Store Connect API Key. Make sure the key's access was set to Admin or App Manager when you generated it — a key with insufficient access can't read the catalog.

"appStoreAppId must be the numeric App Store app ID"

The Apple ID field must be digits only (e.g. 1234567890). You've likely entered the Bundle ID, or your Apple Account email, by mistake. The numeric Apple ID is on the App Information page.

"p8Key does not look like a .p8 private key"

The file you uploaded isn't a private key. Upload the AuthKey_….p8 file Apple gave you when you generated the key — a PEM file containing a BEGIN PRIVATE KEY block. A certificate (.cer) or a provisioning profile is not the same thing.

"Could not sign a request with this key — check the .p8 file is intact"

The .p8 is the right kind of file but is corrupted or truncated — often from copy-pasting its contents instead of uploading the file. Because Apple only lets you download a key once, you can't re-download it: revoke that key in App Store Connect and generate a fresh one.

Rail is verified, but no webhook events appear

Almost always the Server Notification URL. Confirm in App Store Connect → your app → App Information → App Store Server Notifications that the URL is set, that it's Version 2, and that it's this project's exact URL (api.cross-deck.com/appleWebhook/<projectId> — the project ID must match). The Rails page shows a per-rail health pill from the last 100 audit entries; if it's red, signature verification or apply is failing and the audit log says why.

Products aren't appearing on the Products page

Catalog sync runs on connect via the App Store Connect API Key. If products are missing, that key may lack catalog access — check it was generated with Admin or App Manager access — or there were no APPROVED products at connect time. Click Refresh from rail on the Products page to re-pull. Remember a product only mirrors as active once App Store Connect marks it APPROVED.

"Apple is not connected on this project"

An action that needs a verified rail (such as Refresh discovery) ran before the connect handshake completed, or after a disconnect. Reconnect from the Rails page and wait for the card to show Verified.

What's next


Last updated when the Apple App Store payment rail shipped end-to-end — connection, catalog mirror, webhook reconciliation, and migration (May 18, 2026). Code references: backend/src/auth/apple-connect.ts, backend/src/lib/apple-server-api.ts, backend/src/lib/apple-store-connect-api.ts, backend/src/lib/apple-verifier.ts, backend/src/webhooks/apple.ts, backend/src/migration/apple-discover.ts, backend/src/migration/apple-backfill.ts, dashboard/rails/rails.js.