Skip to main content
When you POST /v2/email_messages, Telnyx returns 202 Accepted — not “delivered”. Everything that matters after that point happens asynchronously, over seconds to days, and is reported to you as a stream of events. This page explains the conceptual model behind that stream: what states a message passes through, what causes each transition, which event fires, and what gets recorded. It is a companion to the Webhooks & Events how-to guide, which covers the mechanics of subscribing, verifying signatures, and polling. Read that one to wire things up; read this one to understand what you’re seeing.
The single most important idea on this page: delivery state is tracked per recipient, not per message. A message to three recipients is three independent lifecycles that can end in three different states. Every webhook event you receive describes exactly one recipient.

The two-layer model

Telnyx tracks an outbound email at two levels, and conflating them is the most common source of confusion. The message layer answers “did Telnyx take the job?”. The recipient layer answers “did the mail arrive?”. A message reaches completed when every one of its recipients has reached a terminal state — completed means finished, not delivered. The message layer moves to failed only on a pre-dispatch rejection that stops the whole request, such as a scheduled send that exceeds your daily send limit at fire time. Transport outcomes — delivered, bounced, deferred — are never written onto the message row.
Treat the message-level values as a description of current behavior, not a frozen enum. The authoritative delivery state is always the recipient row, and that is what every delivery webhook and every Email Detail Record describes. Build your logic on the recipient layer.
The recipient status sending exists in the schema and the transition table, but the current outbound path never writes it — recipients go from queued straight to an injection outcome. Don’t build branching that waits to observe it.

The lifecycle

An email is handed to KumoMTA, the outbound mail transfer agent. Telnyx injects the message over HTTP, and KumoMTA reports back asynchronously with log records — one per event, per recipient. Those records drive the state machine.

Per-recipient states

Callbacks are not ordered, so queued also accepts a Delivery, TransientFailure, Bounce, OOB, AdminBounce, or Expiration that arrives before the injection outcome is recorded — going straight to delivered, deferred, bounced, failed, or expired respectively. Terminal states are delivered, bounced, failed, expired, gw_reject, cancelled, and injection_timeout. Once a recipient is terminal it does not regress — a late TransientFailure arriving after a delivered is logged for diagnostics and ignored, never applied. There is exactly one deliberate exception: injection_timeout → sent. See Ambiguous injection below.

The message-level track

The parent message runs on its own short track, and it is not part of the per-recipient state machine above: When the consumer picks the message up it sets the message to sending and records a message-scoped sending event. That event is stored but deliberately does not publish a webhook — the per-recipient webhooks are published separately once the MTA responds. Recipient rows stay queued throughout; they only move once injection is accepted, refused, times out, or a callback arrives.

What each recipient state means

