queued to sent to delivered (or bounced/failed), plus engagement signals like opened and clicked. Your email domains also emit lifecycle webhooks (email_domain.verified, email_domain.suspended, …). This guide covers webhooks, account event polling, and per-message event history.
Polling is a first-class option for stored outbound message events: if you don’t have a publicly reachable HTTPS endpoint — common for agents, batch jobs, and on-prem services — you can query GET /email_events with no webhook infrastructure. Polling and webhooks overlap, but their subscription/filter coverage and payload details differ — see Event schemas by surface.
Prerequisites
- A Telnyx account with an email domain configured and verified
- Your API key
- For webhooks: a publicly accessible HTTPS endpoint (or ngrok for local development) and your public key for signature verification
Event model
An email message moves through a series of states. Each transition emits an event carrying the event type, a timestamp, the email message ID, and an optional payload with delivery/engagement details.Lifecycle of a message
Outbound emails typically progress:email.bounced is not limited to a permanent remote rejection. Four distinct MTA outcomes — an ordinary bounce, a queue expiration, an administrative bounce, and an asynchronous out-of-band (OOB) bounce — all publish the same email.bounced webhook. The webhook event type alone does not tell you which one happened. Read payload.error_evidence.code (the 30xxx normalized error code) to distinguish them. The recipient’s authoritative status on the recipient row may differ from the webhook’s status field, which carries the event slug.expired, while an administrative bounce resolves it to failed — both still publish email.bounced:
Event names and compatibility
Outbound event envelopes carry two names:event_typepreserves the legacyemail.-prefixed name. Webhook subscriptions match this field.canonical_event_typeis additive and identifies the precise outcome, also prefixed withemail..- The per-message events endpoint also retains deprecated
type, whose value remains the bare stored name. Existing rows retain their stored names; canonical rendering of old rows uses recorded evidence and never guesses a sharper outcome for a legacyfailedrow.
Subscribe to
email.bounced to receive expirations and inspect canonical_event_type for email.expired. Neither email.expired nor email.gw_reject is a subscription value. email.injection_timeout is emitted by the email API but is absent from the current domains subscription allowlist; use polling to observe that outcome. Administrative bounce webhooks retain email.bounced with canonical email.failed; polling renders the legacy name from the stored row (email.failed for new recipient-scoped admin failures).
After delivery, engagement events fire as recipients interact:
email.received event.
Separately, your email domains emit lifecycle events — email_domain.created when a domain is registered, email_domain.verified when its DNS records validate, and email_domain.degraded / email_domain.suspended / email_domain.deleted as its health changes.
Webhook event-type reference
Email webhook subscriptions accept 22 event types: 15 outbound/trackingemail.* types, email.received from the inbound pipeline, and 6 email_domain.* lifecycle types. 21 are published; only email.sending is accepted but intentionally not published. The table below lists the published subscription values.
email.sending is accepted by the subscription allowlist but never published as a webhook. The service records a stored sending event when a message is handed to the outbound MTA, but explicitly suppresses its webhook publication (skip_webhook: true). You can subscribe to it, but you will never receive an email.sending callback. The sending event is returned by the Events API — see Consume via polling. Only 21 of the 22 allowed event types are actually delivered as webhooks.Message lifecycle (email.*)
Engagement & tracking (email.*)
Domain lifecycle (email_domain.*)
The six
email_domain.* events come from the domains service. GET /email_events returns stored outbound message events, including cancellation, daily-limit, and system failures; it does not backfill domain lifecycle events or email.received. Cancellation, daily-limit, and system-failure webhook publication runs after the event transaction commits, when the domain has a matching subscription. Event IDs match across webhook delivery and account polling for stored recipient events and for the queued, sending, sandbox, cancelled, and daily_limit_exceeded message-event fan-outs. Scheduled is the exception: polling returns one message-scoped stored row, while webhooks derive one event ID per recipient, so scheduled webhook IDs do not appear in polling.Consume via webhooks
Webhooks push events to your HTTPS endpoint in real time. Email webhooks are scoped to a domain: you create a subscription on a specific email domain, and it fires only for the event types you allowlist on that subscription.Webhook payload envelope
Delivery webhooks are recipient-scoped, not message-scoped. One recipient produces one webhook event carrying one status and one address. The payload never contains arrays of
to/cc/bcc. A message sent to three recipients produces three separate email.delivered callbacks, each with its own recipient_id and its own outcome. This differs from the Events API, which returns stored events with mixed cardinality (message-scoped admission events plus per-recipient delivery transitions) — see Event schemas by surface.In rare cases where a callback carries no durable recipient ID (for example, an ownership mismatch or a pre-recipient-ID legacy message), the webhook falls back to a message-scoped payload without recipient_id. Treat recipient_id as present in the normal case but do not crash on its absence.POST with a JSON body in this shape:
Recipient lifecycle payload fields
BCC addresses are redacted. A webhook for a BCC recipient carries a
bcc key whose email is an HMAC-SHA256 sentinel of the form redacted+sha256:<16-hex>@bcc.invalid, with name: null. The sentinel is stable for the same address, so support tooling can correlate records without the plaintext address ever leaving Telnyx. You cannot recover the original BCC address from a webhook.Error evidence on failure events
Error statuses (bounced, failed, deferred, expired, suppressed, gw_reject) carry a normalized error contract in two equivalent shapes. Prefer error_evidence for programmatic branching; errors[] mirrors the platform-wide Telnyx error array convention.
bounce_category is an internal classification field. It does not appear in normal recipient-scoped webhook payloads and is not part of the public webhook contract. In the normal callback path it never reaches the stored event row (the correlation map does not include it). A legacy message-scoped fallback path may persist it in stored events, but the Events API sanitizer does not explicitly strip it. Do not build consumer logic that reads bounce_category from any public surface. Use error_evidence.code (the 30xxx taxonomy) to distinguish failure types programmatically — for example 30001 for a hard bounce versus 30005 for a queue expiry.Event schemas by surface
Account polling uses a webhook-shaped event envelope. Per-message history retains its older shape plus the two additive name fields.
Recipient-scoped account payloads contain the message
id, recipient_id, and one to/cc/bcc object. Account polling builds payload.status from the stored event name, so new sharp outcomes can expose gw_reject, injection_timeout, or expired; webhook payload statuses retain their legacy rendering. Use canonical_event_type for the outcome across surfaces. They do not contain a separate email summary with recipient arrays. Internal observability fields are removed from stored payloads; error evidence depends on what was persisted and is not guaranteed to match the webhook. Use recipient status endpoints to reconcile delivery outcomes.
Message-scoped polling fallbacks can omit recipient_id; their to and cc values can be address objects, address strings, or an empty string when that field has no address, and bcc is "redacted". If the related message or recipient cannot be loaded, the minimal payload contains only id, status, and occurred_at plus any public persisted evidence. Treat from, subject, and recipient fields as optional when consuming historical or fallback rows.
Subscribe to events (webhook CRUD)
Webhook configuration is owned by the email-domains service, not the Email API. The
/email_domains/{domain_id}/webhooks CRUD routes are served by the Telnyx email-domains service; the Email API only resolves a sending domain’s webhook list at publish time to decide where to fan out. Because the two services version independently, treat the email-domains API reference as authoritative for these routes’ request/response schemas and error codes. See Domains & DKIM for domain setup./email_domains/{domain_id}/webhooks. A subscription requires a url and an events allowlist (at least one event type) — there is no default-to-all.
Create a webhook
curl
201 Created:
events allowlist includes the event type — so you can route different event types to different endpoints by creating separate subscriptions.
List webhooks for a domain
curl
page[number], page[size]) with a meta block of { page_number, page_size, total_pages, total_results }.
Retrieve, update, and delete
All four operations return
404 if the domain or webhook is not found; create/update return 422 on validation failure.
Event filtering per subscription
Theevents array on each webhook is an allowlist — Telnyx only delivers events whose type appears in that list to that webhook’s URL. To receive every event type, list all 21 published types (22 are accepted, but email.sending is never delivered). To receive only delivery confirmations and failures:
PATCH.
Signature verification
Telnyx signs every webhook delivery with Ed25519 public-key cryptography so you can verify that a request genuinely came from Telnyx. This is strongly recommended for production. Each webhook request includes two headers:
The signature is computed over the string
{timestamp}|{raw_json_body}. This is the standard Telnyx webhook signing scheme — email events are delivered by the same central delivery service that signs all Telnyx webhooks.
Outbound message events are published to RabbitMQ by the email API,
email.received by the inbound pipeline, and domain lifecycle events by the email-domains service. Telnyx’s central event_dispatcher service signs payloads (Ed25519 / TelnyxSigner), performs HTTP delivery with retries, and tracks delivery status. The signing and retry logic is shared across all Telnyx webhook products — email uses the identical scheme as messaging webhooks.Get your public key
Find your public key in the Mission Control Portal under Keys & Credentials → Public Key.Verification examples
Verify the raw body. The signature is computed over the exact bytes Telnyx sent. Re-serializing a parsed JSON object (e.g.
JSON.stringify(req.body) after body-parser) can reorder keys and break verification. Read the raw request body, as shown above.Replay protection. Reject webhooks where
telnyx-timestamp is more than 5 minutes outside your server’s clock to prevent replay attacks.Delivery and retries
1
An event occurs
A message changes state or a recipient interacts, and Telnyx emits an event.
2
Telnyx sends a POST
The event_dispatcher service signs the payload and sends an HTTP
POST to every webhook URL on the matching domain whose events allowlist includes the event type.3
Your server responds
Return a
2xx status code to acknowledge receipt.4
Retries on failure
If your endpoint doesn’t return
2xx, Telnyx retries the delivery. Delivery uses the standard Telnyx webhook retry behavior with backoff.Retries, URL health checking, and delivery-status tracking are handled by the central event_dispatcher service shared across all Telnyx webhook products. Email webhooks inherit the same delivery guarantees as messaging webhooks.
Consume via polling
If you don’t have a public HTTPS endpoint — or you want to pull stored outbound message history on demand — use the Events API. Polling is built for agents, batch jobs, on-prem services, and reconciliation of outbound message events after a webhook outage. The bare stored-name fields (payload.status in account polling and deprecated type in per-message history) can return queued, deferred, scheduled, cancelled, sandbox, sending, sent, failed, delivered, bounced, complained, suppressed, rejected, opened, clicked, unsubscribed, daily_limit_exceeded, scan_deferred, quarantined, quarantine_released, quarantine_release_dispatched, quarantine_rejected, quarantine_expired, gw_reject, injection_timeout, or expired. Filters accept bare or email.-prefixed names. The suppression, scan, and quarantine names are stored polling/history outcomes, not additional webhook subscription values. Polling includes stored sending and rejected events, but excludes received and every email_domain.* event.
daily_limit_exceeded records a scheduled-send admission failure when the account has insufficient daily capacity at fire time. It is persisted and published after commit when configured. Subscribe to email.daily_limit_exceeded or poll with event_type=daily_limit_exceeded. Cancellation and system failures likewise publish after commit as email.cancelled and email.failed.Scheduled events do not have webhook/polling event-ID parity. Polling returns the one stored message-scoped
scheduled row with its stored UUID; webhook publication emits one recipient-scoped callback per recipient with a derived UUID. The derived scheduled webhook IDs do not appear in GET /email_events. Reconcile scheduled events with the message ID (payload.id), event type, and occurrence time rather than the event UUID.List account events
GET /email_events returns events for the authenticated account, oldest-first within a time window, with cursor pagination and server-side filtering.
curl
200):
Filters
The
event_type filter accepts bare and email.-prefixed names. Legacy filters preserve their previous coverage: failed includes stored failed, gw_reject, injection_timeout, and expired rows; bounced matches stored bounced rows without expanding to expired. A canonical filter matches its own rows plus older rows whose recorded evidence proves that outcome. Use event_type=expired to query queue expirations and inspect canonical_event_type in the response.
The message filter parameter is
email_id, not message_id. Filter a single message’s events with ?email_id=<email-id>. For compatibility with the Telnyx v2 bracket convention, the service also accepts the legacy alias filter[message_id]=<email-id>, which normalizes to email_id — but note that filter[email_id] is not recognized. Unrecognized filter params are silently ignored and return unfiltered results, so a typo looks like a working query that returns everything.Pagination and ordering
Events are ordered byoccurred_at ascending, then by id. Pages are cursor-based: each response includes meta.page_cursor when more results are available, and omits the field entirely (rather than null) when you’ve reached the end of the result set. Pass that cursor as page_cursor on the next request.
The response meta always includes time_range — the resolved {from, to} query window, not the timestamps of the first and last returned events. With neither bound, from defaults to 30 days ago and to is null. With only from, to is capped at 30 days after from.
Account rows expose the message ID as
payload.id and the recipient correlation as recipient_id when present. They do not return the former record_type, type, email_id, or email summary fields.Per-message event history
To get the event timeline for a single message, useGET /email_messages/{email_id}/events. This is scoped to a message you own and returns its events in chronological order.
curl
200):
type is deprecated but retains the bare stored name. event_type and canonical_event_type are both email.-prefixed. Events embedded in message responses retain their existing type/occurred_at/optional payload shape.
Returns 404 if the email message doesn’t exist or belongs to another account. The per-message endpoint supports page_size and page_cursor but not the event_type/from/to/email_id filters (they don’t apply — you’re already scoped to one message).
Event statistics
GET /email_events/stats returns aggregate counts and derived rates for the authenticated account over a time window.
curl
200):
queued, sent, delivered, deferred, bounced, opened, clicked, complained, unsubscribed, and failed. Counts and rates are recipient-level: every address in to, cc, and bcc counts separately, while repeated events of the same type for the same message and recipient count once. Partial MTA injection results count successful recipients as sent and unsuccessful recipients as failed. The Email API rejects duplicate normalized recipient addresses across to, cc, and bcc. Other polling event types (such as scheduled, cancelled, sandbox, sending, rejected, and daily_limit_exceeded) are not included in stats. The rates are percentages:
from and to are optional ISO 8601 timestamps. With neither bound, from defaults to 30 days ago and to is null; the response reports those resolved query bounds.
Polling patterns
Cursor loop (backfill or steady-state tail)
PollGET /email_events with a from timestamp, then walk the cursor until page_cursor disappears. Because from is inclusive, persist the latest occurred_at and the event IDs seen at that timestamp. Start the next poll from the same timestamp and deduplicate the boundary events by id; using only the timestamp would reprocess them.
Python
When to poll vs. webhook
Use webhooks for real-time reactions and polling to reconcile the overlapping stored outbound message events. Polling does not backfill
email.received or email_domain.* webhooks, and it additionally exposes stored sending and rejected events without webhook delivery. Cancellation and daily-limit events support both polling and webhook subscriptions.
Choosing and combining both
Most production setups use both surfaces together:- Webhooks for real-time reactions — subscribe each domain to the event types your app cares about (
email.delivered,email.bounced,email.complainedare the common minimum). Verify signatures, return2xxfast, and process asynchronously. Remember each callback is one recipient: key your handler onpayload.recipient_id, not onpayload.id. - Polling for reconciliation and backfill — run a periodic
GET /email_events(orGET /email_events/statsfor dashboards) to recover overlapping stored outbound message events missed during webhook downtime and to compute aggregate metrics. It cannot recoveremail.receivedoremail_domain.*webhooks, and error evidence is limited to what was stored. - Per-message lookups for support — use
GET /email_messages/{email_id}/eventsto show a single message’s delivery timeline on demand. - The inbound WebSocket for received mail — use
/email_events/wsonly for inboundemail.receivedstreaming; it carries no outbound delivery events.
Keep your webhook
events allowlist tight. Subscribing to every event type is valid, but most applications only need a handful — filtering at the subscription reduces your endpoint’s load and the noise you have to sift through. Because delivery webhooks are recipient-scoped, a broad allowlist on a large multi-recipient send multiplies your callback volume by the recipient count.