Skip to content
Sendora Cloud
Create account
Module deep-dive

Webhooks

Subscribe to events emitted by every Sendora module — push lifecycle, email lifecycle, SMS, Live Activities, geofences, auth, workflow runs. Outbound is HMAC-SHA256-signed with exponential-backoff retries.

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 to scheduledFor.
  • 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 beyond dwellMs threshold.

Email

  • email.delivered
  • email.bounced
  • email.complained
  • email.opened
  • email.clicked

SMS

  • sms.delivered
  • sms.failed
  • sms.inbound — user replied (handle STOP / START keywords).
  • sms.stop_received — auto-suppression triggered.

Auth Service

  • user.signed_up
  • user.signed_in
  • user.identified
  • user.session_expired
  • user.password_reset

Workflow / automation

  • workflow.run_started
  • workflow.run_completed
  • workflow.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_completed step outcome error if 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