Receiving Crossdeck webhooks — push notifications for customer-state changes
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.
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
- Status: roadmap (Phase 4). Outbound Crossdeck → your-backend webhook delivery is not yet implemented. There is no
POST /v1/webhook_endpointsin the API today; noEndpointspage in the dashboard. This doc is the canonical preview of what it will look like. - Planned shape: HMAC-signed, idempotent, retried with exponential backoff. Every event will carry an
id, atype, acreatedtimestamp, and adataenvelope. Every delivery will be signed with HMAC-SHA256 using a per-endpoint signing secret, and replayable from the dashboard. - Contract will be Stripe-shaped. If you've integrated Stripe webhooks before, you already know the shape:
Crossdeck-Signature: t=<unix>,v1=<hex>, timing-safe verification, 5-minute timestamp tolerance to defeat replay. - Available today: polling. Until webhooks ship, the Node SDK's
getCustomerEntitlements()andlistEntitlements()are how you reconcile state. A 60-second cron is good enough for ~95% of flows; sub-second-latency cases need the upcoming push path. - Available today: lazy verify on access. For paywall gating, the Node SDK's microsecond-cached
isEntitled()check is the recommended pattern. Trust the cache, refresh on the access boundary — see the manual reconciliation pattern. - Build for the future today. Wrap your state-change handling in an internal
OnCustomerStateChange(eventType, payload)dispatcher. Your polling worker calls it today; when webhooks ship, a one-line route handler calls the same dispatcher. Zero refactoring at switch-over.
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.
| Capability | Status | Where 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/node — getCustomerEntitlements(), 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.
- Paid-customer-instant-access. A user completes Stripe Checkout, your dashboard's "Activity" stream shows them as Pro within 800 ms — but their own app's "Welcome to Pro" email doesn't fire until your 60-second poller catches up. That's a worst-case 59-second gap that ruins the moment. Webhooks close that gap.
- Refund / chargeback / dispute handling. A revoke needs to propagate to internal feature flags, abuse-detection systems, and access-control gates in seconds, not minutes. Stale entitlements after a chargeback are a real revenue-recovery problem.
- Audit and compliance side-effects. Many compliance regimes require an audit-log entry within a defined window of the event. A poll-based architecture can technically meet a 1-hour SLA but not a 1-minute one.
- Error-rate threshold alerts. If your error rate in Crossdeck spikes, you want PagerDuty paged in seconds.
error.threshold_exceededas a webhook fires the moment the threshold trips. A poller would learn about it on the next tick — too late. - Cost. Polling at 1 Hz from every customer scales as N customers × 1 rps. Push scales as events × ~constant. At 10,000 customers polling once per second, you'd be at 36 million reconcile calls per hour just to ask "anything new?" — almost all of them returning empty.
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 type | When it will fire | Primary data fields |
|---|---|---|
customer.created | A new cdcust_… is minted (first identify, first purchase, or admin-created). | customerId, email, traits, createdAt |
customer.updated | Traits or email mutate on an existing customer. | customerId, diff (key → [before, after]) |
customer.deleted | GDPR forget, admin-initiated delete, or merge collapse. | customerId, reason |
subscription.created | First subscription on a customer (any rail). | customerId, subscriptionId, rail, productId, periodEndsAt |
subscription.updated | Plan change, trial conversion, billing-cycle change. | customerId, subscriptionId, diff |
subscription.cancelled | Cancellation effective (not scheduled — fired at end of period when access is lost). | customerId, subscriptionId, cancelledAt, cancelReason |
entitlement.granted | A specific entitlement key becomes true for a customer. | customerId, entitlementKey, source, expiresAt |
entitlement.revoked | Entitlement turned false (refund, chargeback, manual revoke). | customerId, entitlementKey, reason |
entitlement.expired | Entitlement reached its expiresAt boundary without renewal. | customerId, entitlementKey, expiredAt |
purchase.completed | A one-time purchase or initial subscription charge cleared. | customerId, purchaseId, rail, amountMinor, currency |
purchase.refunded | Full or partial refund issued. | customerId, purchaseId, refundedAmountMinor, reason |
payment.failed | Card declined, insufficient funds, etc. Fires once per failed attempt. | customerId, subscriptionId, declineCode, nextRetryAt |
error.threshold_exceeded | Error 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:
idis the idempotency key. Crossdeck will retry deliveries on failure — every retry carries the sameid. Your handler stores processed IDs and ignores duplicates.createdis monotonic per project, not globally. A late-arriving retry can land out-of-order versus newer events. Don't trust order — re-read the canonical state withgetCustomerEntitlements()if order matters for your branch.apiVersionis pinned per endpoint. When the envelope shape evolves, existing endpoints continue to receive the old shape. You opt in to a new version explicitly. Stripe-style.
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:
- Use the raw request body, not a re-stringified object. A re-stringify can re-order keys and break the signature. Frameworks that parse JSON eagerly (Express's default
body-parser) must be configured to expose the raw bytes on the webhook route. - Compare with a timing-safe equality primitive.
crypto.timingSafeEqualin Node,hmac.compare_digestin Python,secure_comparein Ruby. Plain===leaks signature bytes through CPU side-channels. - Reject any timestamp older than 5 minutes from now. Closes the replay window. Your server clock should be NTP-synced; we will reject deliveries where our own clock diverges from yours by more than 5 minutes too.
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
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:
| Attempt | Delay after previous | Total time elapsed |
|---|---|---|
| 1 (initial) | 0 s | 0 s |
| 2 | 1 s | 1 s |
| 3 | 5 s | 6 s |
| 4 | 30 s | 36 s |
| 5 | 5 min | ~5 min |
| 6 | 1 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:
- Replay any single failed delivery, or all failures in a time window, with the same
event.idas the original so your idempotency check still works. - Inspect the request body, response body, response status, and response timing for every attempt.
- Disable the endpoint without deleting it — useful when you're investigating a downstream outage and don't want continued retry pressure.
- Filter the failures list by event type, status code, and time window for triage.
Three connection-level rules:
- 10-second response timeout. If your handler hasn't responded in 10 seconds, the connection is dropped and the delivery is retried. Keep handlers fast — enqueue heavy work to a background job rather than processing inline.
2xxmeans "I have durably received this event," not "I have completed every side-effect." Ack as soon as the event is safely enqueued; do not block the response on database writes that could take seconds.- HTTPS only. The endpoint URL must be
https://;http://URLs will be rejected at endpoint creation.
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:
- 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). - 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. - 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.
- 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. - 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:
- Delivery success rate (last hour / last 24h / last 7 days).
- Response latency p50 / p95 / p99.
- Recent attempts log — every event ID, status, latency, retry count.
- A Replay action on any past delivery for sandbox debugging.
Workaround — polling pattern [AVAILABLE TODAY]
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:
crossdeck.getCustomerEntitlements(customerId)— returns the current entitlement set for a single customer. Per-customer rate limit applies; 60-second cron at sub-10k customer count is well within budget.crossdeck.listEntitlements({ projectId })— bulk view across the project (paginated). Better for very-large-customer-base reconciles where per-customer polling would be too many calls.crossdeck.isEntitled(customerId, key)— microsecond cached check from the SDK's 60-second-warm in-memory cache. Don't use this for reconciliation polling (it'll serve the cache); use it on the access-decision path.
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:
- Wait for Phase 4. Outbound webhooks close the gap to sub-second.
- 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:
- Microsecond happy path. Entitled users pay no network cost on every request — the cache check is in-process.
- Always-correct denial. A "no" answer triggers a forced refresh before responding 402, so a recently-upgraded customer never sees a stale denial.
- Cheap for low-cardinality entitlements. When 90% of your traffic is from Pro users and only 10% is from Free users trying to access Pro features, only the 10% pay the network round-trip.
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.
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:
- Register an endpoint URL in the dashboard's
Developers → API → Endpointspage. Pick which event types you want (start narrow —entitlement.granted,entitlement.revoked,entitlement.expired,purchase.refundedcovers ~90% of business logic). - 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. - Add the route handler using the verification sample for your language. Hook it into your existing
onCustomerStateChange()dispatcher (one line). Deploy. - 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.
- 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.
- 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:
- The Crossdeck docs changelog at
docs/index.html#changelog. Until then, this page is the canonical preview — bookmark it. - The dashboard's "What's new" banner on the Developers section.
- The product email list attached to your Crossdeck account email (opt-in at account creation; togglable in Account Settings).
- The
@cross-deck/nodeCHANGELOG — the verification helper used today for inbound rail webhooks is the same primitive that will verify outbound webhooks. The first release that lists "outbound webhook verification" in the changelog is the release that lights this feature up.
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.
Related
- Node SDK · Webhook verification — the
verifyWebhookSignature()primitive that ships today (verifies inbound rail webhooks) and will be reused for outbound webhook verification when Phase 4 launches. Same signing scheme, same call. - Entitlements — the canonical model for "what a customer can do." Outbound webhooks notify on entitlement state changes; this doc explains what entitlements are.
- Node SDK · Entitlements — the
getCustomerEntitlements()/isEntitled()/listEntitlements()calls used by the polling workaround. - Web SDK — for client-side entitlement reads. Pairs cleanly with the server-side polling pattern.
- API keys & authentication — how to provision the secret key the Node SDK uses to reconcile state. The signing secret for an outbound webhook endpoint will be a separate per-endpoint credential.
- Inbound rail-webhook configuration — the dashboard page that emits webhook URLs for Apple App Store Server Notifications, Google Real-Time Developer Notifications, and Stripe Webhooks to call into Crossdeck. Not the same direction as this doc.
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.