The API validated your request, persisted the message, and returned 202. Nothing has been transmitted yet.Fires: email.queued (also email.scheduled for a future send_at, or email.sandbox in sandbox mode — neither attempts delivery).Not yet billable. Acceptance by our API is not acceptance by the MTA.
The message is now the MTA’s responsibility. It will attempt delivery to the recipient’s MX, retrying on transient failures.Fires: email.sent on a successful HTTP injection. A Reception callback also lands the recipient here but does not publish a duplicate event.Billable from this point — queue acceptance is the billing trigger.sent does not mean the recipient received it. Remote acceptance is reported separately as email.delivered.
KumoMTA got a 250 OK from the recipient’s MX. This is the successful terminal state.Fires: email.delivered. EDR records: delivered_at.Delivered means accepted by the receiving server — it does not guarantee inbox placement. The mail can still be filed as spam by the recipient’s provider after acceptance, which is invisible to SMTP. See Deliverability.
The receiving server returned a 4xx transient response (greylisting, rate limiting, “try again later”). KumoMTA backs off and retries.Fires: email.deferred. EDR records: deferred_at, plus error_evidence carrying code 30002.A recipient can be deferred many times before resolving. deferred_at is stamped on the first deferral and locked — it is not a “most recent attempt” field. Repeated deferrals for the same address escalate an internal soft-bounce counter, which can eventually auto-suppress the address as a hard bounce.
The receiving server refused the message outright (5xx), or an asynchronous bounce report arrived after the fact.Fires: email.bounced. EDR records: bounced_at and error_evidence (code 30001, or 30002 when KumoMTA reports a bounce carrying a 4xx code).Bounces with a 5.1.x or 5.2.x enhanced code (bad or disabled mailbox) auto-suppress the address. Bounces with 5.7.x (sender policy/authentication) do not — the address may be perfectly valid, and the problem is on your side.
The MTA retried until its queue expiry deadline and gave up. Nothing about the recipient address was rejected; the remote server simply never accepted the message in time.Fires: email.bounced — the webhook event type for the Expiration callback is still the canonical bounce type. EDR records: expired_at, expired: true, and error_evidence with code 30005 (Queue expiry, source mta).Billable — the message was accepted into the MTA queue, which is the billing trigger.Expiration is provisionally treated as a hard bounce for suppression purposes.
Distinct from bounced and expired: nothing about the recipient address was rejected and the MTA did not run out of retries. Either an internal/system failure occurred (e.g. the message could not be published to the send pipeline, or the account became ineligible before dispatch), or an operator cancelled the in-flight message (AdminBounce).Fires: email.bounced on the AdminBounce callback path. System failures insert a stored failed event but do not publish an email.failed webhook — see System failures.EDR records: failed_at and error_evidence (code 30003 when an SMTP status is present, otherwise 30099).Because failed is not a recipient-side signal, it does not suppress the address.
The MTA rejected the message at the HTTP injection boundary, before it ever entered a queue. This is pre-queue, so it is never billable.Fires: email.failed. EDR records: error_evidence and errors[] with code 30006 (Gateway rejection, source api).
The injection HTTP call timed out with no response. KumoMTA may or may not have accepted the message.Fires: email.injection_timeout. Not billable. No EDR is published — the outcome is ambiguous, and a later Reception callback would publish one on reconciliation.See Ambiguous injection.
A scheduled or queued message was cancelled before injection. Only queued and sending recipients can be cancelled; anything already accepted by the MTA is past the point of recall and is left untouched. Never billable.

How MTA callbacks map to events

KumoMTA emits typed log records. Each record type maps to exactly one canonical event type, and — separately — drives the recipient state machine.
bounce_category is an internal classification and is not part of any public contract. It is computed during callback handling and drives auto-suppression, but it is not forwarded into the published webhook, and it is never written into the recipient-scoped event row that GET /v2/email_events returns — that row’s payload is built from correlation fields plus the recipient address only. Do not build consumer logic that reads it from either surface — use error_evidence.code to distinguish failure modes.
Expiration diverges three ways, and you need all three to reason about it.
  • The webhook event type is email.bounced — the canonical bounce family type.
  • The recipient status (and therefore the EDR status) is expired, with expired_at and error code 30005.
  • The stored event type returned by GET /v2/email_events is failed — the polling taxonomy has no expired member.
Only AdminBounce maps the recipient to failed. Bounce and OOB map to bounced. If you are building suppression or list-hygiene logic, treat permanent and oob as recipient signals; treat transient and admin as operational signals about your sending, not about the address.
The status field inside a webhook payload is the event type, not the recipient status. An expired recipient produces a payload with "status": "bounced" while its EDR reads "status": "expired". Read error_evidence.code to tell the failure modes apart: 30001 is a hard bounce, 30005 is queue expiry, 30003/30099 are system failures, 30006 is a gateway rejection.

Reception callbacks

email.queued fires from two different places, meaning two different things:
  1. API acceptance — we persisted your request. The recipient stays queued. Pre-queue, not billable.
  2. KumoMTA Reception — the MTA logged reception of the message. This moves the recipient to sent and is billable. No duplicate email.queued event is published — the API-level event already recorded acceptance.
Count sends using email.sent webhooks. A Reception callback updates the recipient to sent and billable, but does not publish a duplicate email.queued event — the API-level email.queued event already recorded acceptance. Counting email.sent is sufficient.Count from the recipient EDR instead — billable: true, or a non-null sent_at — or use GET /v2/email_events/stats, which aggregates at the recipient level. If you derive counts from the raw event stream, count email.sent events — each represents one accepted recipient.

System failures: stored, but not pushed

