Crossdeck Docs
Dashboard

Receiving Crossdeck webhooks — push notifications for customer-state changes

Roadmap preview Planned: Phase 4 · ETA TBD · ~15 min read · Updated May 15, 2026

When a customer's entitlement changes — granted, revoked, expired, refunded — your backend should react in milliseconds, not on the next cron. Outbound webhooks are how Crossdeck will push those state changes to your servers. This page is the canonical preview of that contract: what the events will look like, how they will be signed, and the architecture pattern you can adopt today so adding webhook handlers later is a one-line change. It also documents the polling workaround you can build against right now.

This feature is on the roadmap — it has not shipped yet.

Outbound Crossdeck → customer-backend webhooks are part of Phase 4 of the developer playground build-out. ETA is TBD. Every event shape, signing detail, and code sample in the sections below is marked [ROADMAP] and may change before launch. If you need push-style state today, jump to the polling workaround — it works against the SDKs that ship today and is designed to swap out for real webhooks with no refactor.

Not to be confused with inbound rail webhooks — those are how Apple, Google Play, and Stripe call into Crossdeck (shipped, extensive). The feature documented here is the opposite direction: Crossdeck calling out to your backend. For inbound rail webhooks, see Rail webhooks.

TL;DR

Roadmap notice — what is and isn't built

For roadmap-grade transparency, here is the exact split between what works today and what is planned.

CapabilityStatusWhere it lives
Inbound rail webhooks (Apple / Google / Stripe → Crossdeck) Available today /dashboard/developers/api/ § Webhooks — emits webhook URLs for each rail to paste into App Store Connect and Google Play Console. (Stripe is auto-registered via OAuth — no URL to copy.) This is how Crossdeck learns about your customers' subscription events.
Server-to-server reconciliation via Node SDK Available today @cross-deck/nodegetCustomerEntitlements(), isEntitled(), listEntitlements(). See polling workaround.
Webhook signature verification primitive Available today (one direction) verifyWebhookSignature() in @cross-deck/node. Today it verifies inbound rail webhooks routed through Crossdeck. The same primitive will verify outbound Crossdeck webhooks when they ship — see planned verification samples.
Outbound webhook delivery (Crossdeck → your backend) Roadmap — Phase 4 Not built. No /v1/webhook_endpoints API, no Endpoints page. This document is the preview.
Outbound event taxonomy (customer.created, entitlement.granted, etc.) Roadmap — Phase 4 Names, shape, and field-level contract are previewed below. Subject to change before launch.
Outbound delivery retry / replay UI Roadmap — Phase 4 Will live alongside the existing Webhooks page. Failed delivery list, replay buttons, signing-secret rotation.

If you came here from a Google result or a support reply expecting outbound webhooks to already work, that's our mistake — this page should now make the situation explicit. The polling workaround below is the supported path until Phase 4 lands, and the architecture preparation section walks through how to write your handlers today so the switch is trivial later.

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.

Crossdeck's planned outbound webhook implementation is designed to make all five flows trivial: one endpoint, one signing secret, one event-type subscription, and a few lines of verification code.

Planned event taxonomy [ROADMAP]

The following event types are planned for the initial launch. Names follow Stripe's resource.action convention. Subject to change before Phase 4 ships.

Event typeWhen it will firePrimary data fields
customer.createdA new cdcust_… is minted (first identify, first purchase, or admin-created).customerId, email, traits, createdAt
customer.updatedTraits or email mutate on an existing customer.customerId, diff (key → [before, after])
customer.deletedGDPR forget, admin-initiated delete, or merge collapse.customerId, reason
subscription.createdFirst subscription on a customer (any rail).customerId, subscriptionId, rail, productId, periodEndsAt
subscription.updatedPlan change, trial conversion, billing-cycle change.customerId, subscriptionId, diff
subscription.cancelledCancellation effective (not scheduled — fired at end of period when access is lost).customerId, subscriptionId, cancelledAt, cancelReason
entitlement.grantedA specific entitlement key becomes true for a customer.customerId, entitlementKey, source, expiresAt
entitlement.revokedEntitlement turned false (refund, chargeback, manual revoke).customerId, entitlementKey, reason
entitlement.expiredEntitlement reached its expiresAt boundary without renewal.customerId, entitlementKey, expiredAt
purchase.completedA one-time purchase or initial subscription charge cleared.customerId, purchaseId, rail, amountMinor, currency
purchase.refundedFull or partial refund issued.customerId, purchaseId, refundedAmountMinor, reason
payment.failedCard declined, insufficient funds, etc. Fires once per failed attempt.customerId, subscriptionId, declineCode, nextRetryAt
error.threshold_exceededError rate in Crossdeck telemetry spikes above the configured threshold for the project.projectId, thresholdName, currentRate, windowStartedAt

