Crossdeck Docs
Dashboard

Rail webhooks — how Crossdeck receives signed events from your payment rails

Reference Operated by Crossdeck · ~18 min read · Updated May 15, 2026

Crossdeck operates the receivers for every payment-rail webhook so you don't have to write one. Apple App Store, Stripe Connect, and Google Play each push signed events to a Crossdeck-owned endpoint; we verify the signature, dedupe the event ID, project the change into a hash-chained ledger, and recompute your customer's state — all before the event ever shows up in your dashboard. Stripe is zero-config after OAuth. Apple and Google need a small one-time setup, documented below.

TL;DR

Inbound vs outbound

This doc covers rails → Crossdeck (inbound).

For Crossdeck → your backend (outbound push notifications when a customer's subscription state changes), see Receiving Crossdeck webhooks. That direction is currently roadmap; today, the dashboard, the SDK's reactive onEntitlementsChange listener, and server-side getEntitlements() calls cover the consumption side.

The asymmetry is intentional. Inbound is hard — three rails, three signature schemes, three retry models, three error semantics — and getting any of it wrong leaks revenue or grants unpaid access. Outbound is comparatively easy, and adding it is a feature that lands on top of a finished inbound layer rather than a precondition. The inbound layer described here is finished.

The shared pipeline — what every rail webhook does at Crossdeck

Each rail's receiver function lives in backend/src/webhooks/ and follows the same six-step sequence. The shape never changes; only the rail-specific verification primitive and the routing primitive vary.

StepWhat it doesWhy it matters
1. Verification Rail-specific signature check: Apple JWS leaf-cert chain to Apple's root, Stripe HMAC-SHA256 against the per-account signing secret, Google Pub/Sub OIDC token from the expected service account. Anything malformed is rejected at the edge before any read. No project state is touched on a forged or tampered event.
2. Routing Resolve which Crossdeck project this event belongs to. Apple uses the projectId in the URL path; Stripe Connect uses event.account against the connectedAccountIndex; Google uses the RTDN payload's packageName. One Crossdeck deployment serves every developer. Routing decides whose ledger receives this event.
3. Idempotency Look up the rail event ID (Apple's notificationUUID, Stripe's event.id, or Google's composite eventTimeMillis + purchaseToken key) and short-circuit if we've already processed it. All three rails retry on timeout. A duplicate event must produce zero side effects beyond a single recorded receipt.
4. Identity decision Figure out which cdcust this event belongs to. If new, mint one and record rail_customer_created in the identity journal. If known, record rail_attached when a hint (Apple appAccountToken, Stripe metadata.crossdeck_ref off the subscription/session) links the rail customer to the existing Crossdeck customer that reference resolves to. Identity decisions are hash-chained too — every rail event that touches a customer is auditable to the indexes that were probed and the evidence that justified the decision.
5. Ledger projection Append a forward-only entry to the catalog ledger via writeProjection in a single transaction with the projection write. Subscriptions, purchases, refunds, plan changes — each becomes one entry. The ledger is the source of truth. The dashboard, the analytics warehouse, and the entitlement projection all read downstream from here. The ledger is never edited.
6. State + downstream Recompose the customer's entitlement state from the ledger and emit to listeners: dashboard Firestore onSnapshot subscribers update in real time; the analytics warehouse picks up the event; the Activity page renders the event with rail context. The webhook → state recompute → consumer-side update path is sub-second for Stripe and a few seconds for Apple and Google. Your dashboard reflects what just happened on the rail.

Two contracts are enforced at compile time inside the spine:

Apple — App Store Server Notifications V2

What Apple sends

Apple delivers every subscription, purchase, refund, and lifecycle event as a JWS-signed payload to the URL you registered. Crossdeck's receiver handles the full V2 taxonomy. The notification types we map and react to include:

Notification typeWhat it meansCrossdeck-derived signal
SUBSCRIBED (subtype INITIAL_BUY)First purchase, possibly with an introductory offer / trial.trial_started
SUBSCRIBED (subtype RESUBSCRIBE)A previously-expired subscriber returned.resubscribed
DID_RENEWAuto-renewal succeeded. If the previous state was trial, this is the conversion.renewed or trial_converted
DID_FAIL_TO_RENEWBilling failed; the sub enters retry or grace period depending on subtype.billing_retry_entered or grace_period_entered
DID_RECOVERA failed billing was recovered.billing_recovered
GRACE_PERIOD_EXPIREDThe grace window ended without recovery.grace_period_expired
EXPIREDThe subscription terminated.expired
REFUND / REFUND_REVERSEDApple processed (or reversed) a refund.refund_issued / refund_reversed
REVOKEFamily Sharing access was revoked.revoked
DID_CHANGE_RENEWAL_STATUS / DID_CHANGE_RENEWAL_PREFAuto-renew toggled or plan changed.renewal_status_changed
PRICE_INCREASE / PRICE_CHANGEApple notified the user of a price change.price_change_notified
OFFER_REDEEMEDA promotional offer was applied.(carried in renewal info)
CONSUMPTION_REQUESTApple is asking for consumption metadata for a refund decision.(no state change — informational)
TESTApple's sandbox-only test ping to verify your endpoint.Acknowledged with no_op, reason test_notification.

One-off StoreKit purchases (Consumable, Non-Consumable, Non-Renewing) ride the same notification stream — Crossdeck dispatches on transaction.type and writes them to a separate purchases ledger instead of the subscriptions one.

Verification — JWS chain to Apple's root

Apple signs every notification with a leaf certificate; the leaf certificate is signed by an intermediate CA, which is signed by Apple's root. Crossdeck's receiver decodes the JWS, verifies the chain, and rejects anything that doesn't terminate at a known Apple root certificate. The verifier also re-verifies the inner signedTransactionInfo and signedRenewalInfo JWS payloads inside the same notification — Apple signs every field, end to end.

Implementation: the verifier is built per-call from buildAppleVerifier(env, config) in lib/apple-verifier.ts using Apple's official SignedDataVerifier from @apple/app-store-server-library. Online checks (cert revocation) are enabled. The bundle ID from your project config is part of the verifier construction — the verifier rejects payloads whose bundleId doesn't match what you registered.

Because the production endpoint and sandbox endpoint share a URL, the receiver tries the production verifier first and falls back to sandbox on failure. If both fail, it returns 401 Signature verification failed and writes nothing.

Configuration — what you do

Apple needs three things on Apple's side and one thing on ours, in either order:

  1. Upload your .p8 key + Key ID + Issuer ID in Dashboard → Payment rails → Apple App Store. The key is what Crossdeck uses to authenticate back to the App Store Server API during reconciliation. The Key ID and Issuer ID come from App Store Connect → Users and Access → Keys → In-App Purchase.
  2. Set the App Store Server Notifications V2 URL in App Store Connect → your app → App Information → App Store Server Notifications. Paste:
    https://api.cross-deck.com/v1/webhooks/apple/{your-projectId}
    Your dashboard's Developers → API page renders the exact URL with your projectId interpolated, with a one-click copy button.
  3. Select both Production and Sandbox notification environments so test purchases ingest too. Apple sends sandbox notifications to the same URL with an environment field on the inner payload; Crossdeck routes them to your sandbox ledger automatically.

That's it. Once those land, every signed event from Apple flows through the receiver and into your dashboard. There is no second-step "now go configure idempotency" or "now go register webhook secrets" — Apple signs with their leaf cert and Crossdeck verifies against their root chain; no shared secret is needed.

Identity hint: pass appAccountToken at purchase time.

If your iOS app sets an appAccountToken on the StoreKit purchase (a UUID matching your auth provider's user ID), Crossdeck reads it off the signed transaction and links the resulting cdcust to your existing developer user. Without it, Apple purchases attach to a rail-only customer that gets reconciled later. With it, the very first webhook gets the identity right.

Stripe Connect — zero-config after OAuth

This is the marquee feature of Crossdeck's webhook layer. The Stripe section is short on purpose: there is almost nothing for you to do.

The shipped flow

One Stripe webhook endpoint is registered at the Crossdeck platform level and receives events from every connected account that any of our developers has OAuthed. When you click "Connect Stripe" in the dashboard, the OAuth flow exchanges the authorization code for a connected-account token and writes the connected account ID into our connectedAccountIndex. From that moment forward, every webhook Stripe fires for events on your account — created, updated, deleted, paid, failed, refunded, disputed — lands at our platform endpoint, gets routed to your project via event.account lookup, and flows through the same pipeline as Apple and Google.

The platform-level Stripe webhook is registered once by Crossdeck against the platform's Stripe Connect application. Connected accounts inherit that subscription automatically — that's how Stripe Connect works. You don't paste a URL into a Stripe dashboard. You don't copy a signing secret. You don't configure event types. You don't write a verification function. The webhook is already running; OAuthing just routes events that account emits into your ledger.

The dashboard shows a Stripe webhook URL — that's informational, not actionable.

Your Developers → API page renders the platform endpoint (stripeConnectWebhook) for audit and transparency. You do not paste it into Stripe yourself. It's listed so a CTO doing a security review can see where Crossdeck receives Stripe events from.

What Stripe sends to us

Crossdeck subscribes the platform webhook to a fixed, canonical list of event types. These are exactly the events that move subscription, purchase, or catalog state — anything else from Stripe is ignored at the edge. The handled list, from HANDLED_EVENT_TYPES in backend/src/webhooks/stripe-platform.ts:

Event typeWhat it triggers in Crossdeck
checkout.session.completedSubscription path (mode = subscription) or one-off purchase path (mode = payment), routed by the session's mode.
customer.subscription.createdNew subscription record; state derived from Stripe status + trial_end + cancel_at_period_end.
customer.subscription.updatedState recompute on the subscription record. Plan changes, status flips, trial expiries, pause/resume.
customer.subscription.deletedSubscription terminated; state moves to EXPIRED (or REFUNDED if the deletion was refund-driven).
customer.subscription.trial_will_endSignal trial_ending_soon — pre-emptive trial-conversion churn warning.
invoice.payment_succeededPeriod rolled forward, renewal succeeded, billing recovered.
invoice.payment_failedSubscription enters billing retry. State recompute.
payment_intent.succeededOne-off charge (no invoice attached). Branches to the purchases ledger.
payment_intent.payment_failedOne-off charge failed — auditable but no entitlement effect.
charge.refundedRefund issued. State moves to REFUNDED on the originating subscription or purchase.
charge.dispute.createdDispute filed — separate disputed-state branch on the purchase ledger.
product.created / product.updated / product.deletedCatalog event — normalised through the Stripe adapter and projected via projectRailEvent to your catalog skus collection.
price.created / price.updated / price.deletedCatalog event — same path as product events.

Verification — Stripe-Signature HMAC-SHA256

Every Stripe webhook arrives with a Stripe-Signature header. The receiver verifies it using Stripe's official stripe.webhooks.constructEvent(rawBody, signatureHeader, platformWebhookSecret), which performs an HMAC-SHA256 compare in timing-safe constant time and respects Stripe's replay window. The platform signing secret lives in Google Cloud Secret Manager — never in environment files or Firestore. A failed signature returns 401 Signature verification failed immediately.

After signature verification, the receiver also reconciles the event by calling stripe.events.retrieve(event.id, { stripeAccount }) back into the connected account. This second call fetches the canonical event payload directly from Stripe — upgrading the trust level from "provider-attested via signed webhook" to "provider-attested AND independently re-fetched." Re-fetched values are used for the actual ledger write; the inbound webhook payload is treated as a notification, not the source of truth.

Configuration — just OAuth

The complete developer task list for Stripe:

  1. Dashboard → Payment rails → Stripe → Connect.
  2. Complete Stripe's OAuth screen.
  3. Done.

From the moment the OAuth callback completes and the connected account ID is written into connectedAccountIndex, every webhook Stripe sends for that account flows into your ledger. No URLs pasted, no secrets copied, no event types selected.

OAuth scope is read_write — but Crossdeck never writes.

Stripe Connect's default OAuth scope on the platform is read_write, which is what Crossdeck currently requests. The "we never write" guarantee is enforced in code, not in scope: no backend module calls Stripe.subscriptions.create, Stripe.refunds.*, or any other write op with a stripeAccount option set. Every connected-account API call is a read (events.retrieve, subscriptions.retrieve, customers.retrieve) for verification and reconciliation only.

Google Play — Real-time Developer Notifications

Transport: Pub/Sub, not HTTPS

Google Play doesn't push webhooks over HTTPS like Apple and Stripe do. Instead, the Play Console publishes a message to a Google Cloud Pub/Sub topic. Crossdeck has a push subscription on that topic that triggers our googlePlayWebhook function on every publish.

This is operationally bank-grade by virtue of the transport: Pub/Sub provides at-least-once delivery, automatic retry with exponential backoff, and dead-letter forwarding for poison messages. Crossdeck doesn't have to re-implement any of that; we just process each message idempotently and let Pub/Sub handle redelivery on failure.

What Google sends

Notification typeWhat it meansCrossdeck-derived signal
SUBSCRIPTION_PURCHASEDFirst purchase of a subscription product.trial_started or resubscribed
SUBSCRIPTION_RENEWEDAuto-renewal succeeded.renewed or trial_converted
SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTEDBilling recovered after a failure.billing_recovered
SUBSCRIPTION_ON_HOLDBilling failed — sub is in account hold.billing_retry_entered
SUBSCRIPTION_IN_GRACE_PERIODBilling failed but grace period is active.grace_period_entered
SUBSCRIPTION_CANCELEDAuto-renew turned off; sub will expire at period end.cancellation_scheduled
SUBSCRIPTION_EXPIREDThe subscription terminated.expired
SUBSCRIPTION_REVOKEDGoogle revoked access (refund-driven).refund_issued
SUBSCRIPTION_PAUSEDThe user paused the subscription.paused
SUBSCRIPTION_PRICE_CHANGE_CONFIRMEDThe user accepted a price change.price_change_confirmed
SUBSCRIPTION_DEFERREDThe renewal date moved.renewal_deferred
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGEDPause schedule updated.(no state change)
SUBSCRIPTION_PENDING_PURCHASE_CANCELEDA pending purchase was cancelled before completion.(no state change)
ONE_TIME_PRODUCTOne-off product purchase or cancel. Routes to the purchases ledger.purchase_completed or purchase_refunded
VOIDED_PURCHASEA previously-completed purchase was voided / refunded.refund_issued
TESTOne-shot test ping from Play Console "Send test publish."Acknowledged with no_op.

Verification — Pub/Sub OIDC plus Play Developer API re-fetch

Google Play notifications carry two layers of verification:

  1. Pub/Sub OIDC. Google Cloud's push-subscription delivery includes an OIDC token signed by Google's service account. Cloud Functions verifies the OIDC token before invoking our handler; an unauthenticated message never reaches the receiver code at all. This is enforced at the platform layer.
  2. Play Developer API re-fetch. The RTDN payload itself carries minimal data — a purchaseToken, a numeric notification type, a package name. To get the authoritative purchase shape (price, currency, account hint, full lineage), Crossdeck calls purchases.subscriptions.get / purchases.subscriptionsv2.get on the Play Developer API directly using your uploaded service account credentials. Even if a Pub/Sub message could somehow be forged, the actual purchase data is fetched directly from Google — there is no payload-based attack surface.

Configuration — what you do

  1. Upload your Play service account JSON in Dashboard → Payment rails → Google Play. The service account needs the "View financial data" and "Manage orders" permissions on your Play account, granted in Play Console → Users and permissions.
  2. Set the Pub/Sub topic name in Play Console → Monetization setup → Real-time developer notifications. Paste:
    projects/crossdeck-47d8f/topics/gplay-rtdn
    That's a single shared topic in the Crossdeck Google Cloud project — your messages are routed by their packageName field, not by topic isolation.
  3. Grant Pub/Sub Publisher permission to Google Play's developer-notifications service account on that topic. Google requires this and surfaces the exact instructions in Play Console; the service account email is [email protected].
  4. Click "Send test publish" in Play Console. Crossdeck receives the test notification, acknowledges it as a no-op, and surfaces it in your dashboard's audit log — confirming the wire is up before any real money moves.
Identity hint: pass obfuscatedAccountId at purchase time.

Android's equivalent of Apple's appAccountToken is BillingClient.setObfuscatedAccountId(...). Set this to your auth provider's user ID at purchase time; Crossdeck reads it off the Play Developer API re-fetch and links the resulting cdcust to your existing developer user. Without it, the Google purchase attaches to a rail-only customer that gets reconciled later.

Bank-grade guarantees — verified per event

Every accepted webhook crosses the same four-property bar before it touches your ledger. The table below maps each rail to the primitives that enforce each property.

PropertyAppleStripeGoogle Play
Signature JWS chain to Apple root cert (online revocation checks enabled). Stripe-Signature HMAC-SHA256, timing-safe compare, replay window respected. Pub/Sub OIDC token from Google's service account, verified at the platform layer.
Re-fetch (provider truth) Inner JWS payloads (signedTransactionInfo, signedRenewalInfo) re-verified against Apple's chain. Full App Store Server API reconciliation is the follow-up. stripe.events.retrieve(event.id, { stripeAccount }) — the inbound event ID is re-fetched from the connected account; the re-fetched payload is used for the ledger write. purchases.subscriptionsv2.get against the Play Developer API — RTDN payload is treated as a notification, not the source of truth.
Idempotency key notificationUUID per event; apple:{notificationUUID} as the identity journal idempotency key. event.id (evt_…) per event; stripe:{event.id} as the identity journal idempotency key. Composite of packageName + purchaseToken + notificationType + eventTimeMillis; google:{eventTimeMillis}:{purchaseToken} as the identity journal idempotency key.
Ledger entry Append-only hash-chained, atomic with subscription / purchase doc write. Append-only hash-chained, atomic with subscription / purchase doc write. Append-only hash-chained, atomic with subscription / purchase doc write.
State recompute cdcust entitlement state re-projected from ledger; dashboard listeners notified via Firestore snapshot. cdcust entitlement state re-projected from ledger; dashboard listeners notified via Firestore snapshot. cdcust entitlement state re-projected from ledger; dashboard listeners notified via Firestore snapshot.

Every receiver writes an audit row on every decision — applied, no_op, or rejected. Re-deliveries are recorded once. Signature failures are recorded with signatureVerified: false. Reconciliation failures are recorded with the failed-step name and message. The audit log is the canonical answer to "what did Crossdeck do with that event?"

The hash-chained catalog ledger

Every accepted webhook becomes one entry on a single forward-only ledger per project. The ledger is append-only, hash-chained between entries, and signed at append time. Three properties follow:

Subscriptions, one-off purchases, refunds, disputes, plan changes, catalog changes (product / price create / update / delete) — each becomes one entry. The atomic-write contract (journal + projection in one transaction) guarantees you never see a state that exists without its corresponding ledger entry.

The ledger is read implicitly: the dashboard's customer state, the Activity timeline, the audit log, and the entitlement projection all flow from it. You don't have to query the ledger directly to benefit from it; it's the substrate that makes the rest correct.

What you see in the dashboard after a webhook lands

The dashboard is the consumption surface for everything the receivers produce. After any rail webhook lands and the ledger entry commits, four things update inside the second-to-five-second window for each rail:

The dashboard surfaces consumption; the receivers do the work. Every state change a customer-facing surface shows is traceable to a verified, signed, deduped event with an entry on the ledger.

Failure modes and recovery

The receivers are designed to surface failure cleanly without ever silently dropping a real event. The failure modes break down into four categories, each with a defined response:

FailureWhat happensWhat you do
Signature verification fails Receiver returns 4xx, no project state is touched. An audit entry with signatureVerified: false and decision: "rejected" is written. The rail's own webhook-delivery view shows the 4xx. Check that your project's webhook URL is the one currently registered on the rail. For Apple: the bundleId on your project must match what's in your App Store Connect app. For Stripe: a forged event would be near-impossible given OAuth + HMAC; investigate if this fires.
Duplicate event (idempotent retry) Receiver short-circuits at the idempotency step, returns 200, writes no audit entry beyond a debug log line. The original audit entry is the canonical record. Nothing. This is the rail retrying after a slow ack or a delivery hiccup. The original event was processed.
Crossdeck-side apply error Receiver writes an audit entry with decision: "rejected" and the failure reason, then returns 500. The rail retries. The audit entry on the Errors page shows the reason. Most apply errors are transient (Firestore contention, brief Stripe API blip) and resolve on retry. Apple retries for hours; Stripe retries for ~3 days with exponential backoff; Google Pub/Sub retries until the message exits its retention window.
Receiver itself is unavailable Cloud Functions returns 5xx to the rail. The rail's retry policy fires. Crossdeck monitors this end-to-end. Apple: hours of retry tolerance. Stripe: 72h of exponential backoff. Google: Pub/Sub at-least-once with multi-hour retention. We never reject a real event because of a clock skew — every rail's replay window is wider than the worst-case Crossdeck processing latency.

No real event is ever lost. The retry windows of all three rails are designed for exactly this — a downstream system that briefly stalls, recovers, and processes the queued event on the next delivery. Crossdeck's idempotency guarantees the eventual processing is exactly-once even though the delivery is at-least-once.

Sandbox vs production

Every rail provides a separate sandbox / test environment with its own delivery path:

The dashboard's environment switcher (top-right, "Live · Production" vs "Sandbox") routes the entire UI between the two ledgers. Test purchases never contaminate live MRR; sandbox metrics never leak into production reports. Identity, entitlement, catalog, and activity surfaces all partition cleanly.

Latency expectations

Webhook → dashboard update latencies, measured end to end (rail emits event → dashboard reflects the new state via Firestore snapshot):

RailTypical end-to-end latencyWhy
StripeSub-second (~300–800 ms)Direct HTTPS to Crossdeck. Signature verify is fast; the events.retrieve reconciliation adds one Stripe API round-trip but stays inside the same region.
Apple1–5 secondsJWS chain verification (leaf + intermediate + root) plus inner JWS re-verification for signedTransactionInfo and signedRenewalInfo. Apple's edge → Crossdeck's edge latency adds a fraction of a second.
Google Play2–10 secondsPub/Sub propagation (Play Console → topic → push subscription → function) plus a Play Developer API purchases.subscriptionsv2.get round-trip per event. The largest contributor is Pub/Sub queue time during high-fanout moments.

These latencies are acceptable for any UX that isn't strictly real-time-critical — paywall unlocks, entitlement grants for desktop / web SDKs, dashboard customer-state updates, churn alerts. For SDK-side entitlement gating that must answer in microseconds, the SDK's local cache covers the synchronous read path; the cache is invalidated by the same webhook-driven server-side update described above.

What about the OAuth window?

There is one narrow window worth calling out explicitly: between the moment you click "Connect Stripe" in the dashboard and the moment Crossdeck finishes processing the OAuth callback. The wall-clock duration is typically two to three seconds. During that interval, your connected account ID is not yet in connectedAccountIndex, so a Stripe webhook that arrives mid-flight would route to "no project found" and return 200 without writing.

This is handled by Crossdeck's Stripe discovery backfill: the moment the OAuth callback writes the connected account into Firestore, a separate Firestore-trigger function (onStripeRailConnect) picks it up and runs a read-only discovery scan that paginates through every subscription, customer, and product on the connected account. Anything that existed before the OAuth — including events that fired during the OAuth window itself — is enumerated and projected into your ledger on the first scan. No events are lost.

For Apple and Google, there is no analogous window. The URL / topic name is the same forever; you paste it once when configuring the rail, and the first event after that lands at your endpoint. Reconciliation against the App Store Server API and Play Developer API provides equivalent historical-state backfill for those rails when their config first lands.


Receivers live in backend/src/webhooks/apple.ts, stripe-platform.ts, google.ts. Verification primitives in backend/src/lib/apple-verifier.ts. Shared spine in backend/src/catalog/spine.ts. Identity journaling in backend/src/lib/identity-journal.ts.