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 two-layer model
Telnyx tracks an outbound email at two levels, and conflating them is the most common source of confusion.completed when every one of its recipients has reached a terminal state — completed means finished, not delivered.
The parent status does not encode every rejection reason. A scheduled send that exceeds the account daily limit at fire time remains failed; a sender-domain graduation rejection terminalizes the recipients and can then roll the parent to completed. Transport outcomes — delivered, bounced, deferred — are never written onto the message row. Use recipient state and events for the authoritative outcome.
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, soqueued 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: A scheduled graduation failure happens at fire time, after the original202 response. The worker creates no queued event and never hands the message to the MTA. A ramp rejection uses reason sender_domain_graduation_rejected; a fail-closed graduation dependency failure that exhausts its retries uses sender_domain_graduation_unavailable. Either outcome terminalizes each recipient as non-billable failed; once every recipient is terminal, normal rollup can leave the parent message completed. Use recipient status and recipient-scoped email.failed events—not the parent status alone—to identify the outcome.
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
queued — accepted, not yet handed to the MTA
queued — accepted, not yet handed to the MTA
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.sent — KumoMTA accepted the recipient into its queue
sent — KumoMTA accepted the recipient into its queue
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.delivered — the receiving server accepted the message
delivered — the receiving server accepted the message
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.deferred — temporary failure, will retry
deferred — temporary failure, will retry
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.bounced — permanent rejection by the receiver
bounced — permanent rejection by the receiver
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.expired — KumoMTA exhausted its retry window
expired — KumoMTA exhausted its retry window
event_type: "email.bounced" with additive canonical_event_type: "email.expired". 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.failed — non-delivery caused by us or an operator
failed — non-delivery caused by us or an operator
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: legacy email.bounced with canonical email.failed on the AdminBounce callback path. Outbound system failures insert stored recipient-scoped failed events and publish them as email.failed after the transaction commits, using the same stored event UUIDs returned by polling. Scheduled graduation failures carry payload.error: "system_failure" and reason sender_domain_graduation_rejected or sender_domain_graduation_unavailable. 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.gw_reject — KumoMTA refused the injection
gw_reject — KumoMTA refused the injection
event_type: "email.failed" with canonical_event_type: "email.gw_reject". EDR records: error_evidence and errors[] with code 30006 (Gateway rejection, source api).injection_timeout — we genuinely do not know
injection_timeout — we genuinely do not know
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.cancelled — withdrawn before it was queued
cancelled — withdrawn before it was queued
email.cancelled after commit when a matching domain webhook is configured. Recipient-scoped stored event UUIDs and derived message fan-out UUIDs match account polling.How MTA callbacks map to events
KumoMTA callbacks drive recipient state and event publication. Webhook subscriptions match the legacyevent_type; the additive canonical_event_type identifies the precise outcome.
AdminBounce row renders email.failed, while its webhook retains email.bounced. Both identify the canonical outcome as email.failed.
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.- The legacy webhook
event_typeisemail.bounced;canonical_event_typeisemail.expired. - The recipient status (and therefore the EDR
status) isexpired, withexpired_atand error code30005. - New recipient-scoped events store
expired. The per-message endpoint retains that bare name in deprecatedtype; account polling returns the two prefixed name fields. Older rows are translated only when recorded evidence identifies the outcome.
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.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:
- API acceptance — we persisted your request. The recipient stays
queued. Pre-queue, not billable. - KumoMTA
Reception— the MTA logged reception of the message. This moves the recipient tosentand is billable. No duplicateemail.queuedevent is published — the API-level event already recorded acceptance.
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 and webhook publication
When a message cannot be handed to the send pipeline, every still-non-terminal recipient is driven tofailed and a stored, recipient-scoped failed event is written. These failures do not publish an EDR — mark_system_failure updates 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.
After the marking transaction commits, the caller publishes each stored event as a recipient-scoped email.failed webhook using the same UUID returned by polling. This applies to queue-publication failures, scheduled account-ineligibility failures, and scheduled graduation failures. Cancellation and scheduled-send daily-limit failures similarly publish after commit as email.cancelled and email.daily_limit_exceeded. Publication is a post-commit side effect; if webhook delivery fails, the stored event remains available through GET /v2/email_events and the per-recipient status endpoint.
Graduation uses two bounded reasons in the event payload:
sender_domain_graduation_rejected— the scheduled send exceeded the current domain ramp at fire time.sender_domain_graduation_unavailable— a fail-closed graduation dependency failure exhausted its scheduled-worker retries.
email.queued or hands the message to the MTA. A definite MTA injection refusal also publishes email.failed, but its recipient status is gw_reject with normalized error code 30006, not either graduation reason.
System failures 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. Graduation rejection happens before queue acceptance, so those recipients are 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 terminalinjection_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.
injection_timeout; polling returns email.injection_timeout in both event_type and canonical_event_type. Older failed rows retain their stored name and are never guessed into a sharper outcome. The email API emits the timeout name, but the domains webhook subscription allowlist does not currently accept email.injection_timeout; use polling for this outcome.Event reference
failed event and publishes it as an email.failed webhook after the marking transaction commits; if webhook delivery fails, the same stored event remains available through GET /v2/email_events. Cancellation and scheduled-send daily-limit failures similarly publish email.cancelled and email.daily_limit_exceeded after commit. Billability depends on whether queue acceptance had already occurred. See System failures.
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 abounce_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.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 (
billableforced 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, orExpirationthat actually moves the recipient forward.
- Engagement events (
email.opened,email.clicked,email.unsubscribed) create stored events and webhooks but never invoke the EDR publisher. - Complaints on an already-
deliveredorbouncedrecipient 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
Receptionreconciles the recipient tosent, 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.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
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 assmtp_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: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.
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.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).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.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.
email.-prefixed names, including gw_reject, injection_timeout, and expired. Responses carry legacy event_type and additive canonical_event_type; the per-message endpoint also retains deprecated bare type. Subscriptions continue matching legacy names. The failed filter preserves historical coverage by including the three sharp stored names; bounced does not expand to expired. See Event names and compatibility.Designing a consumer
A few properties of the stream are worth designing around: Events are not ordered. ADelivery 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.
Reconcile webhooks with polling. Webhook publication follows commit, so an interruption can leave a stored event without a delivered callback. Some outcomes are intentionally stored without a webhook (for example, the message-scoped sending event), and legacy email.bounced covers several outcomes; read canonical_event_type. Reconcile against GET /v2/email_events, the stats API, or the authoritative per-recipient status endpoint. System failures normally publish recipient-scoped email.failed webhooks but 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 codeerror_evidence.source— where it originated (smtp,mta,api, …)error_evidence.retryable— whether retrying this address could succeederror_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)
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.
Related
- Webhooks & Events — subscribe, verify signatures, poll, paginate
- Error codes — the full API error reference
- Deliverability — reputation, authentication, and bounce handling
- Suppressions — how auto-suppression works and how to manage the list
- Send an email — the send API and its fields