Every event will share the same envelope shape:

// [ROADMAP] Planned envelope shape — subject to change before launch
{
  id: "evt_01HXX9KE2N7…",                // ULID, idempotency key
  type: "entitlement.granted",
  created: 1715414410000,                // epoch ms
  projectId: "prj_xxx",
  environment: "production",             // "sandbox" | "production"
  apiVersion: "2026-05-15",
  data: {
    customerId: "cdcust_xxx",
    entitlementKey: "pro",
    source: "subscription",
    sourceSubscriptionId: "sub_xxx",
    expiresAt: 1718006400000
  }
}

Three design notes that will be true at launch:

Planned signing contract [ROADMAP]

Every webhook delivery will carry a Crossdeck-Signature header. The signing scheme will match the existing primitive in @cross-deck/node (used today for inbound rail-webhook verification), so the same verification helper works in both directions when this ships.

// [ROADMAP] Planned header — already the shape of inbound webhook signatures today
Crossdeck-Signature: t=1715414410,v1=a3f4b9c7d2e1…

The signature payload is the concatenation "${t}.${rawBody}". v1 is the lower-case hex output of HMAC-SHA256(secret, payload). Verification must:

Sample of a delivery as it will arrive on the wire:

# [ROADMAP] Planned wire format
POST /webhooks/crossdeck HTTP/1.1
Host: api.your-app.com
Content-Type: application/json
User-Agent: Crossdeck-Webhooks/1.0
Crossdeck-Signature: t=1715414410,v1=a3f4b9c7d2e1f8a0b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3

{"id":"evt_01HXX9KE2N7","type":"entitlement.granted","created":1715414410000,"projectId":"prj_xxx","environment":"production","apiVersion":"2026-05-15","data":{"customerId":"cdcust_xxx","entitlementKey":"pro","source":"subscription","sourceSubscriptionId":"sub_xxx","expiresAt":1718006400000}}

Signing secrets will be per-endpoint, revealed once at endpoint creation, and rotatable from the dashboard. During a rotation window the dashboard will accept up to two active secrets per endpoint — sign-with-new, accept-either-on-verify — so you can roll without dropping a delivery.

Planned verification code samples [ROADMAP — exact code may change]

Reference implementations in three languages. The Node sample mirrors the existing verifyWebhookSignature() primitive in @cross-deck/node — same primitive, opposite direction. See Node SDK · Webhook verification for the shipping version.

Node — Express + @cross-deck/node

// [ROADMAP] Planned shape — the verifyWebhookSignature primitive already ships today
// for inbound rail webhooks. Same call, customer-facing direction.
import express from "express";
import { CrossdeckServer } from "@cross-deck/node";

const crossdeck = new CrossdeckServer({
  appId: "app_node_xxx",
  secretKey: process.env.CROSSDECK_SECRET_KEY,
  environment: "production",
});

const app = express();

// IMPORTANT: webhook route uses raw body, not JSON parser.
app.post(
  "/webhooks/crossdeck",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["crossdeck-signature"];
    const secret = process.env.CROSSDECK_WEBHOOK_SECRET;

    let event;
    try {
      event = crossdeck.verifyWebhookSignature(req.body, sig, secret);
    } catch (err) {
      return res.status(400).send("signature mismatch");
    }

    // event.id is the idempotency key — dedupe before side-effects.
    if (await alreadyProcessed(event.id)) {
      return res.sendStatus(200);
    }

    switch (event.type) {
      case "entitlement.granted":
        await grantInternalAccess(event.data.customerId, event.data.entitlementKey);
        break;
      case "entitlement.revoked":
      case "entitlement.expired":
        await revokeInternalAccess(event.data.customerId, event.data.entitlementKey);
        break;
      case "purchase.refunded":
        await markRefunded(event.data.purchaseId);
        break;
    }

    await markProcessed(event.id);
    res.sendStatus(200);
  }
);

