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",
"events": [
"auth.user_upgraded", "auth.device_takeover", "auth.user_merged",
"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: it is shown only in this create response and never again. There is no rotate endpoint; to change the secret, delete the endpoint and create a new one.
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 small, fixed set the platform emits server-side, with stable type strings and payload shapes. These are the only events Sendora originates for you. Today the full list is five — four identity events plus one security event:
auth.user_upgraded— an anonymous user converted to a real account in place (theirsubis preserved). Fires on email+password upgrade and the anon→social link-in-place path ({ link: true }, ADR-025). Payload:{ userId, email, isAnonymous: false, projectId }.auth.user_merged— an anonymous user was merged into an existing account via an explicitmerge()call; the anonymoussubis retired and the canonical id becomes the target's. Re-key device data on the new id. Payload:{ mergedUserId, deletedAnonymousId, eventsReassigned, profilesReassigned, projectId }. See Identity in your backend.auth.device_takeover— a previously-anonymous device signed in to an existing account (email/password, social, magic-link, OTP, or passkey), or an anon→social collision occurred; the anonymoussubis retired and its device reassigned. Re-key device dataanonUserId → identifiedUserId. Payload:{ anonUserId, identifiedUserId, projectId },context: { source: "auth-service" }. See Identity in your backend.auth.user_deleted— a user account was permanently purged (self-service deletion after any grace period, admin deletion, SCIM deprovision, or Apple-ID account deletion). Erase or anonymize your own copy. Payload:{ userId, email, projectId }. Note: an operator-run GDPR bulk-erase does not fire this — don't rely on it as your only erasure signal.auth_service.signing_key_rotated— the auth-service JWT signing key rotated; refresh your cached JWKS. Payload:{ newKid, previousKid, jwksUrl, overlapWindowMs, previousKeyExpiresAt },context: { source: "auth-service" }.
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>",
"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>"
},
"context": { "source": "auth-service" } // type-specific metadata, often {}
}The envelope is identical for every event type: id, type, orgId, occurredAt, properties, and context. There is no top-level projectId — where a project scope applies, it lives inside properties.
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,
"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
4xxexcept408and429is 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 (order by
occurredAt), and a backend restart mid-retry can drop an in-flight delivery — reconcile critical state rather than relying on at-least-once alone.
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
- /docs/api — retry policy table.
- /docs/automation — webhook step config (workflow-driven webhooks; same retry + SSRF posture).
- /docs/push — Expo webhook bridge (concrete Node + Express + expo-server-sdk handler).