Receiving Crossdeck webhooks — the live events, and how to verify them
When something a customer relies on changes — an access rule is added, a rule is lifted — your backend should react in milliseconds, not on the next cron. Outbound webhooks are how Crossdeck pushes those changes to your servers. This page is the contract: how to register an endpoint, how each event is signed, how to verify it in one line with the Node SDK, and the one rule that keeps it bank-grade — a webhook is a nudge, so you verify it and then read the authoritative state it points at.
Crossdeck delivers signed, retried, at-least-once webhooks to endpoints you register. The first event types on the wire are trust.rule.added and trust.rule.removed; new types ride the same spine, so the verification and reconcile pattern here covers all of them.
Not to be confused with inbound rail webhooks — those are how Apple, Google Play, and Stripe call into Crossdeck. The direction documented here is the opposite: Crossdeck calling out to your backend. For inbound rail webhooks, see Rail webhooks.
TL;DR
- Register an endpoint. Dashboard → Developers → Webhooks → Add endpoint: your HTTPS URL, the events you want (or
*). Crossdeck reveals a signing secret (whsec_…) once — store it; it lives in Secret Manager on our side, never in Firestore. - Verify every event in one line.
webhooks.constructEvent(rawBody, signatureHeader, secret)in@cross-deck/nodereturns the typed event or throws — constant-time HMAC, mandatory 5-minute replay window. A throw means do not act. - HMAC-signed, idempotent, retried. Each event carries an
id(your idempotency key), atype, acreatedtimestamp, adatanudge, and areconcilepointer. Delivery is signed with HMAC-SHA256 over a per-endpoint secret and retried with exponential backoff (~3 days) before dead-lettering + auto-disabling the endpoint. - Stripe-shaped. If you've done Stripe webhooks, you know the shape:
Crossdeck-Signature: t=<unix>,v1=<hex>, timing-safe verification, 5-minute tolerance to defeat replay. - The one rule: reconcile, don't trust the payload. The webhook tells you what changed; it never carries the authoritative value. Verify the signature, then GET
event.reconcile.urlfor the truth before you enforce. This is what makes an out-of-order or replayed delivery harmless. - Polling still works as a belt. The Node SDK's
getCustomerEntitlements()/isEntitled()remain the reconciliation primitives — now the fast path is push, and a periodic poll is your safety net for a missed delivery.
What ships today
| Capability | Status | Where it lives |
|---|---|---|
| Outbound webhook delivery (Crossdeck → your backend) | Live | The outbound-event spine — registration, Secret Manager secret custody, canonical envelope, HMAC signer, at-least-once delivery + retry, SSRF guard, dead-letter — plus a scheduled drainer. |
| Live event types | Live | trust.rule.added, trust.rule.removed. New types ride the same spine as thin riders and verify identically. |
| Endpoint management (register / disable / rotate secret / delivery log + replay) | Live | Dashboard → Developers → Webhooks. |
| Signature verification in the SDK | Live | webhooks.constructEvent() (typed envelope) and verifyWebhookSignature() (parsed body) in @cross-deck/node. Constant-time, replay-bounded, rotation-aware. |
| Inbound rail webhooks (Apple / Google / Stripe → Crossdeck) | Live | Rail webhooks — how Crossdeck learns about your customers' subscription events. The opposite direction to this page. |
| Server-to-server reconciliation via Node SDK | Live | @cross-deck/node — getCustomerEntitlements(), isEntitled(), listEntitlements(). The reconcile target the nudge points you at. |
Verify a webhook — webhooks.constructEvent()
Receive the raw body (do not let a JSON body-parser mutate it before verification — the signature is over the exact bytes), pass it with the Crossdeck-Signature header and your endpoint secret. On success you get the typed WebhookEvent; on any failure — bad signature, replayed timestamp, missing header — it throws, and a throw means reject the request.
import { webhooks } from "@cross-deck/node";
app.post("/crossdeck-webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = webhooks.constructEvent(
req.body.toString("utf8"), // the RAW bytes, unparsed
req.headers["crossdeck-signature"],
process.env.CROSSDECK_WEBHOOK_SECRET, // from the dashboard, via env
);
} catch (err) {
return res.sendStatus(401); // bad sig / replay — do NOT act
}
// ACK fast so we don't retry a slow handler, then reconcile out of band:
res.sendStatus(200);
if (!seen(event.id)) { // idempotency — event.id may repeat
const truth = await fetch(event.reconcile.url).then((r) => r.json());
enforceFrom(truth); // the truth, never event.data alone
}
});
Prefer the Stripe-shape client mount if you already hold a CrossdeckServer — crossdeck.webhooks.constructEvent(...) is the same function. Both accept an array of secrets so you can rotate without dropping in-flight deliveries: pass [oldSecret, newSecret] until every endpoint has cut over.
Why this matters — push beats poll
The reason every bank-grade SaaS platform — Stripe, Auth0, Okta, Twilio, GitHub — ships outbound webhooks is that customers' backends genuinely need push semantics for some flows.
Live event types
This is the whole catalogue today. Every event shares the same envelope; only type and data differ. New types ride the same delivery spine — when one ships, it appears here and verifies with the exact same code.
| Type | Fires when | Reconcile with |
|---|---|---|
trust.rule.added |
A rule is added to your Trust blocklist (e.g. a block takes effect). | GET /v1/trust/blocklist |
trust.rule.removed |
A rule is lifted from your Trust blocklist. | GET /v1/trust/blocklist |
The envelope — identical shape for every type:
{
"id": "evt_9f2c8a1b…", // your idempotency key — dedupe on it
"object": "event",
"type": "trust.rule.added",
"api_version": "2026-07-01", // bumped only on a breaking envelope change
"created": 1753700000,
"livemode": true, // false on a sandbox workspace
"data": { "ruleId": "rul_7Q…" }, // the NUDGE — what changed, not the full state
"reconcile": { // where the authoritative truth lives
"method": "GET",
"url": "https://api.cross-deck.com/v1/trust/blocklist"
}
}
Delivery & retry
- At-least-once. The same event may arrive more than once. Dedupe on
event.id— it's stable across retries. - Signed every attempt. Each delivery carries a fresh
Crossdeck-Signatureover the exact raw body. Verify before you read. - 10-second timeout per attempt. A slow endpoint is a failed attempt, retried — never a hang on our side. Return
2xxfast, then do your work; anything else (or a timeout) is treated as a failure. - Exponential backoff, ~3 days. After each failure the next attempt waits 1 min → 5 min → 30 min → 2 h → 6 h → 24 h (7 attempts total).
- Dead-letter + auto-disable. After the final failed attempt the delivery is dead-lettered and the endpoint is automatically disabled. Re-enable it from the dashboard once your receiver is healthy.
- 64 KB payload cap. A webhook is a nudge, never a data export — which is exactly why the reconcile pointer exists.
- SSRF-guarded. Endpoints must be public HTTPS. Loopback, private, link-local, CGNAT, and cloud-metadata targets are rejected at registration.
The nudge rule — reconcile, don't trust the payload
This is the one rule that keeps outbound webhooks bank-grade. The payload tells you what changed; it is never the authoritative value. After you verify the signature, GET the reconcile.url and act on what it returns — not on event.data alone.
Why it matters: at-least-once delivery plus retries means an event can arrive late, twice, or out of order. If you enforced straight off the payload, a replayed trust.rule.added could re-apply a rule you'd already lifted. Reading the reconcile endpoint collapses all of that to one question — "what is true right now?" — so ordering and duplication stop mattering. Verify → reconcile → enforce.
Manage endpoints
Everything endpoint-side lives in the dashboard under Developers → Webhooks:
- Add an endpoint — your HTTPS URL and the events you want (specific types, or
*for all). - Reveal the signing secret once (
whsec_…). Store it in your secrets manager and read it from an env var. On our side it lives in Secret Manager — never in the clear after that first reveal. - Delivery log & replay — inspect attempts and replay a delivery while you debug your handler.
- Rotate the secret — verify with an array of secrets (
[old, new]) until every receiver has cut over, then drop the old one. - Re-enable an endpoint that auto-disabled after repeated failures.
Polling is still your safety net
Push is the fast path; it is not your only line of defence. Keep a low-frequency reconcile — getCustomerEntitlements() / getEntitlements() in @cross-deck/node, or GET-ing the same reconcile endpoints on a schedule — so that if an endpoint is down through every retry, your state still converges on the next poll. Belt and suspenders: webhooks for latency, a periodic poll for durability.
Related
- Node SDK · Webhook verification —
webhooks.constructEvent()andverifyWebhookSignature(), the verification primitives used above. - Entitlements — the model behind the state you reconcile to, and how to gate on it across client, server, and database rules.
- Rail webhooks — inbound (Apple / Google / Stripe → Crossdeck). The opposite direction to this page.
This page documents outbound webhooks that are live in production — the single source of truth for which events Crossdeck delivers and how to receive them. Last updated Jul 28, 2026.
Using an AI assistant? Read this page as clean markdown — index.md — or the whole docs index at /docs/llms.txt.