Python — Flask

# [ROADMAP] Planned shape — exact import path may change at launch
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["CROSSDECK_WEBHOOK_SECRET"]
TOLERANCE_S = 5 * 60

def verify(raw_body: bytes, header: str, secret: str) -> dict:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts["t"], parts["v1"]
    if abs(int(time.time()) - int(t)) > TOLERANCE_S:
        raise ValueError("timestamp outside 5min tolerance")
    payload = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError("signature mismatch")
    import json
    return json.loads(raw_body)

@app.post("/webhooks/crossdeck")
def webhook():
    try:
        event = verify(
            request.get_data(),
            request.headers["Crossdeck-Signature"],
            WEBHOOK_SECRET,
        )
    except ValueError:
        abort(400)

    if already_processed(event["id"]):
        return "", 200

    handle_event(event["type"], event["data"])
    mark_processed(event["id"])
    return "", 200

Ruby — Sinatra

# [ROADMAP] Planned shape — exact gem name may change at launch
require "sinatra"
require "openssl"
require "json"

WEBHOOK_SECRET = ENV.fetch("CROSSDECK_WEBHOOK_SECRET")
TOLERANCE_S = 5 * 60

def verify_signature(raw_body, header, secret)
  parts = header.split(",").map { |p| p.split("=", 2) }.to_h
  t, v1 = parts["t"], parts["v1"]
  raise "replay" if (Time.now.to_i - t.to_i).abs > TOLERANCE_S
  payload = "#{t}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
  raise "mismatch" unless Rack::Utils.secure_compare(expected, v1)
  JSON.parse(raw_body)
end

post "/webhooks/crossdeck" do
  request.body.rewind
  raw = request.body.read
  begin
    event = verify_signature(raw, request.env["HTTP_CROSSDECK_SIGNATURE"], WEBHOOK_SECRET)
  rescue
    halt 400
  end

  return 200 if already_processed?(event["id"])
  handle_event(event["type"], event["data"])
  mark_processed(event["id"])
  200
end
Idempotency is your responsibility.

Crossdeck guarantees at-least-once delivery — never exactly-once. Two retries with the same event.id are common. Store processed IDs in a table with a unique index and bail on conflict. A 5-minute TTL Redis set is usually enough; long-tail retries past 5 minutes are rare but possible.

Planned retry & delivery semantics [ROADMAP]

Crossdeck will treat any HTTP response other than 2xx as a delivery failure and retry on the following exponential-backoff schedule:

AttemptDelay after previousTotal time elapsed
1 (initial)0 s0 s
21 s1 s
35 s6 s
430 s36 s
55 min~5 min
61 h~1 h
7 (final)6 h~7 h

After the seventh attempt (~24 hours of total retry pressure), the delivery is parked in the dashboard's failed-deliveries list. From there you'll be able to:

Three connection-level rules:

Planned dashboard configuration [ROADMAP]

