Skip to content
Sendora Cloud
Create account
Module deep-dive

Webhooks

Receive Sendora's identity & security events, and relay your own product events, to an HTTPS endpoint. Every delivery is HMAC-SHA256-signed and retried with exponential backoff.

Register a webhook

curl -X POST https://api.sendoracloud.com/api/v1/orgs/$ORG_ID/webhooks/endpoints \
  -H "x-api-key: sk_prod_..." \
  -d '{
    "url": "https://your-app.com/hooks/sendora",
    "projectId": "'"$PROJECT_ID"'",
    "events": [
      "auth.user_upgraded", "auth.device_takeover", "auth.user_merged",
      "auth.deletion_scheduled", "auth.deletion_cancelled",
      "push.delivered", "push.token_invalidated"
    ],
    "isActive": true
  }'

$ORG_ID is your Org ID (Dashboard → Settings → General). Requires an API key with the webhooks:write scope, or an Admin dashboard session. Returns the endpoint id + a one-time secret (32-byte hex, used to verify HMAC signatures) — save it now; read paths redact it to a secretConfigured boolean. If you lose it, POST .../endpoints/{id}/rotate-secret issues a fresh one (also returned exactly once) — you do not need to delete and re-register the endpoint.

projectId is required, and it decides what the endpoint receives. An endpoint is scoped to exactly one project: it gets that project's events and no other project's. If you run several apps in one workspace, register one endpoint per project rather than one endpoint for the workspace — a single URL receiving every project's end-user payloads is the shape this field exists to prevent. Every delivery also carries the resolved project at the top level as projectId, so a receiver can assert it matches the one it expects.

Signature verification

Each delivery carries one X-Sendora-Signature header in the format t=<unix_seconds>,v1=<hex> (Stripe-style). v1 is the HMAC-SHA256 hex digest of the string `${t}.${rawBody}` keyed by your endpoint secret. Verify against the exact raw body bytes — re-serializing the JSON changes key ordering and breaks the check. There is no separate X-Sendora-Timestamp header, and Sendora does not enforce a replay window; the t= value is provided so you can enforce your own.

import crypto from "node:crypto";

