Register a webhook
curl -X POST https://api.sendoracloud.com/api/v1/webhooks \
-H "x-api-key: sk_prod_..." \
-d '{
"url": "https://your-app.com/hooks/sendora",
"events": [
"push.delivered", "push.failed", "push.token_invalidated",
"email.bounced", "email.complained",
"user.signed_up"
],
"active": true
}'Returns the webhook id + the secret (32-byte hex string used to verify HMAC signatures). Save it — it's shown only at create time. Rotate via POST /webhooks/:id/rotate-secret at any time; old secret valid for 24h after rotation for graceful cut-over.
Signature verification
Each request carries X-Sendora-Signature (HMAC-SHA256 hex of raw request body keyed by secret) and X-Sendora-Timestamp (unix-seconds). Reject if older than 5 minutes or signature mismatch.
import crypto from "node:crypto";
function verify(rawBody: Buffer, header: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}Event catalog (canonical)
Single canonical list — same event names used for events: [...] on subscribe and type on the inbound payload.
Push lifecycle
push.send_requested— accepted into queue (pre-policy gates).push.scheduled— deferred toscheduledFor.push.sent— dispatched to APNs / FCM / Web Push.push.delivered— provider receipt confirmed.push.failed— dispatch error stamped on row.push.suppressed— blocked by quiet hours / freq cap / consent.push.cancelled— DELETE on a still-scheduled send.push.opened— recipient tapped notification.push.clicked— recipient tapped a specific action button.push.token_invalidated— APNs / FCM returned Unregistered. Reconcile your local user-token table.
Live Activities
push.live_activity_started— token registered.push.live_activity_updated— server-side ContentState pushed.push.live_activity_ended— server-side end called.push.live_activity_invalidated— APNs / FCM rejected the per-activity token.
Geofences
geofence.entered— user crossed the radius.geofence.exited— user left the radius.geofence.dwelled— user stayed beyonddwellMsthreshold.
email.deliveredemail.bouncedemail.complainedemail.openedemail.clicked
SMS
sms.deliveredsms.failedsms.inbound— user replied (handle STOP / START keywords).sms.stop_received— auto-suppression triggered.
Auth Service
user.signed_upuser.signed_inuser.identifieduser.session_expireduser.password_reset
Workflow / automation
workflow.run_startedworkflow.run_completedworkflow.step_executed— emitted per step with outcome.
Payload envelope
{
"id": "evt_01HX...", // dedup key — same id on retry
"type": "push.delivered",
"createdAt": "2026-05-08T12:00:00Z",
"orgId": "<ORG_UUID>",
"projectId": "<PROJECT_UUID>",
"data": {
// type-specific shape; for push.delivered:
"sendId": "<SEND_UUID>",
"tokenId": "<TOKEN_UUID>",
"userId": "u_123",
"platform": "ios",
"providerMessageId": "...",
"deliveredAt": "2026-05-08T12:00:00.124Z"
}
}Use id as your idempotency key — Sendora may redeliver on a 5xx receiver response, but the id is stable.
id format: evt_ prefix + 26-char Crockford-base32 ULID (e.g. evt_01HX7K8M9N3T0PQR4S5V6W7X8Y). Sortable by creation time without needing to parse createdAt.
X-Sendora-Timestamp clock-skew tolerance: Sendora signs at dispatch time. If your server's clock drifts vs NTP by > 5 min, signature verification will fail even with a correct shared secret. Run NTP. Subscribers can log now() - timestamp on receive to monitor drift; alert when delta > 60s.
Per-event `data` shapes
All events share the envelope above; data differs per type:
// push.sent / push.delivered
{ "sendId", "tokenId", "userId", "platform",
"providerMessageId", "deliveredAt" }
// push.failed
{ "sendId", "tokenId", "userId", "platform", "error", "providerCode" }
// push.suppressed
{ "sendId", "tokenId", "userId", "reason" }
// reason: "quiet_hours" | "frequency_cap" | "consent_revoked" | "no_token"
// push.opened / push.clicked
{ "sendId", "tokenId", "userId", "clickAction", "openedAt" }
// clickAction: action button id from actions[]; null/missing for body taps
// push.token_invalidated
{ "tokenId", "userId", "platform", "reason" }
// reason: "unregistered" | "bad_device_token" | "device_token_not_for_topic"
// | "invalid_argument" | "not_found"
// push.opened — body tap (not on a specific action button)
{ "sendId", "tokenId", "userId", "openedAt", "clickAction": null }
// push.clicked — action button tap
{ "sendId", "tokenId", "userId", "openedAt", "clickAction": "<action-id-from-actions[].id>" }
// Both events stamp openedAt; push.opened has clickAction=null,
// push.clicked has clickAction=<the action id>.
// push.live_activity_*
{ "activityId", "externalId", "userId", "platform", "contentState" }
// geofence.entered / .exited / .dwelled
{ "geofenceId", "userId", "latitude", "longitude", "transitionAt" }
// email.delivered / .bounced / .complained / .opened / .clicked
{ "sendId", "to", "messageId", "providerCode" }
// sms.delivered / .failed / .stop_received
{ "sendId", "to", "providerSid", "error" }
// sms.inbound
{ "from", "to", "body", "providerSid", "receivedAt" }
// user.signed_up / .signed_in / .identified / .session_expired / .password_reset
{ "userId", "email", "method", "ipHash", "userAgent" }
// workflow.run_started
{ "workflowId", "runId", "triggerEvent": { "id", "type", "userId" } }
// workflow.run_completed
{ "workflowId", "runId", "outcome", "durationMs" }
// workflow.step_executed
{ "workflowId", "runId", "stepIndex", "stepType", "outcome", "details" }
// details shape varies by stepType:
// send_email { sendId, to, source: "inline" | "template" }
// send_push { tokenCount, userId }
// send_sms { sendId, to }
// update_profile { userId, traitsChanged: ["plan", "lastEventAt"] }
// webhook { url, status: <HTTP status code, integer>, attemptCount }
// branch { taken: "ifTrue" | "ifFalse", condition }
// wait { reason, resumeAt }
// ai_action { flavor, model, tokensIn, tokensOut, savedTo? }
// savedTo is the trait key (string) the output was merged into —
// e.g. savedTo:"welcomeMessage" → traits.welcomeMessage = <output>.
// Absent if the step's config didn't set saveTo.Action button taps land in push.clicked with clickAction = the action id from your actions[].id. Body taps land in push.opened with clickAction null/missing.
Retry policy
Receiver must respond 2xx within 10 seconds. Anything else triggers retry:
- Schedule — 30s, 2m, 10m, 1h, 6h. 5 retries total. After 6h: marked permanently failed (
workflow.run_completedstep outcomeerrorif from a workflow step). - No retry codes:
400,401,403,404,410,422. Treated as permanent (fix the receiver, manually replay). - Replay — Dashboard → Webhooks → Run Detail → Replay button. Or
POST /webhooks/:id/runs/:runId/replay.
SSRF protection
Outbound URLs are validated before each request: RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16), CGN (100.64/10), and cloud-metadata (169.254.169.254) addresses are blocked. Use https:// + a publicly resolvable hostname.
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).