Sending

Events & the message timeline

Know exactly what happened to every message — in real time, not in a daily digest.

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, or subscribe to webhooksto receive each event as a POST the instant it's recorded.

This page covers the event model — the lifecycle, the canonical types, and the exact payload schema for each one. For how to receive events over HTTP — subscribing, the request envelope, signature verification, and retries — see the Webhooks page.

The message lifecycle

A message moves through a small, predictable sequence of states. Each transition records an event, and the message's status field always reflects its most recent one. The happy path for a transactional email is:

queuedsentdelivered opened

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 emits every event — opened only fires when open tracking is enabled, and the failure signals may arrive seconds or hours after delivered, depending on the receiving server. The timeline is the source of truth; status is just a convenience snapshot of the latest entry.

Event types

Sendara normalizes every provider signal into one stable set of canonical types, so your code doesn't depend on the quirks of any upstream provider.

TypeTerminal?Meaning
queuedNoSendara accepted the message and queued it for delivery.
sentNoThe message was handed off to the upstream mail provider.
deliveredNoThe receiving mail server accepted the message.
openedNoThe recipient opened the email (requires open tracking; may fire more than once).
bouncedYesDelivery failed. A hard (Permanent) bounce suppresses the recipient automatically.
complainedYesThe recipient marked the message as spam. The recipient is suppressed automatically.
failedYesThe provider rejected the message, or a delivery attempt failed irrecoverably.
A hard bounce or a complaint adds the recipient to your suppression list automatically, so future sends to that address are blocked before they ever reach a provider. Soft (Transient) bounces do not suppress.

Reading the timeline

Fetch a message with GET /v1/messages/{id} to get its current status plus the full ordered events array. Events are returned oldest-first (by occurred_at).

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_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
      "type": "queued",
      "message_id": "msg_a1b2c3",
      "source": "worker",
      "provider_event_id": "evt_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
      "payload": {},
      "occurred_at": "2026-06-14T10:00:00.000Z",
      "created_at": "2026-06-14T10:00:00.001Z"
    },
    {
      "id": "evt_2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e",
      "type": "sent",
      "message_id": "msg_a1b2c3",
      "source": "worker",
      "provider_event_id": "evt_2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e",
      "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",
      "source": "ses",
      "provider_event_id": "ses-delivery-77f2c1",
      "payload": { "recipient": "user@acme.com" },
      "occurred_at": "2026-06-14T10:00:12.481Z",
      "created_at": "2026-06-14T10:00:12.642Z"
    }
  ]
}

The event object

Every entry in the timeline — and, after stripping the webhook envelope, every webhook delivery — has the same top-level shape:

