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 the mode of the send behind the event, on every event that
has one: the send's own message.* events, and those that follow from it —
message.opened and message.clicked (a test message's stored body can be
loaded by the key that sent it), message.unsubscribed, and an audience
blast's job.started, job.completed and job.canceled, which carry the mode
of the blast's messages. It is absent on events with no send behind them (a
domain verifying, an endpoint being disabled, usage.alert), and on a job.*
event for a blast that scheduled nothing because every recipient was
suppressed. A synthetic event from
POST /v1/webhooks/{id}/test-fire, sendoka events trigger or the dashboard's
Send test event always carries "environment": "test" and
data.test: true, whichever key fired it; the data you pass is merged over
the fixture but cannot change either marker, so a receiver that drops test
events drops those too.
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",
"recipients": ["dead-mailbox@example.com"],
"partial": false
},
"timestamp": "2026-04-22T10:14:27.812Z"
}
recipients and partial are present on email bounces only. recipients names the addresses this event is about.
Multi-recipient emails. SES reports each destination (to, cc, bcc) of one message separately. A bounce from some but not all of them fires message.bounced with partial: true and leaves the message's own status alone, so a message.delivered for another recipient can still follow. The message moves to bounced only once every destination has bounced or complained. That last event has partial: false. Each recipient's outcome is also written to the message's status_history[] as Recipient bounced: <address> — <reason>. A redelivered SES notification announces nothing twice.
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 reason: GET /api/v1/emails/{id} / GET /api/v1/sms/{id} expose error_message, and provider_response with SES/SNS's callback body. A failure raised by the send call itself is stored as a fixed sentence, not the provider's exception text — see send-failure text.
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",
"recipients": ["reader@example.com"],
"partial": false
},
"timestamp": "2026-04-22T10:14:27.812Z"
}
status is always bounced for complaints. recipients and partial follow the multi-recipient rule for bounces: on a message with several destinations, one recipient's complaint gives partial: true and the message keeps its status.
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",
"environment": "live"
}
environment is the opened message's mode: test for a test-mode message
(it never reached an inbox, so the open came from someone loading its stored
body).
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",
"environment": "live"
}
environment is the clicked message's mode, as for message.opened. Only a
live message sent from your own verified domain redirects. A click on a
test-mode message's link, or on a live message sent from your org's
sandbox sender, still fires this event, but
the person clicking lands on a page that names the destination and asks them to
continue, rather than being redirected: whoever held the key chose that
destination, and our domain must not bounce anyone to it unasked. The page
shows the destination's host on a line of its own (an internationalized name in
its xn-- form) and spells out any invisible or direction-changing characters
in the address as [U+XXXX]. A link whose message has since been deleted gets
the same page, and no event: nothing is left to say who sent it.
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",
"environment": "live"
}
message_id is null when the unsubscribe token wasn't minted from a specific message. environment is the message's mode, or the link's when no message backs it; in practice always live, because redeeming a test-mode link changes nothing and fires nothing (authentication).
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.
inbound.email
Fired once per received message per organization, after it is stored, for a domain with inbound enabled (inbound-messages.md).
{
"event": "inbound.email",
"data": {
"inbound_message_id": "inb_01HN...",
"from": "jane@customer.example",
"from_name": "Jane Doe",
"envelope_from": "0100018f...@amazonses.com",
"to": "support@acme.com",
"subject": "Invoice question",
"attachments": [
{
"index": 0,
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 48213,
"disposition": "attachment",
"content_id": null,
"downloadable": true
}
],
"verdicts": { "spam": "PASS", "virus": "PASS", "spf": "PASS", "dkim": "PASS", "dmarc": "PASS" }
},
"timestamp": "2026-04-22T10:16:00.000Z",
"tenant_id": null,
"delivery_id": "whd_01HN..."
}
from/from_name— the message's From header. Until October 2026fromcarried the SMTP envelope sender instead, which for most mail is the sending service's bounce address; that value is nowenvelope_from.subject— decoded;nullwhen the message has none.attachments— metadata only,[]when there are none. Download one withGET /v1/inbound/{inbound_message_id}/attachments/{index}, which returns a link valid for 10 minutes;downloadable: falsemeans the contents were not kept (past 50 per message, or the message's virus verdict isFAIL).verdicts— the spam, virus, SPF, DKIM and DMARC results for the message, eachPASS,FAIL,GRAY,PROCESSING_FAILED,DISABLEDornull. None of them stops delivery to you; see inbound-messages.md.- Fired once the body and attachments have been read, which can be a few
seconds after
GET /v1/inboundfirst lists the message. - No bodies and no links in the payload: fetch
GET /v1/inbound/{inbound_message_id}fortext,htmlandheaders. - Scoped to the receiving domain's tenant; no
environment(there is no send behind it). An SNS redelivery of the same message is deduplicated and fires nothing.
verification.delivered / verification.failed
The delivery receipt for an OTP code sent by POST /v1/verifications. A code
is not a message (its body is the credential, so no messages row is
written), so it never fires message.*; these two events are the only signal
that a code did or did not reach the handset or mailbox.
{
"event": "verification.failed",
"data": {
"verification_id": "ver_01HN...",
"channel": "sms",
"delivery_status": "failed",
"error": "TEXT_UNREACHABLE",
"occurred_at": "2026-04-22T10:14:24.501Z"
},
"timestamp": "2026-04-22T10:14:25.002Z",
"environment": "live",
"tenant_id": null,
"delivery_id": "whd_01HN..."
}
verification.delivered: the SES Delivery notification, or the carrier's delivered receipt.data.errorisnull.verification.failed:data.delivery_statusisbounced(a hard email bounce, or an invalid number) orfailed(a soft email bounce, a carrier filter, an expiry, an unreachable handset, an SES reject).data.errorcarries the provider's reason.- At most one per verification: the first terminal receipt wins, and a redelivered or late receipt fires nothing.
- Live only. A test key contacts no provider, so no receipt exists. A send the
provider refused outright already answered the
POSTwith502and fires nothing. - The destination is never in the payload. Fetch
GET /v1/verifications/{verification_id}for the masked destination, status and attempts.
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. Only live deliveries (and events with no environment) count: a delivery whoseenvironmentistest— a test send's event, a test-fire, a replay of either — neither extends the streak nor clears it, so a receiver that refuses test events cannot get a live endpoint disabled. 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; recipients?: string[]; partial?: boolean } }
| { 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"; recipients?: string[]; partial?: boolean } }
| { 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 domain with inbound enabled — see inbound.email for the payload |
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. tenant_id (and the stored row's tenant) follows the newest live send from that number to the handset in the last 90 days, the same rule as suppression.created, falling back to the receiving number's tenant |
job.started |
An audience blast sent its first message. Envelope environment is the blast's mode (test for a test-key blast) |
job.completed |
An audience blast finished — carries sent / delivered / failed / canceled / suppressed counts. Envelope environment as for job.started; absent when every recipient was suppressed and nothing was scheduled |
job.canceled |
A blast was canceled via DELETE /v1/jobs/{id}. Envelope environment as for job.started |
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. The spend cap is org-wide, so a spend_cap threshold fires once per period whichever channel crosses it; channel names the send that did |
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.