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",
  "environment": "live",
  "tenant_id": null,
  "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.

environment is live or test. Endpoints are not split by environment — a sok_test_* send fires real webhooks at every matching endpoint you have configured — so this is the field to branch on if your receiver should ignore rehearsals. It is absent on events with no send behind them (a domain verifying, an endpoint being disabled).

tenant_id is the tenant the event belongs to in platform mode, and null for an org-level event. A platform key typically subscribes ONE org-wide endpoint and receives every tenant's events through it; this is what maps a bounce back to the tenant that caused it without re-fetching the message.

With headers:

Content-Type: application/json
X-Sendoka-Event: message.delivered
X-Sendoka-Environment: live           # absent when the event has no environment
X-Sendoka-Tenant-Id: ten_01HN...      # absent on org-level events
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:

  • On a permanent (hard) bounce the recipient address is added to the suppression list (reason: "bounce"), scoped to the sending tenant (or org if platform-root). A transient (soft) bounce flips the message to bounced but suppresses nothing.
  • Subsequent sends to a suppressed 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/emails/{id} for email, GET /api/v1/sms/{id} for SMS (there is no /v1/messages route). On an email bounce, error_message reads Bounce: Permanent (General) — <diagnostic> / Bounce: Transient (...), and provider_response carries the SES bounce notification verbatim, including bounceType ("Permanent" | "Transient" | "Undetermined"), bounceSubType and bouncedRecipients[].diagnosticCode. status_history[] records the transition with the same detail.

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"
}

When it fires: on single-send, only through the SES/SNS callback paths and the scheduled-send cron's terminal-failure fan-out. For batch endpoints (POST /v1/emails/batch, POST /v1/sms/batch), one event per provider-refused item — the rows that come back "status": "failed" with "code": "PROVIDER_ERROR" and a non-null id, carrying the provider's message in data.error. Items rejected before any send attempt (VALIDATION_ERROR, SUPPRESSED, SENDER_NOT_VERIFIED, DLT_*) come back with "id": null and fire nothing. A 100-item batch can therefore emit up to 100 message.failed events.

Not symmetric with single-send: POST /v1/emails and POST /v1/sms record the failed row but fan out no webhook for it.

An inbound SMS STOP reply does not fire message.failed — there is no outbound message failing. It fires suppression.created and stores an inbound row readable via GET /v1/inbound.

Fetch the message to get the provider error: GET /api/v1/emails/{id} / GET /api/v1/sms/{id} expose error_message and provider_response with SES/SNS's error body.

message.complained

Fired when the recipient marked the message as spam (SES complaint notification). These used to fan out as message.bounced. They no longer do — a message.bounced subscription receives bounces only — so subscribe to message.complained explicitly, and to both names 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.

Viewing the message in the Sendoka dashboard never fires it: the dashboard preview is served with the pixel removed and tracked links unwrapped to their destinations, so neither message.opened nor message.clicked can come from your own team looking at a send. GET /v1/emails/{id}?include=body still returns html exactly as sent, pixel and wrapped links included — render that yourself and you will record an open.

message.clicked

Fired when a rewritten link is followed (requires track_clicks on the send). Deduplicated per (message, IP, URL) within a 1-hour bucket, so scanner refetches — Outlook SafeLinks and security sandboxes fetch tracked URLs repeatedly — don't flood your endpoint. Clicks on different links, or the same link an hour later, each fire their own event. The redirect itself is never throttled.