idstringRequired
Unique event identifier, prefixed evt_. Stable across webhook retries of the same event.
typestringRequired
Canonical event type: queued, sent, delivered, opened, bounced, complained, or failed.
message_idstringRequired
The message this event belongs to (msg_…).
sourcestringRequired
Where the event originated: worker (Sendara's send pipeline) or the upstream provider (ses, sns, pinpoint).
provider_event_idstringRequired
The provider's own identifier for the underlying signal. Used to deduplicate provider callbacks.
payloadobjectRequired
Type-specific detail. Shape depends on type — see the schemas below. May be {} when there is no extra detail.
occurred_atstringRequired
RFC 3339 timestamp of when the event happened at its source. The timeline is ordered by this field, ascending.
created_atstringRequired
RFC 3339 timestamp of when Sendara recorded the event. Lags occurred_at slightly for provider callbacks.
The id on a timeline event 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 — an event can be delivered more than once, but its id never changes across retries.

Per-event payload schemas

The payload object carries the detail that is specific to each event type. Fields also vary by source: events from the upstream provider (source: "ses") carry the provider's diagnostics, while synthetic sandbox events (source: "worker") carry a human-readable detail instead. The sections below list every field you may see.

queued & opened

These carry no type-specific detail; payload is an empty object {}. The event's occurred_at is the meaningful signal — for opened, the timestamp of the open.

sent

Emitted when the upstream provider accepts the handoff. The payload surfaces the provider's own message id, which is also written to the message's provider_message_id.

provider_message_idstringRequired
The upstream provider's message identifier. The same value is mirrored onto the message as provider_message_id.
{
  "type": "sent",
  "source": "worker",
  "payload": { "provider_message_id": "0100018f-...-000000" }
}

delivered

Emitted when the receiving mail server accepts the message. For email this arrives as a provider callback (source: "ses") and carries the recipient. For synchronous channels such as outbound webhooks, delivery is recorded at send time (source: "worker") with a detail string.

{
  "type": "delivered",
  "source": "ses",
  "payload": { "recipient": "user@acme.com" }
}

bounced

Emitted when delivery fails. Read bounce_type to tell a hard bounce (Permanent — the address is dead and is suppressed) from a soft one (Transient — a temporary failure like a full mailbox, which does not suppress).

recipientstringOptional
The address that bounced. Present on provider-originated bounces (source: ses).
bounce_typestringOptional
Permanent for a hard bounce (recipient is suppressed) or Transient for a soft bounce (not suppressed).
diagnosticstringOptional
The raw SMTP diagnostic from the receiving server, e.g. "smtp; 550 5.1.1 user unknown".
detailstringOptional
Human-readable reason. Present on synthetic sandbox bounces (source: worker) in place of the provider fields.
{
  "type": "bounced",
  "source": "ses",
  "payload": {
    "recipient": "user@acme.com",
    "bounce_type": "Permanent",
    "diagnostic": "smtp; 550 5.1.1 user unknown"
  }
}

complained

Emitted when a recipient reports the message as spam through their mail client. Complaints always suppress the recipient — stop sending marketing mail to anyone who complains.

recipientstringOptional
The address that filed the complaint. Present on provider-originated complaints (source: ses).
detailstringOptional
Human-readable reason. Present on synthetic sandbox complaints (source: worker).
{
  "type": "complained",
  "source": "ses",
  "payload": { "recipient": "user@acme.com" }
}

failed

Emitted when the provider rejects the message outright (for example an SES Reject) or a delivery attempt fails irrecoverably. Unlike a bounce, a failure usually means the message never left the pipeline.

errorstringRequired
The reason the send attempt failed — a provider rejection (e.g. Reject) or a transient delivery failure.
{
  "type": "failed",
  "source": "worker",
  "payload": { "error": "provider rejected: message content flagged" }
}

Ordering & delivery guarantees

The timeline is built to be safe to consume from either side — polling the message resource or receiving webhooks — without losing or double-counting events.

  • Append-only, ordered by occurred_at. Events are never mutated or removed. The array from GET /v1/messages/{id} is sorted ascending by occurred_at.
  • At-least-once for webhooks. A provider can replay a callback, and a webhook delivery is retried on any non- 2xx response, so the same event may reach your endpoint more than once. Deduplicate on event_id / id and make handlers idempotent.
  • Exactly-once in the timeline. Each underlying provider signal is recorded once — duplicate provider callbacks are collapsed by provider_event_id — so reading the message resource always returns a clean, deduplicated history.
  • No global timestamp ordering across events. Because opens and bounces depend on remote servers, a later occurred_atcan be recorded before an earlier one. Don't assume webhooks arrive in lifecycle order; reconcile against the timeline if exact order matters.

Receiving events as webhooks

To get events pushed to you in real time, register a webhook subscription and Sendara will POST each matching event to your URL, signed so you can verify it came from us. The webhook body wraps the event in a small envelope (event_id, event_type, message_id, the same payload shown above, and timestamps).

The full receiving guide lives on the Webhooks page:

Always verify the signature before trusting a webhook, and treat handlers as idempotent — an event may be delivered more than once. See Verifying signatures for the exact scheme. Do not re-implement verification from this page.

Driving events in the sandbox

Use a test key (sk_test_…) to exercise the whole send-to-webhook path without sending real email or being billed. The recipient address drives the simulated outcome, and your webhooks still fire with the same payload shape as production — so you can build and test your handler end to end before going live:

  • delivered@… → a queued sentdelivered sequence
  • bounced@… → a bounced event (synthetic, source: "worker", with a detail string)
  • complained@… → a complained event
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": "bounced@example.com" },
    "payload": { "subject": "Test", "body_html": "<p>Test</p>" },
    "metadata": { "from_email": "hello@yourdomain.com" }
  }'

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.