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 three related surfaces: webhooks (push, real time, recipient-scoped), message-event polling (pull, on demand, mixed cardinality), and an inbound WebSocket (push, real time, inbound only).
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 event taxonomies and payload shapes are not identical — 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:
There is no
email.expired webhook event type — expired is a recipient status, not an event type.
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 19 event types: 13 outbound/trackingemail.* events handled by the email API (12 published plus email.sending which is accepted but never delivered), email.received published by the inbound pipeline, and 5 email_domain.* domain-lifecycle events published by the domains service. This is the complete webhook subscription allowlist. The polling API uses a separate 16-type message-event taxonomy described under Consume via polling.
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 18 of the 19 allowed event types are actually delivered as webhooks.Message lifecycle (email.*)
Engagement & tracking (email.*)
Domain lifecycle (email_domain.*)
The five
email_domain.* events are emitted by the Telnyx email-domains service, email.received is emitted by the inbound pipeline, and the other thirteen email.* events are handled by the email API (twelve are published as webhooks; email.sending is accepted by the subscription schema but never delivered). A webhook subscription can span the full 19-type allowlist; only 18 types are actually delivered. GET /email_events stores and returns only its separate outbound message-event taxonomy; it does not return domain lifecycle events or email.received.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
The three consumption surfaces do not share a schema. They differ in cardinality (one event per recipient for webhooks versus mixed cardinality for the Events API) and in field set. Do not write a single parser that assumes all three are interchangeable.The Events API strips observability-only and internal fields from the stored event payload before returning it. Fields you see in a webhook — including
error_evidence — are not guaranteed to be present in the Events API response. Treat the webhook as the source of delivery-failure detail.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 18 published types (19 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 polling model accepts these 16 bare event types:queued, deferred, scheduled, cancelled, sandbox, sending, sent, failed, delivered, bounced, complained, rejected, opened, clicked, unsubscribed, and daily_limit_exceeded. Unlike webhook subscriptions, polling includes sending, cancelled, rejected, and daily_limit_exceeded, but excludes received and every email_domain.* event.
daily_limit_exceeded is a scheduled-send admission failure, not a webhook subscription event. It is persisted when a scheduled send fires and the account’s daily send limit no longer has room for it — the message is rejected at fire time rather than at submission time. It cannot be selected in a webhook events allowlist; poll for it or query it via filter[event_type]=daily_limit_exceeded.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 the bare event names (e.g. delivered, bounced, opened) — not the email.-prefixed webhook event types. So event_type=delivered,bounced filters to delivery and bounce events.
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.
The
email summary block ({from, to, cc, subject}) is present when the underlying message is available for preload, and omitted (not null) when it isn’t. Don’t rely on it always being present — guard for its absence.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):
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 sending, cancelled, rejected, and daily_limit_exceeded message events that cannot be selected in a webhook subscription.
Consume via WebSocket (inbound only)
GET /email_events/ws upgrades to an authenticated WebSocket that streams inbound email in real time.
The WebSocket is inbound-only. It streams
email.received events for messages arriving at your enabled inbound domains. It does not carry outbound delivery lifecycle events — you will never receive email.delivered, email.bounced, email.deferred, or any tracking event on this socket. To consume outbound delivery events, use webhooks or the Events API.connected frame naming the account and the active filter:
{"type":"ping"} to receive {"event_type":"pong"}. Native WebSocket ping/pong control frames are handled by the server automatically. Any other client frame is answered with an error frame; unparseable frames never close the connection. Pass an optional inbox_id query parameter to filter to a single inbox — a non-UUID value is rejected with code 10002.
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 it does not returnerror_evidence. - 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.