The dashboard's Developers → API page will gain an Endpoints tab alongside the existing rail-webhook URLs. The flow will be:

  1. Click "Add endpoint." Enter the destination URL (e.g. https://api.your-app.com/webhooks/crossdeck). HTTPS only. Localhost is permitted in sandbox mode for tunnel-based testing (ngrok, Cloudflare Tunnel).
  2. Select event types. Multi-select from the taxonomy above, or pick "All events". Granularity matters — subscribing to "All" when you only care about entitlement.* wastes your bandwidth and ours.
  3. Receive the signing secret. One-time reveal. The secret is shown once on screen at endpoint creation; it cannot be re-revealed afterwards (only rotated). Treat it like a password.
  4. Send a test event. The dashboard will offer a "Send test entitlement.granted" button that delivers a signed event with realistic dummy data — useful for end-to-end smoke before flipping a live endpoint.
  5. Operate. The Endpoints list shows last delivery time, success rate over the last 7 days, and a one-click Disable. Disabled endpoints keep their audit trail; deleted endpoints lose it.

Per-endpoint diagnostics will include:

Workaround — polling pattern [AVAILABLE TODAY]

This is the supported path until outbound webhooks ship.

The Node SDK is the source of truth for "what does this customer currently have?" — and it's been built to make polling cheap. The pattern below is what we recommend for customers shipping into production today; the architecture preparation section walks through writing it so the switch to webhooks later is a one-liner.

The shape: a cron job on a 60-second interval polls the Node SDK for the current state of every customer you care about, diffs against your last-known state, and dispatches into your internal state-change bus.

// [AVAILABLE TODAY] Polling pattern with @cross-deck/node
import { CrossdeckServer } from "@cross-deck/node";
import { onCustomerStateChange } from "./internal/state-bus";

const crossdeck = new CrossdeckServer({
  appId: "app_node_xxx",
  secretKey: process.env.CROSSDECK_SECRET_KEY,
  environment: "production",
});

async function reconcileEntitlements(customerId) {
  const current = await crossdeck.getCustomerEntitlements(customerId);
  const previous = await loadLastKnown(customerId);

  for (const key of diff(previous, current).granted) {
    await onCustomerStateChange("entitlement.granted", {
      customerId,
      entitlementKey: key,
      source: "polling",
    });
  }
  for (const key of diff(previous, current).revoked) {
    await onCustomerStateChange("entitlement.revoked", {
      customerId,
      entitlementKey: key,
      source: "polling",
    });
  }

  await saveLastKnown(customerId, current);
}

// Run every 60 seconds against active customers.
setInterval(async () => {
  const active = await listActiveCustomerIds();
  for (const id of active) {
    try { await reconcileEntitlements(id); }
    catch (err) { reportError(err); }
  }
}, 60_000);

Available calls in the shipping Node SDK that this pattern relies on:

Honest caveat. This is pull, not push. There will be lag — bounded by your poll interval and the SDK's cache TTL. A 60-second cron means a worst-case 60-second gap between Crossdeck knowing about a state change and your backend knowing. Acceptable for the vast majority of flows; not acceptable when "paying customer needs instant-access in under 5 seconds" is a hard requirement. For that subset, you have two options:

  1. Wait for Phase 4. Outbound webhooks close the gap to sub-second.
  2. Verify lazily at the access boundary (see below). Trust the cache for the hot path; refresh on the access decision itself.

Workaround — manual reconciliation at access boundary [AVAILABLE TODAY]

For paywall gating — the most common "I need fast and correct entitlement state" case — the recommended pattern is cache by default, refresh on access. The Node SDK ships with a 60-second-warm in-memory cache; isEntitled() answers in microseconds from the cache, then the cache refreshes opportunistically in the background.

// [AVAILABLE TODAY] Paywall gating with manual refresh
async function requireEntitlement(req, key) {
  const userId = req.user.id;

  // 1. Microsecond cache hit on the hot path.
  if (crossdeck.isEntitled(userId, key)) return true;

  // 2. Cache says no — verify with a fresh fetch in case it's stale.
  const fresh = await crossdeck.getCustomerEntitlements(userId, { forceRefresh: true });
  return fresh.has(key);
}

app.post("/api/pro-feature", async (req, res) => {
  if (!(await requireEntitlement(req, "pro"))) {
    return res.status(402).json({ error: "upgrade_required" });
  }
  // … fulfil the request
});

This pattern's good properties:

When outbound webhooks ship, you can keep this pattern — your webhook handler invalidates the cache on entitlement.granted / entitlement.revoked and the cache becomes always-fresh without the forced refresh. Less code, lower latency, same shape.

Architecture preparation — make the switch a one-liner

The single best thing you can do today to prepare for outbound webhooks is wrap your state-change handling in an internal dispatcher. Both the polling worker (today) and the webhook handler (later) call into the same function. When webhooks ship, you delete the cron and add a route — zero refactoring on the side-effect logic.

// internal/state-bus.ts — the shape you want today and at switch-over
type CustomerStateEvent =
  | { type: "entitlement.granted"; customerId: string; entitlementKey: string; source: string }
  | { type: "entitlement.revoked"; customerId: string; entitlementKey: string; source: string }
  | { type: "subscription.cancelled"; customerId: string; subscriptionId: string }
  | { type: "purchase.refunded"; customerId: string; purchaseId: string };

export async function onCustomerStateChange(eventType: CustomerStateEvent["type"], payload: any) {
  switch (eventType) {
    case "entitlement.granted":
      await applyAccess(payload.customerId, payload.entitlementKey);
      await sendUpgradeEmail(payload.customerId, payload.entitlementKey);
      break;
    case "entitlement.revoked":
      await removeAccess(payload.customerId, payload.entitlementKey);
      await sendDowngradeEmail(payload.customerId, payload.entitlementKey);
      break;
    case "subscription.cancelled":
      await markSubscriptionInactive(payload.subscriptionId);
      break;
    case "purchase.refunded":
      await reversePurchase(payload.purchaseId);
      break;
  }
}

Today, your polling worker calls onCustomerStateChange():

// today — polling worker dispatches into the bus
for (const key of diff.granted) {
  await onCustomerStateChange("entitlement.granted", { customerId, entitlementKey: key, source: "polling" });
}

Later, your webhook route calls the same function:

// future — webhook route dispatches into the same bus, one line of glue
app.post("/webhooks/crossdeck", async (req, res) => {
  const event = crossdeck.verifyWebhookSignature(req.body, req.headers["crossdeck-signature"], WEBHOOK_SECRET);
  await onCustomerStateChange(event.type, event.data);
  res.sendStatus(200);
});

The side-effect code — apply access, email the user, mark the subscription, reverse the purchase — never changes. Only the upstream source of the event changes. This is the Stripe-style "abstract the event source" pattern and it pays off the moment a second source appears.

Make the dispatcher idempotent from day one.

Even with polling-only today, you'll have duplicate dispatches in failure modes (worker crash mid-loop, double-run). When webhooks ship, at-least-once delivery makes idempotency a hard requirement. Cheaper to bake it in now than retrofit it later — guard side-effects with a (customerId, eventType, eventKey) lookup against an already-applied table.

Migration path when this ships

When Phase 4 lands, the migration from polling to webhooks is a five-step checklist. Most teams will do all five in under an hour:

  1. Register an endpoint URL in the dashboard's Developers → API → Endpoints page. Pick which event types you want (start narrow — entitlement.granted, entitlement.revoked, entitlement.expired, purchase.refunded covers ~90% of business logic).
  2. Copy the signing secret into your secret manager (Google Cloud Secret Manager, AWS Secrets Manager, Vault, fly secrets, etc.). Do not commit it to source. The secret is shown once at creation — store it immediately.
  3. Add the route handler using the verification sample for your language. Hook it into your existing onCustomerStateChange() dispatcher (one line). Deploy.
  4. Send a test event from the dashboard's "Send test" button. Confirm a 200 lands in your logs and the dispatcher fires its side-effects against the test customer.
  5. Confirm a real event end-to-end. Trigger an entitlement change in sandbox (grant a test entitlement to a test customer through the dashboard) and watch the delivery land. Replay it once to confirm idempotency.
  6. Disable the polling cron. Don't delete it for the first week — leave the cron in place running half as often (every 5 minutes instead of every 60 seconds) as a safety belt. After a week of clean webhook deliveries, delete the cron.

The whole migration is additive — you can run polling and webhooks in parallel for as long as you want. Your dispatcher's idempotency check makes "both fire on the same event" a no-op.

Get notified when this ships

Phase 4 is on the roadmap but does not yet have a published ship date. When it lands, the announcement will appear in:

If you have a specific use case that depends on outbound webhooks landing — particularly the "paying customer needs sub-5-second access propagation" case — open a support ticket. We track which customers are waiting on Phase 4 and use that to prioritise ship-order against the rest of the developer-experience backlog.


This page is a roadmap preview, not a shipping reference. The contract above is what we plan to build; field names, signing-header format, and retry schedule may evolve before launch. The polling workaround is real and supported today. Last updated May 15, 2026.