When the message cannot be handed to the send pipeline at all — a publish failure into the send queue after the request has already been committed, or an account that became ineligible before a scheduled send fires — every still-non-terminal recipient is driven to failed and a stored failed event is written for each one. That path does not publish an email.failed webhook, and it does not publish an EDR eithermark_system_failure updates the recipient rows and inserts stored events directly, without invoking the EDR publisher. For a failure before queue acceptance there may be no EDR at all; for a recipient already accepted, the existing EDR is not updated to reflect the failure. If you rely exclusively on webhooks, these failures are invisible. Reconcile with GET /v2/email_events or the authoritative per-recipient status endpoint — not the EDR. The email.failed webhook is published for a definite injection refusal by the MTA (recipient status gw_reject, error code 30006). System failures also do not blanket-reset billability. The transition is permitted from queued, sending, sent, and deferred; a recipient that had already been accepted into the MTA queue (sent or deferred) keeps its billable: true, because the message really was transmitted. Only pre-queue recipients (queued, sending) end up non-billable.

Complaints are additive, not a state change

email.complained fires when a mailbox provider forwards an ARF spam report (the recipient hit “mark as spam”). By definition this arrives after delivery. It does not move the recipient out of delivered or bounced — the delivery already happened, and that fact is not retracted. The complaint is recorded alongside it and auto-suppresses the address. Because it is a no-op for the state machine, it does not produce a new EDR.

Ambiguous injection: the honest unknown

If the injection call times out, retrying risks delivering the message twice; not retrying risks losing it. Telnyx chooses loss over duplication — the industry-standard trade-off for transactional mail. The recipient moves to the terminal injection_timeout state and email.injection_timeout fires. If KumoMTA did accept the message, its Reception callback arrives moments later and reconciles the recipient forward to sent, from where the normal lifecycle resumes. This is the only terminal-to-non-terminal edge in the entire state machine, and it is safe precisely because the callback is authoritative proof of acceptance.
email.injection_timeout is a webhook-only event type. It is not part of the stored event taxonomy returned by GET /v2/email_events, and the underlying recipient transition is recorded as failed in the persisted event row. If you rely on polling rather than webhooks, ambiguous injections surface as failures.

Event reference

A pre-dispatch system failure produces a stored failed event with no corresponding webhook; its billability depends on whether queue acceptance had already occurred. See System failures.
Billing is derived from the source event, never from the resulting status. A recipient is billable the moment it is proven to have been accepted into the MTA queue — by Reception, Delivery, TransientFailure, Bounce, AdminBounce, OOB, Expiration, or a successful injection.This is why a bounce is still billable: the message was transmitted; the receiver rejected it. And why gw_reject, cancelled, injection_timeout, and pre-queue system failures are never billable — nothing was ever transmitted. A message that fails on our side before reaching the queue costs you nothing.

Bounce categories

Bounce-family callbacks are classified with a bounce_category:
bounce_category is internal. It is computed when the callback is handled and is used to drive auto-suppression, but it is not forwarded into the published webhook payload and not written into the recipient-scoped event row that GET /v2/email_events returns — that payload is built from correlation fields plus the recipient address only. Treat it as absent from both surfaces.Do not write handlers that branch on bounce_category on either surface. Use error_evidence.code (30001 hard bounce, 30005 queue expiry, 30003/30099 system failure, 30006 gateway rejection) together with error_evidence.enhanced_code to distinguish the failure modes. The recipient status (bounced vs expired vs failed) on the authoritative recipient-status endpoint carries the same distinction the category was standing in for.
An out-of-band bounce is the reason a delivered recipient can later become bounced: the receiving server accepted the message, then generated a bounce afterwards. It is the only correction edge out of delivered, and it is genuinely common with forwarding setups and catch-all domains.

The Email Detail Record (EDR)

The Email Detail Record is the durable, per-recipient record of what happened. Where webhooks are a real-time notification you might miss, the EDR is the system of record used for billing, support, and reconciliation. One recipient = one EDR identity. The recipient’s UUID is the stable EDR UUID, so a message to five people produces five EDR identities.

What produces an EDR — and what doesn’t

