Every message keeps an ordered event timeline: an append-only log of what happened to it, from the moment Sendara accepted it through delivery, opens, and any bounces or complaints. You can read the timeline on demand from the message resource. Provider and worker delivery events can also be sent to a subscribed webhook as a POST. Pre-dispatch suppressed and unsubscribed events remain on the timeline and are not webhook-dispatched.
email.received webhook schema appears below. For how to receive dispatched events over HTTP, including subscribing, the request envelope, signature verification, and retries, see the Webhooks page.The message lifecycle
A message moves through a short sequence of states. Each transition records an event, and the status field of the message carries the most recent one. Sendara sets the first status, queued, when it accepts the send. queued is a status only. Sendara writes no queued event, so the timeline starts at sent. The normal path for a transactional email is:
sent → delivered → opened → clicked
Any of three terminal failure signals can interrupt it: failed if the provider rejects the handoff, bounced if the receiving server refuses the message, and complained if the recipient later reports it as spam. Not every message produces every event. Amazon SES emits opened only when open tracking is on. A failure signal can arrive seconds or hours after delivered, which depends on the receiving server. The timeline is the authority. The status field is a snapshot of the newest entry.
Event types
Sendara normalizes every provider signal into one stable set of canonical types, so your code does not depend on the quirks of any upstream provider.
| Type | Terminal? | Meaning |
|---|---|---|
| sent | No | Sendara handed the message to the upstream mail provider. |
| delivered | No | The receiving mail server accepted the message. |
| opened | No | The recipient opened the email. Open tracking may fire more than once. |
| clicked | No | The recipient clicked a tracked link in the email. Click tracking may fire more than once. |
| delivery_delayed | No | SES temporarily delayed delivery. A delivered or bounced event can still follow. |
| bounced | Yes | Delivery failed. A hard (Permanent) bounce suppresses the recipient automatically. |
| complained | Yes | The recipient marked the message as spam. Sendara suppresses the recipient automatically. |
| failed | Yes | The provider rejected the message, or a delivery attempt failed irrecoverably. |
| suppressed | Yes | Sendara blocked the recipient before dispatch because the address is suppressed. Timeline only; not webhook-dispatched. |
Transient) bounces do not suppress.Read the timeline
Fetch a message with GET /v1/messages/{id} to get its current status plus the full ordered events array. Sendara returns the events oldest-first (by occurred_at).
curl https://api.sendara.dev/v1/messages/msg_a1b2c3 \
-H "Authorization: Bearer sk_live_xxx"The response embeds the timeline directly on the message:
{
"id": "msg_a1b2c3",
"channel": "email",
"status": "delivered",
"message_type": "transactional",
"idempotency_key": "rcpt_9f21",
"provider_message_id": "0100018f-...-000000",
"created_at": "2026-06-14T10:00:00.000Z",
"updated_at": "2026-06-14T10:00:12.642Z",
"events": [
{
"id": "evt_2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e",
"type": "sent",
"message_id": "msg_a1b2c3",
"account_id": "acc_8f21c4",
"source": "worker",
"provider_event_id": "wkr_9d1c0a7f2e5b8c3d4a6f1b9e2c7d0a5f",
"payload": { "provider_message_id": "0100018f-...-000000" },
"occurred_at": "2026-06-14T10:00:00.412Z",
"created_at": "2026-06-14T10:00:00.420Z"
},
{
"id": "evt_3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f",
"type": "delivered",
"message_id": "msg_a1b2c3",
"account_id": "acc_8f21c4",
"source": "ses",
"provider_event_id": "0100018f-...-000000-000000",
"payload": {
"notificationType": "Delivery",
"mail": { "messageId": "0100018f-...-000000" },
"delivery": { "recipients": ["[email protected]"], "smtpResponse": "250 2.0.0 OK" }
},
"occurred_at": "2026-06-14T10:00:12.481Z",
"created_at": "2026-06-14T10:00:12.642Z"
}
]
}The event object
Every entry in the message timeline has the same top-level shape. For events that are dispatched, the webhook envelope reuses the event id, type, message id, account id, payload and timestamps:
id is the same value Sendara sends as the Sendara-Event-Id header and the event_id field on the matching webhook. Use it as the idempotency key in your webhook handler. Sendara can deliver an event more than once, but its id never changes across retries.Per-event payload schemas
The payload object carries the detail for the event. The shape follows the source, not the type. Read this difference before you write a parser.
source: "ses". Sendara stores the SES notification exactly as SES sent it. The keys are camelCase and the object is nested, for examplebounce.bouncedRecipients[0].emailAddress.source: "worker". Sendara writes its own flat object with snake_case keys, such asprovider_message_id,error, ordetail. A sandbox event always takes this shape.
opened
An opened event is a raw SES Open notification. Read the nested open object for the event timestamp, IP address and user agent. Mail clients and image proxies can produce more than one open, so do not treat this as proof that a person read the message.
{
"type": "opened",
"source": "ses",
"payload": {
"notificationType": "Open",
"mail": { "messageId": "0100018f-...-000000" },
"open": {
"timestamp": "2026-07-01T10:21:00.000Z",
"ipAddress": "192.0.2.1",
"userAgent": "Mozilla/5.0"
}
}
}clicked
A clicked event is a raw SES Click notification. The nested click object identifies the tracked URL and carries the timestamp, IP address and user agent reported by SES.
{
"type": "clicked",
"source": "ses",
"payload": {
"notificationType": "Click",
"mail": { "messageId": "0100018f-...-000000" },
"click": {
"timestamp": "2026-07-01T10:22:00.000Z",
"ipAddress": "192.0.2.1",
"userAgent": "Mozilla/5.0",
"link": "https://acme.com/account"
}
}
}sent
Sendara records this event when the upstream provider accepts the handoff. The payload surfaces the provider's own message id. Sendara writes the same id to the message's provider_message_id.
{
"type": "sent",
"source": "worker",
"payload": { "provider_message_id": "0100018f-...-000000" }
}delivery_delayed
SES emits DeliveryDelay when it has not yet delivered the message but can continue retrying. This event is not terminal. Read the delayed-recipient status and diagnostic code, then wait for a later delivered or bounced event.
{
"type": "delivery_delayed",
"source": "ses",
"payload": {
"notificationType": "DeliveryDelay",
"mail": { "messageId": "0100018f-...-000000" },
"deliveryDelay": {
"delayType": "TransientCommunicationFailure",
"expirationTime": "2026-07-02T10:15:00.000Z",
"delayedRecipients": [
{
"emailAddress": "[email protected]",
"status": "4.4.1",
"diagnosticCode": "smtp; 421 4.4.1 connection timed out"
}
]
}
}
}delivered
Sendara records this event when the receiving mail server accepts the message. For email it arrives as an SES callback (source: "ses") and it carries the SES delivery object. For a synchronous channel such as an outbound webhook, Sendara records the delivery at send time (source: "worker") with a detail string.
{
"type": "delivered",
"source": "ses",
"payload": {
"notificationType": "Delivery",
"mail": { "messageId": "0100018f-...-000000" },
"delivery": {
"recipients": ["[email protected]"],
"smtpResponse": "250 2.0.0 OK"
}
}
}bounced
Sendara records this event when delivery fails. Read bounce.bounceType to separate a hard bounce (Permanent: the address does not exist, and Sendara suppresses it) from a soft bounce (Transient: a temporary fault such as a full mailbox, and Sendara does not suppress it).
{
"type": "bounced",
"source": "ses",
"payload": {
"notificationType": "Bounce",
"mail": { "messageId": "0100018f-...-000000" },
"bounce": {
"bounceType": "Permanent",
"bounceSubType": "General",
"bouncedRecipients": [
{
"emailAddress": "[email protected]",
"action": "failed",
"status": "5.1.1",
"diagnosticCode": "smtp; 550 5.1.1 user unknown"
}
]
}
}
}complained
Sendara records this event when a recipient reports the message as spam in their mail client. A complaint always suppresses the recipient.
{
"type": "complained",
"source": "ses",
"payload": {
"notificationType": "Complaint",
"mail": { "messageId": "0100018f-...-000000" },
"complaint": {
"complaintFeedbackType": "abuse",
"complainedRecipients": [{ "emailAddress": "[email protected]" }]
}
}
}failed
Sendara records this event when the provider rejects the message outright (for example an SES Reject), or when a delivery attempt fails irrecoverably. Unlike a bounce, a failure usually means the message never left the pipeline.
{
"type": "failed",
"source": "worker",
"payload": { "error": "provider rejected: message content flagged" }
}The order and the delivery guarantees
Poll the message resource for the complete outbound timeline. Webhooks provide at-least-once delivery for supported provider and worker events; pre-dispatch suppressed and unsubscribed timeline events are not dispatched.
- Append-only, ordered by
occurred_at. Sendara never changes an event and never removes one. Sendara sorts the array fromGET /v1/messages/{id}ascending byoccurred_at. - At-least-once for webhooks. A provider can replay a callback. Sendara retries a webhook delivery on any non-
2xxresponse, so the same event may reach your endpoint more than once. Deduplicate onevent_id/idand make your handler idempotent. - Exactly-once in the timeline. Sendara records each underlying provider signal once. Sendara collapses a duplicate provider callback by
provider_event_id, so the message resource always returns a clean, deduplicated history. - No global timestamp ordering across events. Because opens and bounces depend on remote servers, Sendara can record a later
occurred_atbefore an earlier one. Do not assume webhooks arrive in lifecycle order. Reconcile against the timeline if exact order matters.
Receive an event as a webhook
To receive supported provider and worker events in real time, register a webhook subscription. Sendara then POSTs each matching dispatched event to your URL. Sendara signs each request, so you can verify that it came from us. The webhook body wraps the event in a small envelope (event_id, event_type, message_id, the source-specific payload, and timestamps). Router-only suppressed and unsubscribed events stay on the message timeline.
The full receiving guide lives on the Webhooks page:
- Create a subscription and choose which event types to receive.
- The request envelope & headers cover the four
Sendara-*headers on every request. - Verifying signatures covers the HMAC-SHA256 scheme with copy-paste Node and Python snippets.
- Retries & delivery guarantees explains exponential backoff over a 24-hour window.
Inbound email webhook payload
email.received is a separate inbound webhook event. It is delivered to matching subscriptions, but it is never stored as an outbound message_events row, message-timeline event, or message status. Sendara builds the envelope from the inbound email id: event_id is that id with an evt_ prefix, and message_id is the id itself. So message_id repeats payload.id and carries an inb_ value, not an empty string. Do not branch on an empty message_id to detect an inbound event. Match on event_type instead. The payload contains the parsed sender and recipients, subject, text and HTML bodies, security verdicts, attachment metadata, and threading fields.
message_id as an empty string. The subscription payload below is the one an email.received subscription receives.{
"event_id": "evt_inb_a1b2c3",
"event_type": "email.received",
"message_id": "inb_a1b2c3",
"account_id": "acc_a1b2c3",
"payload": {
"id": "inb_a1b2c3",
"domain": "inbound.acme.com",
"recipient": "[email protected]",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Question about my order",
"text": "Can you help?",
"spam_verdict": "PASS",
"virus_verdict": "PASS",
"attachments": [],
"received_at": "2026-07-01T10:25:00Z"
},
"occurred_at": "2026-07-01T10:25:00Z",
"created_at": "2026-07-01T10:25:01Z"
}Drive events in the sandbox
Use a test key (sk_test_…) to exercise the whole send-to-webhook path. Sendara sends no real email and bills you nothing. The recipient address drives the simulated outcome. Your webhooks still fire with the same payload shape as production, so you can build and test your handler before you send real email:
delivered@…→ asent→deliveredsequencebounced@…→ asent→bouncedsequence (synthetic,source: "worker", with adetailstring)complained@…→ asent→complainedsequence
curl https://api.sendara.dev/v1/send \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"idempotency_key": "test_bounce_1",
"destination": { "email": "[email protected]" },
"payload": { "subject": "Test", "body_html": "<p>Test</p>" },
"metadata": { "from_email": "[email protected]" }
}'Then poll GET /v1/messages/{id} to watch the timeline populate, or point a subscription at a tunnel (such as a local webhook inspector) to see the live deliveries. See the Sandbox & test sends page for the full list of simulated outcomes and verified test recipients.