# Receiving Crossdeck webhooks

> Source: https://cross-deck.com/docs/webhooks-receive/ · Crossdeck developer docs (generated from the published page).

Live
~15 min read
·
Updated Jul 28, 2026

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.

Outbound webhooks are live.

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](https://cross-deck.com/docs/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/node` returns 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), a `type`, a `created` timestamp, a `data` nudge, and a `reconcile` pointer. 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= ,v1= `, 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.url` for 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-Signature` over 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 `2xx` fast, 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](https://cross-deck.com/docs/node-sdk/#webhooks) — `webhooks.constructEvent()` and `verifyWebhookSignature()`, the verification primitives used above.

- [Entitlements](https://cross-deck.com/docs/entitlements/) — the model behind the state you reconcile to, and how to gate on it across client, server, and database rules.

- [Rail webhooks](https://cross-deck.com/docs/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.