{
  "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".

suppression.created

Fired when an inbound SMS STOP reply (or STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT sent as the whole message — see sms.md for the exact matching rule) creates a suppression. This is how your application learns a recipient opted out — subsequent sends to that number return SUPPRESSED. The row it writes carries reason: "stop" and cannot be deleted through the API (409 SUPPRESSION_PROTECTED); only a fresh opt-in by the recipient lifts it.

{
  "event": "suppression.created",
  "data": {
    "id": "sup_01HN...",
    "channel": "sms",
    "value": "+15555550123",
    "reason": "stop",
    "source": "sms_reply",
    "inbound_sms_id": "ism_01HN...",
    "created_at": "2026-04-22T10:16:00.000Z"
  },
  "timestamp": "2026-04-22T10:16:00.000Z",
  "environment": "live",
  "tenant_id": "ten_01HN..."
}
  • id — the suppression row.
  • value — the opted-out phone number.
  • inbound_sms_id — the stored inbound message that carried the STOP, readable via GET /v1/inbound/{id}; null in the rare case the row could not be stored (the suppression and this event still happen).
  • environment is always live: a test-mode send never reaches a carrier, so no real handset can STOP one.
  • tenant_id follows the prior send the STOP answers when one exists (a shared pooled number suppresses only for the tenant that messaged that recipient), falling back to the receiving number's tenant.

When it fires: once per state change, not per STOP message. A repeat STOP from a number that already carries a stop (or complaint) suppression fires nothing — there is nothing new to announce — and an SNS redelivery of the same notification is deduplicated the same way. A STOP from a number that was suppressed for a removable reason (a prior bounce, or a manual entry) does fire: the row's reason is escalated to stop, which changes it from something you can delete to something nobody can, and that is a state change you need to hear about. Today this event is emitted only for SMS STOP replies (reason: "stop", source: "sms_reply"): bounce- and complaint-driven suppressions are announced by message.bounced / message.complained, and unsubscribes by message.unsubscribed, each of which documents its suppression side effect.

Deliberately not delivered as inbound.sms: the STOP is stored as an inbound row — GET /v1/inbound shows it, which is the recovery path if your endpoint was down — but it is not pushed to inbound.sms subscribers. inbound.sms receivers are typically chat UIs and autoresponders, and an automatic reply to a STOP is exactly the send CTIA forbids. Subscribe to suppression.created to be told about opt-outs.

What counts as a STOP changed, so more replies now reach inbound.sms. The keyword rule is in sms.md: STOP opts out as the first word of the body, the other opt-out words (STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) and the help words (HELP, INFO) only as the whole message. Replies that used to be swallowed as keywords — Can I cancel my 3pm?, when does the sale end?, I need help with my order — are now ordinary inbound messages: they suppress nothing, are not logged as help_received, and are announced as inbound.sms like any other reply. If your handler assumed those words never arrived, it will start seeing them.

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 min, 6h) + jitter — roughly 2, 4, 8, 16, 32, 64, 128 minute gaps — max 8 attempts total (initial + 7 retries), spanning a bit over 4 hours.
  • 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.email Email received at a verified inbound domain (/api/webhooks/inbound-email)
inbound.sms Free-form SMS reply received at a pool number. An opt-out reply is excluded — stored and readable via GET /v1/inbound, but announced through suppression.created; a bare HELP / INFO is answered out of band by the carrier registration and only logged. Both are matched narrowly (see sms.md), so a reply that merely contains cancel, end, quit or help arrives here as normal
job.started An audience blast sent its first message
job.completed An audience blast finished — carries sent / delivered / failed / canceled / suppressed counts
job.canceled A blast was canceled via DELETE /v1/jobs/{id}
dlt.template_approved / dlt.template_rejected India DLT template status poll
usage.alert Monthly usage crossed 80% or 100% of the plan allowance, or of the org spend cap when one is set. Org-level: the envelope carries no environment and no tenant_id. data: { channel: "email" | "sms", period: "YYYY-MM", threshold: 80 | 100, kind: "plan_allowance" | "spend_cap", used, limit }. Fires at most once per (period, channel, kind, threshold) — the dedup ledger, not your endpoint, prevents repeats
tenant.erased A per-tenant erasure run completed (GDPR shop/redact and friends) — fires once, after the final batch. data: { tenant_id, external_ref, messages_redacted, message_events_deleted, contacts_deleted, inbound_deleted }; carries counts, never the erased content. Envelope tenant_id names the erased tenant

domain.unverified carries a dkim_status that is not always SES's

data.dkim_status on domain.unverified is SES's own DKIM attribute status (PENDING | SUCCESS | FAILED | TEMPORARY_FAILURE | NOT_STARTED) plus one value Sendoka synthesizes: IDENTITY_NOT_FOUND, which means the SES identity behind the domain is gone — deleted upstream, or living in a different region. It is a permanent answer, not a transient failure: the domain cannot send until it is re-added, which mints fresh DKIM tokens to publish.

Switch on this field defensively. A handler written against the SES enum alone hits an unhandled value the first time an identity disappears.

No event is aliased onto another live event

Each event is delivered only to endpoints subscribed to that exact name (or *). Two cases where an alias looks tempting and is deliberately not applied:

  • inbound.email is not aliased onto message.sent. That event is live and means "your outbound message was dispatched", so delivering inbound mail there would hand third-party senders, subjects and bodies to a handler that assumes everything arriving is one of its own sends.
  • message.complained is not aliased onto message.bounced. Complaints used to ride on message.bounced, but that name is still live with its own meaning, so aliasing would not preserve a subscription — it would widen one. A bounce-only receiver would start getting a second event type it never asked for, and one that validates its input would fail every complaint delivery, exhaust the retry budget, and eventually have its endpoint auto-disabled — costing it the bounces it did subscribe to. If you handled complaints via message.bounced, add message.complained to your subscription.