// rawBody = the exact request body string (do NOT JSON.parse then re-stringify)
function verify(rawBody: string, sigHeader: string, secret: string, toleranceSec = 300): boolean {
  const parts = Object.fromEntries(sigHeader.split(",").map((p) => p.split("=")));
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false; // your policy — not enforced by Sendora
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Every delivery also sends X-Sendora-Event (the event type), X-Sendora-Delivery (the envelope id — your idempotency / dedup key, stable across retries), and X-Sendora-Attempt (1-based attempt counter).

What actually reaches your endpoint

Sendora webhooks come in two kinds. Getting this distinction right saves you from subscribing to events that never arrive.

1. Sendora system events (server-emitted)

A fixed set the platform emits server-side, with stable type strings and payload shapes. These are the only events Sendora originates for you — the table below is the complete list, 10 of them.

⚠ The table below is generated from the same registry the backend dispatches from, and a test fails the build if a payload here disagrees with the object we actually send. It used to be hand-written beside the code, and drifted — an integrator built against a description that omitted a field that was present, and another that promised one that was not. Build against this table.

⚠⚠ It also used to list more events than it should. This section rendered every auth.* shape in the registry, eight of which Sendora does not emit — they are passthrough shapes (kind 2 below) that only ever arrive if you post them yourself. Under a heading saying “the only events Sendora originates”, that read as a promise of delivery, and an integrator built two handlers for messages that could not arrive. It is now filtered on the same dispatchedBySendora flag the API serves, so the page and the dispatcher cannot disagree.

EventPayloadWhat it means
auth.credential_linked{ userId, method, projectId }A second credential (email/password, an OAuth social identity, or a gaming identity) was attached to an already-identified account, preserving the same user_id (sub). The account can now sign in via the new method and land on the same sub — the cross-platform account-unification signal (ADR-030).
auth.credential_revoked{ userId, provider, projectId, lastCredential, stateVersion }An identity provider told us the user withdrew consent for one linked credential (today: Apple's consent-revoked notification — Settings → Apple ID → Sign in with Apple → Stop using). The linkage is marked revoked, not deleted: it authenticates nobody now, but the same Apple ID re-consenting lands back on the SAME user_id. Sessions for the account are revoked. ⚠ stateVersion is a MONOTONIC per-linkage counter — delivery is at-least-once and UNORDERED, and this event has an exact opposite (auth.identity_reactivated) on the same row, so discard anything whose stateVersion is not GREATER than the state you already hold. X-Sendora-Delivery does not cover this: a redelivered revoke after a reactivation is a different delivery, not a retry. ⚠ lastCredential:true means the account can no longer be authenticated into by ANYTHING — it still exists and still holds the user's data, so treat it as unreachable-but-alive, NOT as deleted (auth.user_deleted is the erasure signal and will not arrive).
auth.deletion_cancelled{ userId, email, projectId }A pending self-service deletion was CANCELLED because its owner signed back in within the grace window — the account is restored with the same user_id. If you deferred your own data erase to auth.user_deleted, KEEP the data: that delete will never arrive. The deterministic restore/reconcile signal.
auth.deletion_scheduled{ userId, email, projectId, scheduledPurgeAt, graceDays }A user requested self-service account deletion and a GRACE window opened — the account is deactivated now and hard-purge is scheduled for scheduledPurgeAt. Start your own grace countdown or notify the user. NOT fired for instant (grace=0) deletion — that fires auth.user_deleted directly.
auth.device_takeover{ anonUserId, identifiedUserId, projectId }Anonymous user retired + push tokens reassigned after the same device signed in to an identified account. Subscribe to delete the matching anonymous user_id from your own mirror table so audience queries don't fan out duplicate notifications.
auth.identity_reactivated{ userId, provider, projectId, revokedAt, stateVersion }A provider-verified sign-in brought a previously REVOKED linkage back to life — the user re-consented and landed on their ORIGINAL account. This is the other half of auth.credential_revoked: if you flagged, held or locked an account on that signal, this is what tells you to release it. ⚠ Without it a revocation looks one-way from outside, which it is not. ⚠ Compare stateVersion with > (never >=) against the state you hold, and discard anything not newer — the two events arrive unordered.
auth.session_evicted{ userId, sessionId, reason, maxSessionsPerUser, evictedLastUsedAt, deviceInfo, projectId, recoverable }A device was signed out because the account hit its session cap (maxSessionsPerUser, default 10) and a NEW sign-in needed room. The LEAST-RECENTLY-USED live session is revoked — not the newest, and not the oldest-created. This is the one account-affecting action a player actually notices ("why am I signed out?"), so it is here to stop your support desk guessing. ⚠ recoverable:false means that device can never sign back into this account — for an anonymous player the revoked refresh token was its ONLY credential, so the account still exists and still holds their progress with nothing able to reach it. ⚠ This is a RESOURCE BOUND, not a theft control: a stolen ACCESS token creates no session row, so the cap cannot see it. ⚠ Under a refresh lifetime of Never the cap is the ONLY mechanism that ends a session.
auth.user_deleted{ userId, email, projectId, reason? }A user account was permanently purged (self-service deletion after any grace period, admin deletion, SCIM deprovision, Apple-ID account deletion, the orphan-anonymous prune, or the whole workspace being deleted). Erase or anonymize your own copy of the user's data. ⚠ `reason` is present ONLY on the workspace-deletion path (`workspace_deleted`) and absent otherwise — branch on its presence, never expect it.
auth.user_merged{ mergedUserId, deletedAnonymousId, eventsReassigned, profilesReassigned, pushTokensReassigned, projectId }Anonymous user merged into an existing account via an explicit merge() call — the anonymous sub is retired and the canonical id becomes the target's. Re-key your data on the new id.
auth.user_upgraded{ userId, email, isAnonymous, projectId }Anonymous user converted to a real account in place — the sub (user_id) is preserved, so this is a status flip, not a remap.

A key marked ? is conditional — present on one code path only. Branch on its presence; never expect it.

Being on this list is not a promise of traffic. auth.credential_revoked needs a real Apple consent-revocation before it ever fires for you. The list answers “can this arrive at all”; only your delivery log answers “has it arrived”.

You do not have to take this page's word for any of it. GET /orgs/:orgId/webhooks/event-types serves the whole catalogue as JSON with a dispatchedBySendora boolean per entry, from the same registry that generates this table — and since s58.342 a subscription to a name that is neither catalogued nor one of your own already-ingested event types is refused with a 422 rather than accepted with a 201.

2. Your own events (passthrough relay)

Every event you send to the Events API (POST /events or /events/batch) is relayed to any endpoint subscribed to that type (or *), with the type and properties exactly as you sent them — Sendora does not define or validate their shape. This is how you get order.shipped, level.completed, or any product event onto a webhook: emit it, subscribe to it, relay it.

There is no fixed server catalog of module webhook events. In particular, module analytics events — push.*, email.*, sms.*, geofence.*, and automation events — are written to your analytics event stream server-side and are not delivered to webhook endpoints. Query them via Analytics, or re-emit the ones you need through the Events API to relay them onward.

Naming caveat: passthrough matches on the exact type string, so an event you emit named auth.user_upgraded would be relayed indistinguishably from the real system event. Namespace your own events (e.g. app.*) to avoid colliding with the reserved auth.* / auth_service.* system names.

Payload envelope

{
  "id": "b3f1c2a4-5d6e-4f7a-8b9c-0d1e2f3a4b5c",   // plain UUID v4 — dedup key, same id on every retry
  "type": "auth.device_takeover",
  "orgId": "<ORG_UUID>",
  "projectId": "<PROJECT_UUID | null>",             // top-level, on EVERY event. null = org-wide scope
  "occurredAt": "2026-07-17T12:00:00.000Z",         // ISO-8601 UTC, set at dispatch time
  "properties": {                                    // type-specific — see per-event shapes below
    "anonUserId": "<UUID>",
    "identifiedUserId": "<UUID>",
    "projectId": "<PROJECT_UUID | null>"             // identity events also repeat it here
  },
  "context": { "source": "auth-service" }            // type-specific metadata, often {}
}

The envelope is identical for every event type: id, type, orgId, projectId, occurredAt, properties, and context.

This paragraph used to say there was no top-level projectId and to route you into properties for it. That has been wrong since the field was added: the dispatcher puts projectId at the top level of every envelope, and it is a pinned contract with a test that fails if it is dropped. Identity events additionally repeat it inside properties, which is what the old text was describing. Read the top-level one — it is the scope the delivery was actually resolved against.

null means org-wide scope. To map a projectId UUID to one of your apps, find it in the dashboard under Settings → Projects (each project row has a copy button), or list them via GET /orgs/:orgId/projects.

Use id as your idempotency key (also delivered as the X-Sendora-Delivery header). Sendora retries on 5xx / timeout / network errors, but id is stable across every attempt.

id format: a plain RFC-4122 UUID v4 — not prefixed and not time-sortable. Order events by occurredAt if you need chronology, and dedupe on id.

context carries optional per-event metadata (e.g. { "source": "auth-service" } on device-takeovers) and is {} for most events. Both properties and context are additive — treat unknown keys as forward-compatible, never reject on them.

System event payloads

The five server-emitted events, with their exact properties. Passthrough events carry whatever properties you sent to the Events API — Sendora relays them unchanged.

// auth.user_upgraded  — anon → real, sub preserved
{ "userId": "<uuid>", "email": "a@b.com", "isAnonymous": false,
  "projectId": "<uuid | null>" }

// auth.user_merged  — explicit merge(), anon sub remapped to target
{ "mergedUserId": "<uuid>", "deletedAnonymousId": "<uuid>",
  "eventsReassigned": 12, "profilesReassigned": 1, "pushTokensReassigned": 1,
  "projectId": "<uuid | null>" }

// auth.device_takeover  — sign-in / collision retires the anon sub
// context: { "source": "auth-service" }
{ "anonUserId": "<uuid>", "identifiedUserId": "<uuid>",
  "projectId": "<uuid | null>" }

// auth.user_deleted  — account permanently purged
{ "userId": "<uuid>", "email": "a@b.com", "projectId": "<uuid | null>" }

// auth_service.signing_key_rotated  — refresh your cached JWKS
// context: { "source": "auth-service" }
{ "newKid": "...", "previousKid": "...", "jwksUrl": "https://...",
  "overlapWindowMs": 86400000,
  "previousKeyExpiresAt": "2026-07-18T00:00:00.000Z" }

For identity semantics (which id is stable, when to re-key your own tables), see Identity in your backend and Device-takeover on signIn.

Retry policy

Your receiver must respond 2xx within 5 seconds. Anything else — non-2xx, timeout, or network error — triggers a retry.

  • Schedule — up to 5 attempts total (1 initial + 4 retries) with backoff 2s → 8s → 30s → 90s (~2 minutes end to end). After the last attempt the delivery is marked permanently failed.
  • No-retry codes — any 4xx except 408 and 429 is permanent (fix the receiver, then replay). 5xx, 408, 429, timeouts, and network errors are retried.
  • Replay — Dashboard → Webhooks → Deliveries → Replay, or POST /orgs/:orgId/webhooks/logs/:logId/replay. Replay fires exactly one fresh attempt, not the full 5-attempt chain.
  • No durable outbox — deliveries dispatch in-process (fire-and-forget). Ordering is not guaranteed, and a backend restart mid-retry can drop an in-flight delivery — reconcile critical state rather than relying on at-least-once alone.
  • occurredAt is stamped when we build the envelope, not when the underlying thing happened. For most events those are the same moment. They are not the same when an upstream provider redelivers a notification we already processed: we generate a fresh envelope, so the redelivery carries a newer occurredAt than events that legitimately came after it. Ordering on occurredAt alone will therefore re-apply a stale state change.
  • For the identity-linkage events, order on stateVersion. auth.credential_revoked and auth.identity_reactivated describe opposite transitions of one linkage and both carry a per-linkage counter that only ever increments. Discard anything whose stateVersion is not greater than the state you already hold — >, never >=, since an equal version is the same state.

SSRF protection

Every delivery URL is validated before each attempt (and on manual replay), not just at registration. Blocked: RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16 — which covers cloud-metadata 169.254.169.254), 0.0.0.0/8, CGN (100.64/10), and the IPv6 equivalents (::1, fc00::/7, fe80::/10, IPv4-mapped). Hostnames are DNS-resolved and the resolved address re-checked against the same list, failing closed on a resolution error. Use https:// + a publicly resolvable host.

More