Event catalog

Every webhook endpoint can subscribe to a subset of events. This is the complete list with payload schemas.

Envelope

Every event delivered to your webhook looks like:

{
  "event": "message.delivered",
  "data": { ... },
  "timestamp": "2026-04-22T10:14:22.113Z",
  "delivery_id": "whd_01HN..."
}

delivery_id is unique per delivery attempt row — use it as your dedup key. A replayed delivery carries a new delivery_id by design.

With headers:

Content-Type: application/json
X-Sendoka-Event: message.delivered
X-Sendoka-Delivery-Id: whd_01HN...
X-Sendoka-Signature: <hex>            # legacy: HMAC-SHA256(body, secret)
X-Sendoka-Timestamp: 1713820800
X-Sendoka-Signature-V2: <hex>         # HMAC-SHA256("{timestamp}.{body}", secret)

Verify V2 in new integrations — it binds the timestamp so captured payloads can't be replayed outside your tolerance window.

See recipes/verify-webhooks.md for HMAC verification.

Events

message.sent

Fired when Sendoka has successfully handed a message to the provider (SES / SNS). Does not guarantee delivery to the recipient — wait for message.delivered for that.

{
  "event": "message.sent",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "sent"
  },
  "timestamp": "2026-04-22T10:14:22.113Z"
}

When it fires: immediately after the SES/SNS call returns success. For batch endpoints, one event per row. For audience sends, fires from the cron that drains the queue.

message.delivered

Fired when the provider confirms the recipient's mail server accepted the message (SES delivery notification) or the carrier accepted the SMS (SNS DELIVERED).

{
  "event": "message.delivered",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "delivered"
  },
  "timestamp": "2026-04-22T10:14:24.501Z"
}

When it fires: asynchronously, after SES/SNS publishes to our inbound webhook (/api/webhooks/ses, /api/webhooks/sns-sms). Typically seconds after message.sent, but can lag minutes during provider load.

Note: delivered does not mean opened or read. It means the recipient's mail server said yes.

message.bounced

Fired when the recipient's mail server rejected the message, or the carrier rejected the SMS.

{
  "event": "message.bounced",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "bounced"
  },
  "timestamp": "2026-04-22T10:14:27.812Z"
}

Side effects:

  • The recipient address is added to the suppression list (reason: "bounce"), scoped to the sending tenant (or org if platform-root).
  • Subsequent sends to that address return SUPPRESSED / ALL_SUPPRESSED until you remove the entry.

Hard vs soft: the payload only signals bounced. To distinguish, fetch the message: GET /api/v1/messages/:id returns bounce_type: "permanent" | "transient" from the SES notification.

message.failed

Fired when Sendoka could not hand the message to the provider at all — validation failures at SES, account-level throttling, transient provider outage after retry exhaustion.

{
  "event": "message.failed",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "failed"
  },
  "timestamp": "2026-04-22T10:14:22.500Z"
}

Also fires for SMS complaint/STOP keyword paths — the message is marked failed and the number is added to suppressions.

Fetch the message to get the provider error: GET /api/v1/messages/:id exposes provider_response with SES/SNS's error body.

message.complained

Fired when the recipient marked the message as spam (SES complaint notification). Previously these fanned out as message.bounced — subscribe to both if you want every negative signal.

{
  "event": "message.complained",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "bounced"
  },
  "timestamp": "2026-04-22T10:14:27.812Z"
}

status reflects the stored message status, which is bounced for complaints.

Side effects: the recipient is suppressed with reason: "complaint".

message.opened

Fired when the tracking pixel loads (requires track_opens on the send). Deduplicated per (message, ip) within a 1-hour bucket, so inbox-scanner prefetch doesn't flood your endpoint — expect one event per real reader per hour, not per render.

{
  "event": "message.opened",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email"
  },
  "timestamp": "2026-04-22T10:15:02.001Z"
}

Caveat: opens are a lower bound. Image-blocking clients never fire the pixel; privacy proxies (Apple MPP) fire it without a human looking.

message.clicked

Fired when a rewritten link is followed (requires track_clicks on the send). One event per click, including repeat clicks on the same link.

{
  "event": "message.clicked",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "url": "https://example.com/your-original-link"
  },
  "timestamp": "2026-04-22T10:15:09.420Z"
}

message.unsubscribed

