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 tobouncedbut suppresses nothing. - Subsequent sends to a suppressed address return
SUPPRESSED/ALL_SUPPRESSEDuntil 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 viaGET /v1/inbound/{id};nullin the rare case the row could not be stored (the suppression and this event still happen).environmentis alwayslive: a test-mode send never reaches a carrier, so no real handset can STOP one.tenant_idfollows 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) orX-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 viaPATCH /v1/webhooks/:idand recover with bulk replay. See features/webhooks.md. - Ordering not guaranteed.
message.deliveredcan arrive beforemessage.sentunder load, since different routes write the delivery row. Dedup onmessage_idand 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.emailis not aliased ontomessage.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.complainedis not aliased ontomessage.bounced. Complaints used to ride onmessage.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 viamessage.bounced, addmessage.complainedto your subscription.