EDRs are published at three points, not on every event:
  • Sandbox acceptance — a provisional record per recipient (billable forced false); sandbox never enters the send pipeline, so this is its only publication point.
  • Injection outcomes — a provisional record for each recipient the MTA accepted, and a final record for each recipient it refused (gw_reject).
  • Applicable callback transitions — a Reception, Delivery, TransientFailure, Bounce, OOB, AdminBounce, or Expiration that actually moves the recipient forward.
Nothing else publishes one:
  • Engagement events (email.opened, email.clicked, email.unsubscribed) create stored events and webhooks but never invoke the EDR publisher.
  • Complaints on an already-delivered or bounced recipient are state-machine no-ops, so they produce no new record.
  • Ambiguous injection timeouts explicitly get no EDR — the outcome is unknown. If a late Reception reconciles the recipient to sent, that transition publishes the provisional record.
  • Pre-dispatch system failures write stored events and update recipient rows directly; they do not publish an EDR.

Provisional and final records

For a recipient accepted by the MTA, the first record is provisional — a unique-per-recipient rating intent, so a pipeline replay cannot create a second one. Every subsequent lifecycle transition (deferred, delivered, OOB, bounce, expiry) publishes an append-only final record: a new row that carries the same recipient UUID as its stable EDR identity. Records are never rewritten in place.
A refused recipient starts final, not provisional. When the MTA refuses injection, the recipient goes straight to gw_reject and a single final EDR is published for it — there is no provisional record first, because there was never a rating intent to reserve. Do not assume every EDR identity begins with a provisional row.

Lifecycle fields

Every lifecycle timestamp is set-once: it locks on first acceptance, and a later record that tries to overwrite a stamped value is rejected. That is why deferred_at is the first deferral rather than the most recent one — a retry never rewrites it, and it never rewrites sent_at either.
delivery_status is not the lifecycle state. Despite the name, the EDR’s delivery_status field is a fixed platform-level field currently emitted as "not_configured". Read status for the delivery outcome. Filtering or branching on delivery_status will not do what you expect.

Error fields

Two structured fields carry failure detail, and both use the normalized 30xxx error taxonomy — not raw SMTP codes. Raw SMTP codes vary per remote MX, collide across unrelated failure modes, and are absent entirely for failures that never reached SMTP (queue expiry, suppression, gateway rejection). The EDR therefore carries a product-level code and preserves the raw SMTP status alongside it as smtp_status.

The 30xxx taxonomy

error_evidence

Six fields. Required on every error status: bounced, failed, deferred, expired, suppressed, gw_reject. Omitted entirely on success statuses.

errors[]

Seven fields per entry. Non-empty for every error status, empty for success statuses.
code, title, source, and retryable are present on every entry. detail, smtp_status, and enhanced_code legitimately stay null for failures that never reached SMTP — fabricating a response string would be worse than leaving it empty.

Webhook payloads

Webhook payloads are not a projection of the full EDR. They are built from a compact recipient-scoped shape:
On error events (bounced, failed, deferred, expired, suppressed, gw_reject recipient statuses) the payload additionally carries error_evidence and errors[]. Scheduled sends add send_at; ambiguous-injection events add ambiguous_timeout: true.
The raw smtp_code / smtp_response compatibility fields are conditional. They are present only when the failure carries callback-driven SMTP evidence — a Bounce, TransientFailure, OOB, AdminBounce, or Expiration record from KumoMTA that actually reached the wire.Failures raised before SMTP was ever spoken carry no smtp_code or smtp_response at all. Injection refusals (gw_reject, code 30006) build their payload from the normalized error contract only: error_evidence and errors[], with smtp_status and message explicitly null. Treat error_evidence as the field you branch on, and both raw SMTP fields as optional extras that may be absent entirely.
What a webhook payload does not contain. Billing fields (billable, sandbox), EDR lifecycle timestamps (sent_at, delivered_at, bounced_at, expired_at, …), SMTP-host evidence (sending_ip, mx_hostname, mx_ip), tags, and bounce_category are not forwarded into the published webhook.These fields are not uniformly recoverable elsewhere. Billing fields, lifecycle timestamps, and SMTP-host evidence live on the EDR. bounce_category is internal: it is not on the webhook, and it is not written into the recipient-scoped event row that GET /v2/email_events returns. The stored event returned by that endpoint carries a sanitized payload — internal correlation fields are stripped before it is returned — so it is not a full EDR replacement.Null values are not stripped — "name": null is sent as an explicit null. Handle nulls rather than assuming key absence.If you need the full record, use the authoritative per-recipient status endpoint or the EDR — not a recipient_id join against GET /v2/email_events, whose public payload omits the internal recipient identifiers.