Fired when a recipient unsubscribes — either the RFC 8058 one-click POST (source: "one_click") or the hosted landing page (source: "email_link").

{
  "event": "message.unsubscribed",
  "data": {
    "channel": "email",
    "recipient": "reader@example.com",
    "message_id": "msg_01HN...",
    "source": "one_click"
  },
  "timestamp": "2026-04-22T10:16:00.000Z"
}

message_id is null when the unsubscribe token wasn't minted from a specific message.

Side effects: the recipient is suppressed with reason: "unsubscribe".

Event routing

Endpoints choose which events they receive. Dashboard → Webhooks → your endpoint → Events checklist. Or via API:

curl -X PATCH .../webhooks/endpoints/whk_01HN... \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": ["message.bounced", "message.failed"] }'

An endpoint with events: [] gets no events. Use that to pause without deleting.

Delivery semantics

  • At-least-once. Deduplicate on delivery_id (body) or X-Sendoka-Delivery-Id (header).
  • Retry on non-2xx. Exponential backoff min(2^attempt s, 1h) + jitter — roughly 2s, 4s, 8s, 16s gaps — max 5 attempts total (initial + 4 retries).
  • 10-second timeout per attempt. Return 200 before you do slow work.
  • Auto-disable. 10 consecutive exhausted deliveries flip the endpoint to enabled: false; re-enable via PATCH /v1/webhooks/:id and recover with bulk replay. See features/webhooks.md.
  • Ordering not guaranteed. message.delivered can arrive before message.sent under load, since different routes write the delivery row. Dedup on message_id and apply the latest status.

Tenant scoping (platform mode)

In platform mode, webhook endpoints can be bound to a tenant (tenantId column on webhook_endpoints). fanoutWebhookEvent(orgId, event, data, tenantId) filters endpoints:

  • Endpoint's tenantId = null (org-wide) → receives events for any tenant under this org.
  • Endpoint's tenantId = "tnt_X" → receives events only for messages sent under tenant X.

Tenant-bound keys can only create endpoints scoped to their own tenant.

Typed event handling

Drop this minimal type declaration into your project — narrows event.data by event name with no runtime dispatch beyond the switch.

type WebhookEvent =
  | { event: "message.sent"; data: { message_id: string; channel: "email" | "sms"; status: "sent" } }
  | { event: "message.delivered"; data: { message_id: string; channel: "email" | "sms"; status: "delivered" } }
  | { event: "message.bounced"; data: { message_id: string; channel: "email" | "sms"; status: "bounced"; reason?: string } }
  | { event: "message.failed"; data: { message_id: string; channel: "email" | "sms"; status: "failed"; error?: string } }
  | { event: "message.complained"; data: { message_id: string; channel: "email" | "sms"; status: "bounced" } }
  | { event: "message.opened"; data: { message_id: string; channel: "email" | "sms" } }
  | { event: "message.clicked"; data: { message_id: string; channel: "email" | "sms"; url: string } }
  | { event: "message.unsubscribed"; data: { channel: "email" | "sms"; recipient: string; message_id: string | null; source: "one_click" | "email_link" } };

function handle(event: WebhookEvent) {
  switch (event.event) {
    case "message.sent":
      // event.data.status is "sent" here
      break;
    case "message.delivered":
      break;
    case "message.bounced":
      // trigger your own "please update your email" flow
      break;
    case "message.failed":
      break;
  }
}

Infrastructure events

Beyond the message lifecycle above, these fire with the same envelope — data carries the resource id + new status:

Event Fires when
domain.verified / domain.unverified / domain.removed DKIM verification flips / domain deleted
domain.warmup_started Warmup ramp begins for a domain
brand.verified / brand.unverified / brand.removed 10DLC brand registration status
campaign.verified / campaign.unverified / campaign.removed 10DLC campaign registration status
phone_number.verified / phone_number.unverified / phone_number.released Number provisioning status
inbound.sms Free-form SMS reply received at a pool number (STOP/HELP handled separately)
dlt.template_approved / dlt.template_rejected India DLT template status poll

Future events (not yet emitted)

  • inbound.email — email received at verified inbound domain (today inbound mail fans out as message.sent from the inbound-email webhook)

Subscribe to these in your endpoint config now; they're accepted in the API but never fire yet. When we ship them, your endpoint picks them up automatically — no config change needed.