Rail webhooks — how Crossdeck receives signed events from your payment rails
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
- Three rails, one shared pipeline. Apple App Store Server Notifications V2, Stripe Connect platform webhooks, and Google Play Real-time Developer Notifications all flow through the same verify → dedupe → journal → project sequence.
- Verified at the edge before any write. Apple JWS chain, Stripe HMAC-SHA256, Google Pub/Sub OIDC. A malformed signature is rejected with a 4xx before we read anything from your project.
- Bank-grade idempotency on every event ID. Apple's
notificationUUID, Stripe'sevt_ID, Google's per-message composite key — re-deliveries are no-ops, recorded once. - Forward-only hash-chained ledger. Every accepted webhook becomes one append-only entry. The ledger is never edited; corrective events get their own entries. The audit trail is permanent and replay-detectable.
- Stripe is zero-config after OAuth. You click "Connect Stripe" in the dashboard and Crossdeck's platform-level webhook starts receiving events from your connected account automatically. Nothing to paste anywhere.
- Apple and Google need a small one-time setup. Paste a URL into App Store Connect plus upload a
.p8key; or paste a Pub/Sub topic name into Play Console plus upload a service account JSON. About five minutes each. - You never write a webhook handler. No HMAC verification code, no idempotency table, no retry logic. The receivers run inside Crossdeck and surface state through the dashboard and the SDKs (the SDK's reactive
onEntitlementsChangelistener + server-sidegetEntitlements()). Outbound push-to-your-backend webhooks are roadmap — see Inbound vs outbound below.
Inbound vs outbound
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.
| Step | What it does | Why 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:
- Journal-first writes only. Catalog / subscription / purchase mutations route through
writeProjection, which requires journal evidence as a parameter. There is no overload that accepts a "skip journal" flag. - Atomic journal + projection. The journal append and the projection write are committed inside one Firestore transaction. A crashed write never leaves a ledger entry without state, or state without a ledger entry.
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 type | What it means | Crossdeck-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_RENEW | Auto-renewal succeeded. If the previous state was trial, this is the conversion. | renewed or trial_converted |
DID_FAIL_TO_RENEW | Billing failed; the sub enters retry or grace period depending on subtype. | billing_retry_entered or grace_period_entered |
DID_RECOVER | A failed billing was recovered. | billing_recovered |
GRACE_PERIOD_EXPIRED | The grace window ended without recovery. | grace_period_expired |
EXPIRED | The subscription terminated. | expired |
REFUND / REFUND_REVERSED | Apple processed (or reversed) a refund. | refund_issued / refund_reversed |
REVOKE | Family Sharing access was revoked. | revoked |
DID_CHANGE_RENEWAL_STATUS / DID_CHANGE_RENEWAL_PREF | Auto-renew toggled or plan changed. | renewal_status_changed |
PRICE_INCREASE / PRICE_CHANGE | Apple notified the user of a price change. | price_change_notified |
OFFER_REDEEMED | A promotional offer was applied. | (carried in renewal info) |
CONSUMPTION_REQUEST | Apple is asking for consumption metadata for a refund decision. | (no state change — informational) |
TEST | Apple'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:
-
Upload your
.p8key + 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. -
Set the App Store Server Notifications V2 URL in App Store Connect → your app → App Information → App Store Server Notifications. Paste:
Your dashboard's Developers → API page renders the exact URL with your projectId interpolated, with a one-click copy button.https://api.cross-deck.com/v1/webhooks/apple/{your-projectId} -
Select both Production and Sandbox notification environments so test purchases ingest too. Apple sends sandbox notifications to the same URL with an
environmentfield 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.
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.
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 type | What it triggers in Crossdeck |
|---|---|
checkout.session.completed | Subscription path (mode = subscription) or one-off purchase path (mode = payment), routed by the session's mode. |
customer.subscription.created | New subscription record; state derived from Stripe status + trial_end + cancel_at_period_end. |
customer.subscription.updated | State recompute on the subscription record. Plan changes, status flips, trial expiries, pause/resume. |
customer.subscription.deleted | Subscription terminated; state moves to EXPIRED (or REFUNDED if the deletion was refund-driven). |
customer.subscription.trial_will_end | Signal trial_ending_soon — pre-emptive trial-conversion churn warning. |
invoice.payment_succeeded | Period rolled forward, renewal succeeded, billing recovered. |
invoice.payment_failed | Subscription enters billing retry. State recompute. |
payment_intent.succeeded | One-off charge (no invoice attached). Branches to the purchases ledger. |
payment_intent.payment_failed | One-off charge failed — auditable but no entitlement effect. |
charge.refunded | Refund issued. State moves to REFUNDED on the originating subscription or purchase. |
charge.dispute.created | Dispute filed — separate disputed-state branch on the purchase ledger. |
product.created / product.updated / product.deleted | Catalog event — normalised through the Stripe adapter and projected via projectRailEvent to your catalog skus collection. |
price.created / price.updated / price.deleted | Catalog 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:
- Dashboard → Payment rails → Stripe → Connect.
- Complete Stripe's OAuth screen.
- 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.
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 type | What it means | Crossdeck-derived signal |
|---|---|---|
SUBSCRIPTION_PURCHASED | First purchase of a subscription product. | trial_started or resubscribed |
SUBSCRIPTION_RENEWED | Auto-renewal succeeded. | renewed or trial_converted |
SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED | Billing recovered after a failure. | billing_recovered |
SUBSCRIPTION_ON_HOLD | Billing failed — sub is in account hold. | billing_retry_entered |
SUBSCRIPTION_IN_GRACE_PERIOD | Billing failed but grace period is active. | grace_period_entered |
SUBSCRIPTION_CANCELED | Auto-renew turned off; sub will expire at period end. | cancellation_scheduled |
SUBSCRIPTION_EXPIRED | The subscription terminated. | expired |
SUBSCRIPTION_REVOKED | Google revoked access (refund-driven). | refund_issued |
SUBSCRIPTION_PAUSED | The user paused the subscription. | paused |
SUBSCRIPTION_PRICE_CHANGE_CONFIRMED | The user accepted a price change. | price_change_confirmed |
SUBSCRIPTION_DEFERRED | The renewal date moved. | renewal_deferred |
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED | Pause schedule updated. | (no state change) |
SUBSCRIPTION_PENDING_PURCHASE_CANCELED | A pending purchase was cancelled before completion. | (no state change) |
ONE_TIME_PRODUCT | One-off product purchase or cancel. Routes to the purchases ledger. | purchase_completed or purchase_refunded |
VOIDED_PURCHASE | A previously-completed purchase was voided / refunded. | refund_issued |
TEST | One-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:
- 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.
- 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 callspurchases.subscriptions.get/purchases.subscriptionsv2.geton 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
- 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.
-
Set the Pub/Sub topic name in Play Console → Monetization setup → Real-time developer notifications. Paste:
That's a single shared topic in the Crossdeck Google Cloud project — your messages are routed by theirprojects/crossdeck-47d8f/topics/gplay-rtdnpackageNamefield, not by topic isolation. -
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]. - 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.
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.
| Property | Apple | Stripe | Google 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:
- Replay-detectable. Any attempt to alter a prior entry breaks the chain at every entry after it. Verification walks the chain and confirms each link.
- Permanent audit trail. No ledger entry is ever edited. Corrective events — a refund, a plan downgrade, a manual operator action — get their own entries that reference the prior state. The "before" lives in the chain.
- Deterministic state. Customer entitlement state at any wall-clock time can be reconstructed by replaying the ledger forward to that time. The live state cached in the customer document is just the current playhead; the chain is the source of truth.
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:
- Customer record. The status pill flips (TRIAL → ACTIVE on conversion, ACTIVE → BILLING_RETRY on a failed renewal, ACTIVE → REFUNDED on a refund). MRR recomputes if the change affected billing. The dashboard's Customer Overview reflects the new state on the next Firestore snapshot.
- Activity timeline. A new row appears with rail context — which rail emitted the event, the raw event type, the Crossdeck-derived signal (
trial_converted,billing_recovered, etc.), and a link back to the original event ID on the rail's own dashboard. - Errors page. If verification failed or reconciliation errored, the failed event appears with the failure reason. The signature-failed path never leaves project state in an inconsistent shape — failures are observable on the Errors page; state remains last-good.
- Audit log. The identity journal entry (e.g.
rail_attachedwhen a Stripemetadata.crossdeck_reflinked a Stripe sub to an existing customer) shows up with the evidence kind (stripe_webhook_signed,apple_jws_verified,google_pubsub_verified) and the JTI of the originating event.
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:
| Failure | What happens | What 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:
- Apple. Sandbox notifications are sent to the same Production V2 URL with an
environment: "Sandbox"field on the inner payload. Crossdeck's receiver decodes the env from the signed payload and routes to your sandbox ledger. - Stripe. Connected accounts can be in test mode or live mode; the platform-level webhook receives both, and the receiver routes via
event.livemodeon each event. - Google Play. Play license-tested account purchases flow through the same RTDN pipe as real purchases. Disambiguation happens at the Play Developer API re-fetch step; for v1, RTDN payloads default to production env and license-test purchases are flagged during reconciliation.
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):
| Rail | Typical end-to-end latency | Why |
|---|---|---|
| Stripe | Sub-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. |
| Apple | 1–5 seconds | JWS 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 Play | 2–10 seconds | Pub/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.
Related
- Identity verification — how rail events drive identity decisions, including
rail_customer_createdandrail_attachedjournal entries and the evidence kinds (stripe_webhook_signed,apple_jws_verified,google_pubsub_verified) that justify them. - Entitlements — how the ledger state composes into the entitlement projection that SDKs read.
- Connect Stripe — the OAuth flow whose callback registers your account for the platform webhook described here.
- Receiving Crossdeck webhooks — the outbound side (Crossdeck → your backend); currently roadmap.
- Source maps — unrelated to webhooks but in the same dev-infrastructure family.
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.