Consuming the lifecycle

Three surfaces expose the outbound lifecycle. Pick based on your infrastructure — but note that they do not carry identical fields (see the warning above).
The WebSocket endpoint is inbound-only. is a best-effort real-time stream of inbound email.received events for your account. It does not carry the outbound delivery lifecycle — no email.sent, email.delivered, email.bounced, or any other outbound event is broadcast on it. Use webhooks or polling for outbound.
Webhooks fire per event, per recipient, and each subscription carries an allowlist — you receive only the event types you asked for. A subscription listing only email.bounced gets bounces and nothing else. See the Webhooks & Events guide for subscription setup, payload envelopes, Ed25519 signature verification, cursor pagination, and retry behavior.
The polling API and the webhook allowlist use different taxonomies. Polling filters accept bare event names (delivered, bounced); webhook subscriptions use the prefixed form (email.delivered). The stored taxonomy has no expired or injection_timeout member — both are recorded as failed — and it excludes webhook-only types such as email.injection_timeout entirely. Don’t assume a name valid in one is valid in the other.

Designing a consumer

A few properties of the stream are worth designing around: Events are not ordered. A Delivery callback can arrive before the Reception that logically precedes it. The state machine handles this — queued → delivered is a legal edge — and your consumer should too. Use the timestamps, not arrival order. Events are idempotent, and replays happen. The same callback redelivered produces no second state transition and no duplicate stored event. Your handler should be equally tolerant: key on the event ID. Terminal is not always final. delivered → bounced via an out-of-band report is legitimate and will happen in production. Don’t build logic that assumes a delivered message can never bounce. Scope everything by recipient. A three-recipient message produces three parallel event streams. Aggregating them into one message-level status will lose information the moment one recipient bounces and another delivers. Don’t count from webhooks alone. Some outcomes are stored without a webhook (pre-dispatch system failures), and one webhook type covers several statuses (email.bounced for bounced, expired, and admin-bounced). Reconcile against GET /v2/email_events, the stats API, or the authoritative per-recipient status endpoint. Note that system failures do not publish an EDR, so the EDR alone will not close that gap.

Delivery failures come in two layers

When something goes wrong, the layer tells you where to look.

Layer 1 — API errors (10xxx)

The request was rejected synchronously. No message was created, no event fires, nothing is billed. You get an HTTP error with a Telnyx error code — 10015 for validation failures, 10007 for an unverified domain or unauthorized sender, 10006 for a bad API key. These are your bugs, and they are fixable before you retry. Full list in the Error codes reference.

Layer 2 — Delivery failures (30xxx)

The request succeeded and delivery failed downstream. These surface asynchronously as events, and the detail lives in error_evidence:
  • error_evidence.code — the normalized taxonomy code ("30001", "30002", "30005"), never a raw SMTP code
  • error_evidence.source — where it originated (smtp, mta, api, …)
  • error_evidence.retryable — whether retrying this address could succeed
  • error_evidence.smtp_status — the raw SMTP status, preserved for triage (550, 421)
  • error_evidence.enhanced_code — the RFC 3463 enhanced status code ("5.1.1")
  • error_evidence.message — what the receiving server actually said (null when nothing reached SMTP)
Route on code and retryable, triage on smtp_status and enhanced_code. 30002 is temporary (you’ll see email.deferred, and the MTA is already retrying — do nothing). 30001 is permanent (you’ll see email.bounced, and retrying the same address will fail again). 30005 means we ran out of retry window, and 30003/30006/30099 mean the failure was on our side of the wire, not the recipient’s. Within a hard bounce, the enhanced code tells you whose problem it is. 5.1.x and 5.2.x are recipient problems — bad or full mailbox, and the address is auto-suppressed. 5.7.x is a sender problem — authentication or policy rejection — and the address is deliberately not suppressed, because it is probably fine. A rise in 5.7.x means you should be checking SPF, DKIM, and DMARC, not cleaning your list.