Connect Stripe — one click, zero manual webhook setup
Crossdeck connects to your Stripe account through Stripe Connect (Standard) OAuth. You click one button on the Rails page, approve Crossdeck in Stripe's own UI, and you're done. There is no Account ID to copy, no webhook URL to paste, no event types to select, and no signing secret to verify on your side. Webhook delivery, signature verification, idempotency, and a read-only backfill of every historical customer and subscription all happen automatically the moment you authorise.
Connecting Stripe (the one-click OAuth on this page) takes zero code: it imports your historical customers and streams every new payment in. Attributing each payment to the right user in your app is a different step that does take code — two small SDK calls, covered in the one step you do in code. Connection alone cannot know which of your users a Stripe checkout belongs to; that is exactly what the code supplies. Skip it and payments still arrive — just unattributed, waiting in a review queue.
TL;DR
- One click via OAuth. The Connect with Stripe button on the Rails page kicks off Stripe Connect Standard's official OAuth handshake. You approve in Stripe's UI. Crossdeck stores a connected-account access token; you stay signed in to your Stripe dashboard.
- One code step — separate from connecting, and required. Connecting is codeless, but tying each payment to a user in your app is a distinct change: two SDK calls (
identify()+ a checkout reference). Without it, payments land unattributed in a review queue instead of on the right person. - No Account ID to copy. Stripe sends us your
acct_…identifier in the OAuth response. You never type it. - No webhook URL to paste. Crossdeck registers one platform-level Connect webhook against the Crossdeck Stripe platform application; every account that OAuths into us inherits delivery automatically.
- No events to select. The
HANDLED_EVENT_TYPESallowlist is set in code on our side. Below is the full list, with what we do for each. - Crossdeck never sees your raw secret key. The OAuth flow exchanges a one-time auth code for a connected-account access token, server-side, scoped to your account.
- Silent migration fires the instant you connect. A Firestore trigger discovers your historical customers, subscriptions, products, prices, and recent invoices read-only. Refresh the dashboard in 2–5 minutes; your data is there.
- Revoke any time from Stripe Dashboard → Settings → Apps. We detect the revocation on the next webhook and mark the project as disconnected.
What happens when you click Connect with Stripe
Here is the entire flow, end-to-end, as it executes in code. There is nothing for you to configure between any two steps.
1. The dashboard kicks off the handshake. On the Payment rails page, the Connect with Stripe button calls the stripeOAuthStart Firebase Function with your current projectId and the current origin. The backend writes a one-time CSRF state nonce to Firestore at oauthStates/{nonce} with a 10-minute TTL, then redirects you to Stripe.
2. Crossdeck redirects you to Stripe's authorize page.
https://connect.stripe.com/oauth/authorize
?response_type=code
&client_id=ca_… (Crossdeck's platform Connect client_id)
&scope=read_write
&state=<one-time CSRF nonce>
&redirect_uri=https://us-east4-crossdeck-47d8f.cloudfunctions.net/stripeOAuthCallback
3. You approve in Stripe's UI. Stripe shows their own consent screen with the Crossdeck app name, the scope, and the account you're authorising. You stay on connect.stripe.com throughout — you never type credentials into Crossdeck.
4. Stripe redirects back to stripeOAuthCallback with an auth code. Crossdeck verifies the state nonce is the one we wrote, is fresh, and hasn't been used.
5. Crossdeck exchanges the code for a connected-account access token. This happens server-side, using Crossdeck's platform secret key (read from Google Cloud Secret Manager — not from Firestore, not from environment variables). Your raw secret key never leaves Stripe; we receive only the OAuth token that's tied to your account ID and revocable from your dashboard.
6. We persist the connection. Two writes happen in parallel:
projects/{projectId}.paymentRails.stripe = {
rail: "stripe",
connectedAccountId: "acct_…",
scope: "read_write",
connectedAt: <timestamp>,
verificationStatus: "verified",
env: "live" | "test",
}
connectedAccountIndex/{connectedAccountId} = {
projectId,
rail: "stripe",
env,
connectedAt: <timestamp>,
}
The first record lives on your project doc and powers the Rails page's status pill. The second is the routing index — when Stripe POSTs a webhook for your connected account, the platform webhook handler looks up connectedAccountIndex/{acct_…} to find which Crossdeck project to ingest into.
7. The one-time state token is deleted. It's been used; nothing further references it.
8. The dashboard takes you home. The callback redirects you to /onboarding/?stripe=connected&account=<your-connected-account-id> on the same origin you started from. The dashboard reads the query string, lights up the Connected card, and starts polling the migration banner.
9. A Firestore trigger fires immediately. The instant the paymentRails.stripe field appears on your project doc, onStripeRailConnect (in migration/stripe-discover.ts) wakes up in its own Cloud Function instance with a full 540-second timeout budget. It runs the read-only discovery scan over your Stripe account — customers, subscriptions, products, prices, recent invoices — and writes the counts into projects/{projectId}/migration/stripe. When discovery completes, a second trigger (onStripeDiscoveryCompleted in migration/stripe-backfill.ts) projects every record into Crossdeck's data model. You don't have to do anything; just refresh the dashboard in 2–5 minutes.
Cloud Functions can kill a request container the moment it returns its HTTP response. If we'd fired the historical scan inline from the callback, the runtime could cut off a 30-second paginated walk mid-flight. Decoupling via "Firestore-as-event-bus" gives the migration its own container with its own 540-second budget. Documented in auth/stripe-oauth.ts.
Tell Crossdeck who's buying — the one step you do in code
Connecting Stripe streams every payment into Crossdeck. The one thing connection can't do on its own is tell Crossdeck which of your users made a given purchase — because a checkout happens on Stripe, and Stripe doesn't know your app's users. You have to pass that link through. It's two calls, and it's the difference between revenue that lands on the right person and revenue that lands in a review queue.
It's the only code Crossdeck asks you to write for the Stripe rail. Skip it and purchases still get captured — they just arrive unattributed and wait for you to link them by hand (see How identity resolution works, below).
The principle is simple: at every point where identity exists in your app, hand Crossdeck the stable identifier it can recognise. There are exactly two such points.
1. When a user signs in — identify them. Pass the id your own app already uses to mean one specific person — the same id you'd use to look that person up in your own system. Crossdeck doesn't prescribe what that id is; it only needs it to be the same value every time, and only ever that one person. Those two properties are load-bearing — How identity resolution works, below, explains why, and what breaks if the value is wrong. This call is what ties a person's behaviour, errors, and payments to one record.
Crossdeck.identify(user.id, { email: user.email });
2. When they check out — thread the reference through Stripe. A logged-in user is logged in to your app, not to Stripe. So when you create the Checkout Session, attach the reference the SDK gives you. Crossdeck reads it back off the webhook and attaches the subscription to the exact person.
// Client-side: the SDK returns the stable id for the current person.
const crossdeckRef = Crossdeck.getCheckoutReference();
// Server-side, when you create the Checkout Session, stamp it on the
// subscription's metadata under the key `crossdeck_ref`:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [ /* … */ ],
subscription_data: { metadata: { crossdeck_ref: crossdeckRef } },
});
crossdeck_ref is a metadata key, deliberately. Metadata holds many keys, so it never collides with client_reference_id if you already use that field for your own reconciliation — you don't have to give anything up. (If client_reference_id is free, you may set it to the same value as a fallback; Crossdeck reads the metadata key first.)
How identity resolution works — why the value you pass is everything
A checkout runs on Stripe, and Stripe authenticates a card, not a person in your app. The webhook that tells Crossdeck "someone just paid" carries Stripe's own ids and nothing about your user. The reference you thread through is the only thread connecting this payment to this person you've been tracking. Remove it and the money still arrives — with no name on it.
getCheckoutReference() doesn't invent an id — it hands back the one you set with identify(). So the correctness of this whole system is decided by a single thing: the value you pass to identify(). Get it right and everything downstream — the journey, the errors, the entitlement, this purchase — settles onto one coherent record. Get it wrong and it generally can't be repaired after the fact, because Crossdeck records the identity you assert; it does not second-guess you.
That's why the value has to be three things — and each one heads off a specific collapse:
- Stable — the same person, every session, every device, indefinitely. The value is frozen into the purchase and entitlement records the moment they're written. If it can change — a per-session token, an anonymous id you regenerate on logout — the next event can't find the earlier record. The person shatters into fragments, none of them whole, and the purchase strands on whichever fragment happened to exist at checkout.
- Unique to exactly one human — never shared, never reused. If two people can ever resolve to the same value — a shared account id, an org or tenant id, a placeholder you meant to replace — their identities fuse into one. One person then inherits the other's paid entitlement, and nothing can pull them back apart.
- The id you already use for that person elsewhere. Pass the same value your own system uses, so the purchase lands on the record you've been building all along instead of minting a new one beside it.
This is why the docs say the value, not a value. Hand identify() the wrong one — a transient token, a shared id, or literally a different user's — and the purchase attaches, confidently, to the wrong record or to none. That is worse than an orphan, because it looks correct. The fix is never downstream; it's passing the right identifier at the source.
When a real purchase arrives, one of two things happens — and Crossdeck genuinely cannot tell which applies to your integration until it does:
- Reference present and valid — the subscription attaches to the exact person, silently and immediately. Nothing for you to do. This is the path the two calls above buy you.
- Reference absent — Crossdeck still captures the purchase against everything Stripe hands us (customer, amount, plan, invoice), entitles it so the buyer keeps what they paid for, and flags it on your dashboard for you to link — the same one-click merge you already use for migration conflicts. You'll also get a calm heads-up email. The revenue is never lost; it waits for a human.
An unattributed purchase is captured with full Stripe detail and surfaced for you to resolve — never dropped, never auto-matched to the wrong person. The email on a Stripe customer is evidence for that human review, never an automatic join key. And that restraint is exactly why the value you pass must be right: Crossdeck faithfully records the identity you assert and won't paper over a wrong one — it only ever flags the absence of a reference, never a bad one masquerading as good.
Why one click is enough — Stripe Connect platform webhooks fan in automatically
The reason you don't paste a webhook URL is architectural, not aesthetic. Stripe Connect supports two kinds of webhook endpoint:
- Per-account endpoints. The pattern the old Crossdeck setup flow used — each developer adds an endpoint inside their own Stripe Dashboard, copies its signing secret, pastes it into a third-party form. Brittle, manual, and breaks the moment the URL changes.
- Platform-level Connect endpoints. A single endpoint registered against the Stripe platform application (the Crossdeck Connect app). Every account that OAuths into the platform inherits delivery automatically — Stripe fans events out from the connected account, stamps
event.accountwith theacct_…ID, and delivers them to the platform endpoint.
Crossdeck uses the second. One endpoint, one signing secret (held in Google Cloud Secret Manager), thousands of accounts.
The routing happens in code. Every inbound event includes event.account — the connected account it originated from. The stripeConnectWebhook handler:
- Verifies the HMAC signature against the platform signing secret.
- Reads
event.account. - Looks up
connectedAccountIndex/{event.account}in Firestore. - Ingests the event into the resolved
projectId.
That's why connecting Stripe is a one-line write to Firestore on our side and zero lines of config on yours. The webhook exists at the platform level. The routing exists in our index. End-to-end.
Events Crossdeck subscribes to
Crossdeck's webhook handler maintains an explicit allowlist of event types it processes. Anything outside the list is acknowledged with 200 OK so Stripe stops retrying, then ignored. The full set, from HANDLED_EVENT_TYPES in backend/src/webhooks/stripe-platform.ts:
| Event | Crossdeck action |
|---|---|
checkout.session.completed |
If mode = "subscription", retrieves the subscription and feeds it through the sub path. If mode = "payment", branches into one-off purchase handling. |
customer.subscription.created |
Reads metadata.crossdeck_ref (the checkout reference your app stamped, off the Stripe API object) and attaches the subscription to that existing Crossdeck customer; if it is absent or doesn't resolve, mints a rail-only customer instead. Mirrors subscription state into projects/{id}/subscriptions/{subId}, journals a rail.subscription.upserted entry, and projects entitlements. |
customer.subscription.updated |
Mirrors the new state on the existing sub doc, recomputes entitlements. Emits the derived trial_converted / billing_retry_entered / billing_retry_recovered / subscription_churned signal where state transitions imply one. |
customer.subscription.deleted |
Captures canceled_at, transitions the sub to EXPIRED, recomputes entitlements (revokes those tied to the sub). |
customer.subscription.trial_will_end |
Surfaces the derived signal trial_ending_soon on the audit log. Use it to drive trial-conversion-window UI in your app. |
invoice.payment_succeeded |
Retrieves the parent subscription, re-mirrors its state (advances current_period_end, exits BILLING_RETRY if applicable), recomputes entitlements. |
invoice.payment_failed |
Re-mirrors sub state — typically the move to BILLING_RETRY. Stripe's Smart Retries take over from there; we re-apply on every retry outcome. |
payment_intent.succeeded |
Branches inside applyEvent on whether the price is recurring. Recurring → sub path. Non-recurring → one-off purchase: writes projects/{id}/purchases/{chargeId} via the spine's journaled rail.purchase.upserted kind, recomputes entitlements. |
payment_intent.payment_failed |
Audit-only for the one-off path; on the sub path, advances the parent subscription's state on the next invoice.* event. |
charge.refunded |
If the underlying charge has an invoice, walks invoice → subscription and re-mirrors state with refunded: true (sub flips to REFUNDED). If no invoice (raw one-off charge), updates the purchase record with refunded: true and amountRefunded. Entitlements re-project — granted ones get revoked. |
charge.dispute.created |
Stamps disputed: true on the matched purchase record. Surfaces as a risk signal in the dashboard. |
product.created |
Normalized through the Stripe adapter and projected onto the catalog spine via projectRailEvent. Hash-chained journal entry; the product appears in projects/{id}/catalog/skus. |
product.updated |
Spine merges new fields onto the catalog SKU. Journal entry records the before/after diff. |
product.deleted |
Catalog SKU is tombstoned (soft delete). Entitlements that referenced the product surface as orphans in the dashboard's catalog drift report. |
price.created |
Same catalog-spine path. Price metadata (unit_amount, currency, recurring interval) lands on the SKU. |
price.updated |
Spine merges; journal records diff. |
price.deleted |
Spine tombstones the price; same orphan handling as product.deleted. |
Every event flows through the same four-step pipeline before the right-hand-column action runs: signature verify → idempotency claim → reconcile with Stripe → apply. Each step writes an audit row with decision: "applied" | "no_op" | "rejected" — visible in the dashboard's audit log.
After signature verification, Crossdeck calls back to Stripe with stripe.events.retrieve(event.id, { stripeAccount }) to fetch the canonical event from Stripe's side — not from the inbound payload. A spoofed but signature-valid payload (vanishingly unlikely, but possible) is caught here. The audit row records reconciledWithProvider: true.
What about other events Stripe emits?
Stripe emits around 250 event types across Billing, Issuing, Treasury, Identity, Climate, and the rest of the platform surface. Crossdeck handles the 17 above. Everything else is acknowledged with 200 OK (so Stripe doesn't retry) and dropped — we don't process events we don't have a defined behaviour for.
The allowlist is explicit on purpose. New event types from Stripe shouldn't silently change Crossdeck's behaviour. If you need a different event surfaced — for example charge.dispute.funds_withdrawn for finance reporting, or customer.tax_id.created for compliance — request it. Adding to the allowlist is one line per event in HANDLED_EVENT_TYPES, plus the handler branch.
Webhook signing and idempotency — handled for you
Stripe HMAC-signs every webhook with a per-endpoint signing secret. Crossdeck verifies every inbound POST against the platform signing secret (read from Google Cloud Secret Manager) before doing anything with the payload. A failed verification returns 401 and Stripe retries with exponential backoff; an unsigned or malformed POST is rejected at 400.
Idempotency is enforced via markProcessedOrSkip(projectId, event.id, …). Every Stripe event ID is claimed in projects/{id}/processedEvents/{event.id} the first time it's processed; replays (which Stripe will send up to 3 days after the first delivery if you respond non-200) become no-ops. The journal-first projection also uses stripe:{event.id} as its idempotency key so re-deliveries collapse to a single ledger entry.
You write none of this. From your side, Stripe sees one webhook URL, Crossdeck verifies its signature, your dashboard reflects the event. For the full pipeline shape see Rail webhooks.
The OAuth scope — read_write, but Crossdeck never writes
The OAuth handshake requests scope=read_write. This is the default Stripe Connect Standard grants on the platform; Stripe requires platforms to email Connect support before they enable read_only on a connected-account flow. To unblock launch we ship with read_write today.
The architectural commitment that Crossdeck never writes to your Stripe account is enforced in code, not in the OAuth scope. Quoting the comment in backend/src/auth/stripe-oauth.ts:
- No backend module imports
Stripe.subscriptions.createagainst a connected account. - No code path calls
Stripe.refunds,Stripe.charges, or any other write op with astripeAccountoption set. - All connected-account calls are reads (
events.retrieve,subscriptions.retrieve,customers.retrieve) for verification and reconciliation only.
You can verify this yourself: open your Stripe Dashboard → Developers → Logs and filter by the Crossdeck application's API calls. Every entry will be a GET. No POST, no PUT, no DELETE — ever.
The only "write" Crossdeck issues against a connected account is the oauth.deauthorize call when you click Disconnect, which is the explicit, user-initiated revoke flow.
Silent migration — what's running while you wait
The moment your connectedAccountId lands on the project doc, two Firestore triggers run sequentially in their own function instances. From backend/src/migration/stripe-discover.ts and stripe-backfill.ts:
Phase 1 — Discovery (onStripeRailConnect)
Read-only scan over your Stripe account. Paginates through:
subscriptions.list({ status: "all", expand: ["data.items.data.price.product"] })— counts, MRR by currency, plan distribution.customers.list— capped at 5,000 for the identity-key heuristic (statistically robust above that), 10,000 hard cap for the scan itself.products.list({ active: true })+prices.list({ active: true })— builds your discovered-products catalog with subscriber counts per price.invoices.list({ status: "paid", created: { gte: now - 30d } })— last-30d revenue aggregate.
Results land in projects/{projectId}/migration/stripe. The dashboard's migration banner polls this doc and shows live counts.
Phase 2 — Backfill (onStripeDiscoveryCompleted)
The moment discovery transitions to status: "completed", a second trigger projects every discovered record into Crossdeck's data model:
- One Crossdeck customer (
cdcust_…) per Stripe customer, written toprojects/{id}/customers/{cdcust}. The backfill mints these as rail-only records — it never stampsdeveloperUserIdor infers identity from Stripe metadata. You link historical customers to your app users with the migration POST (or they self-resolve the next time that person callsidentify()); any still-unlinked rail customers surface in the dashboard for one-click resolution. - One subscription record per Stripe subscription at
projects/{id}/subscriptions/{subId}, with full state machine (TRIAL,ACTIVE,BILLING_RETRY,EXPIRED,REFUNDED), period bounds, price, interval, currency. - One purchase record per paid one-off invoice at
projects/{id}/purchases/{chargeId}, covering the last 13 months (full trailing-12mo MAR + buffer). - Recomputed entitlements per customer via
recomputeCustomerEntitlements, so the moment backfill finishes,Crossdeck.isEntitled("pro")on the SDK side answers correctly for every historical paying customer.
Bounds, budgets, and idempotency
- Read-only against Stripe. No webhooks created, no metadata writes, no
POSTs. Read-only end-to-end. - 10,000 records per list endpoint hard cap. ~95% of accounts are comfortably under; the few above get an honest "first 10k scanned — re-run for full scan" disclaimer in the dashboard.
- 540-second function budget. Comfortably covers a 10k-record scan (~30 seconds on a healthy connection). Larger accounts paginate across multiple executions automatically.
- Idempotent. Every write keys on the Stripe stable identifier (
customer.id,subscription.id,charge.id). Re-running the backfill converges on the same state — no duplicates, no orphans. The Run backfill button on the Rails page lets you re-trigger it on demand.
Typical run for a small account: 2–5 minutes from clicking Connect to your customers appearing in the dashboard. Accounts with 100k+ historical customers take longer; the migration banner shows progress throughout.
Stripe Tax
Stripe Tax is already enabled by default for every Crossdeck-connected account that buys through Crossdeck's own Checkout / Payment Links flows — we set automatic_tax: { enabled: true } on the platform's Checkout configuration. For Stripe transactions originating in your own app (the common case), tax behaviour is governed by your own Stripe Tax settings, not by anything Crossdeck does. There is nothing for you to configure here.
Sandbox vs production
Stripe Connect treats test-mode and live-mode as completely separate platform applications. Crossdeck respects the split:
- Live OAuth connects your live Stripe data into your Crossdeck production project. The
envfield onpaymentRails.stripeand on theconnectedAccountIndexentry is"live". - Test OAuth (used during pre-launch testing) connects your Stripe test data into a Crossdeck sandbox project. The
envfield is"test".
The environment is encoded on the access token at handshake time and persisted on the project doc. The platform webhook receiver reads envFromStripeEvent(event) on every inbound event so live and test data never cross-pollute — a test-mode customer.subscription.created can't accidentally light up a live entitlement.
The default deploy ENV is live because Crossdeck's platform has passed Stripe Connect review. Internal staging deploys override via STRIPE_CONNECT_ENV=test; you don't pick this yourself.
Revoking access
Two paths get you to a disconnected state:
From the Crossdeck dashboard
Click Disconnect on the Stripe rail card on the Rails page. The dashboard confirms the action via a typed-confirm gate (you type the project name back) because disconnecting Stripe means new subscription events stop landing — renewals, churns, and trial conversions will drift in your app immediately. Once confirmed, Crossdeck calls stripe.oauth.deauthorize on the platform side and clears the connection from Firestore.
From Stripe
Open Stripe Dashboard → Settings → Apps, find Crossdeck in the connected apps list, click Revoke. Stripe POSTs an account.application.deauthorized event to our platform webhook on its way out; we detect the revocation and mark the project as disconnected. Either way, you're not relying on us to honour the revocation — Stripe's side is authoritative.
Historical data stays intact: your customers, subscriptions, purchases, and entitlements remain in Crossdeck. New webhook events stop ingesting. To resume, click Connect with Stripe again — idempotent backfill catches you back up.
Troubleshooting
"OAuth failed — invalid state" / "Unknown state — link may have expired"
The state token has a 10-minute TTL between clicking Connect and approving in Stripe. If you walked away, refreshed Stripe's authorize page, or hit it twice in different tabs, the token is gone. Start over from the Rails page.
"Connection completed but no data appearing"
The silent migration is still running. Check the migration banner at the top of any dashboard page — it shows current phase (Discovery, Backfill) and live counts. Typical wait is 2–5 minutes; large historical accounts take longer. If the banner stays on the same phase for > 15 minutes, click Run backfill on the Rails card to re-trigger from scratch (idempotent).
"OAuth callback redirected me to /sign-in/"
Firebase Auth's session persistence is per-origin — localStorage on cross-deck.com is invisible to a page loaded from crossdeck--crossdeck-47d8f.us-east4.hosted.app. If you started Connect from one origin and the post-OAuth redirect lands on another, your auth state isn't there and the dashboard bounces to sign-in.
Solution: always start Connect from cross-deck.com (the canonical origin). Crossdeck honours an ALLOWED_DASHBOARD_ORIGINS list — if your starting origin isn't on it, the callback falls back to cross-deck.com rather than redirecting to an untrusted host. Unknown origins always end up on the canonical, signed-in side.
"Account is already connected to a different project"
One Stripe account can OAuth into only one Crossdeck project at a time — the connectedAccountIndex entry is keyed by connectedAccountId. Disconnect from the other project first, then re-connect from this one. The dashboard surfaces this as a friendly error rather than a silent overwrite.
"Webhook events not appearing"
Usually transient. The Rails page shows a per-rail health pill driven by the last 100 audit-log entries — if it's red, signature verification or apply is failing and the audit log will say why. If the pill is healthy but no events appear, check that connectedAccountIndex/{your-connected-account-id} is present in Firestore (the dashboard shows this implicitly as the green Verified status on the rail card). If it's absent — rare, would imply the OAuth callback half-completed — disconnect and reconnect to re-write the index.
"Token exchange failed" at the OAuth callback
Stripe's token endpoint rejected the auth code. The detail string in the URL (?stripe=token_failed&detail=…) carries Stripe's reason; common causes are an auth code already redeemed (you double-submitted), or a clock-skew issue between Stripe and our platform secret. Retry from the Rails page; if it persists, send us the detail string.
"I disconnected and reconnected, now I have duplicate customers"
Rare. Happens when identity collides across the disconnect period — e.g. you re-OAuthed and the same person checked out again before your app re-stamped crossdeck_ref, so they came back as a fresh rail-only customer. The migration verify step catches this and queues the duplicates for human merge resolution. See Identity verification for the full identity-graph behaviour.
What's next
- Identity verification — how rail webhooks resolve a Stripe customer to a developer-side user (
metadata.crossdeck_ref, fallback heuristics, the identity-graph merge). - Entitlements & gating — what to do with the entitlements that just got projected from your Stripe subscriptions.
- Rail webhooks — the full architecture under the hood: signature verify → idempotency → reconcile → apply → audit.
- Track custom events — events your code fires alongside the Stripe events Crossdeck ingests, so checkout funnels and revenue-driven cohorts share one timeline.
- Web SDK reference — the
@cross-deck/webSDK that reads the entitlements this connection produces. - Connect Apple App Store — generate the In-App Purchase Key and App Store Connect API Key, upload both, register your Server Notifications V2 URL.
- Google Play setup — service account JSON + Pub/Sub topic. Doc landing soon.
Last updated when Stripe Connect Standard OAuth shipped as the only supported Stripe wiring path (May 15, 2026). Code references: backend/src/auth/stripe-oauth.ts, backend/src/webhooks/stripe-platform.ts, backend/src/migration/stripe-discover.ts, backend/src/migration/stripe-backfill.ts, dashboard/rails/rails.js.