Skip to main content

Telnyx Messaging: Email — Full Documentation

Complete page content for Email (Messaging section) of the Telnyx developer docs (https://developers.telnyx.com). Root index: https://developers.telnyx.com/llms.txt · Lightweight index for this subsection: https://developers.telnyx.com/development/llms/messaging-email-llms-txt.md

Getting Started

Send Your First Email

Source: https://developers.telnyx.com/docs/messaging/email/quickstart.md
Email API is in invite-only beta. Access is limited to accounts with the email.beta_access capability enabled. Contact us to request access. Send your first email using the Telnyx Email API. You can go the full route — add a custom domain, verify DNS, and send — or use Telnyx’s shared domain for a two-minute send with zero DNS setup.

Prerequisites

All requests use the production base URL https://api.telnyx.com/v2 and an Authorization header with the Bearer authentication scheme. Replace YOUR_API_KEY in the examples with your key.

Option A: Send in two minutes with a shared domain

Telnyx provides a shared sending domain (mail.telnyx.com) that’s already verified and ready to use — no DNS setup required. Sends must use onboarding@mail.telnyx.com as the from address, and recipients are limited to the account owner’s verified email address.
curl
The shared domain is perfect for testing and onboarding. When you’re ready to send to arbitrary recipients from your own branded address, switch to a custom domain (Option B below). Shared-domain sends are limited to the account owner’s verified email address as the recipient. To send to any recipient, add and verify your own domain. Skip ahead to Check the result to verify delivery.

Option B: Use a custom domain

1. Add a sending domain

Emails must be sent from a domain you control so recipients can authenticate the sender (SPF, DKIM, and DMARC). Add your domain with one request:
curl
The 201 response returns the domain with a status of pending and an id you’ll use in the next steps. Save that id. Telnyx also provides shared sending domains as a restricted zero-setup option. Shared domains are visible to all accounts in GET /email_domains (look for "type": "shared") and need no DNS setup, but sends must use onboarding@ as the from address and every recipient must match the verified email address of the account owner. Use a custom domain for arbitrary recipients, your own from address, and control over sender authentication and domain reputation. Trial accounts are restricted to the account owner’s verified email address as the recipient, even with a custom domain. Sending to any other recipient returns 403 with code 10007. Upgrade the account to send to arbitrary recipients.

2. Verify the domain

DNS records are generated for every domain you add. Fetch them and add them at your registrar.
curl
The response includes records for ownership (TXT), SPF (TXT), DKIM (TXT), MX, and DMARC (TXT), each with host, value, and a required flag. Add each record’s host and value at your registrar or DNS host. Ownership and DKIM are required. MX is required only when inbound_enabled is true; SPF and DMARC are optional but recommended for deliverability.
curl
A 200 returns the updated domain. Sending is enabled only when data.status is verified; the same 200 can also report pending (records not yet observed) or failed (a required record is missing or wrong). Inspect the verification map, wait for propagation, and retry.

3. Send your first email

With a verified domain, send a message with a minimal payload — from, to, subject, and text_body:
curl
Replace the placeholders:
  • from: an address on a domain you’ve verified. For a shared domain, use only onboarding@.
  • to: the recipient address (an array, even for a single recipient). Shared-domain sends are limited to the account owner’s verified email address. Trial accounts are limited to the account owner’s verified email address on any domain, including custom domains.
The optional Idempotency-Key header makes retries safe. Generate a unique UUID v4 for each logical send, then reuse the same key and request body if a network error leaves the result uncertain. Do not put the key in the JSON body.

4. Check the result

A successful send returns 202 Accepted with the created message:
The status: "queued" means your message is on its way. Save the id to look it up and track delivery. If this request is retried after a successful send, Telnyx returns the original status and response body with Idempotent-Replayed: true in the response headers. The first response does not include that header. Retrieve the message:
curl
List events for the message — queued, sent, delivered, bounced, opened, clicked, and more as delivery progresses:
curl
You’ve sent your first email. To go further — HTML bodies, attachments, scheduling, idempotency, and tracking — see the Send Email guide.

Sending Email

Email API Guide

Source: https://developers.telnyx.com/docs/messaging/email/send-email.md
Send email with the Telnyx Email API. This guide covers the full request payload, scheduling and idempotency, tracking, errors, and batch sending. All requests use the production base URL https://api.telnyx.com/v2 and an Authorization: Bearer YOUR_API_KEY header.

Send a message

Send an email with POST /email_messages. The only required fields are from and to; subject is required unless you’re sending with template_id.
curl

Address fields

Body, headers, and attachments

Send an attachment by base64-encoding its content:
curl
Request attachments[] fields: Response attachments[] returns filename, content_type, url, sha256, size_bytes, disposition, and content_id. The base64 content you submitted is never returned — fetch the stored file from url instead.

Templates

Send with a stored template instead of inline bodies. Provide template_id and optional template_variables for Liquid rendering. When you use a template, subject is optional — the template’s subject is rendered; if the template has no subject or renders empty, the request returns 400.
curl
Non-object template_variables values may cause a 422 validation error on message creation. Pass an object.

Schedule a send

Set scheduled_at to an ISO 8601 timestamp in the future to schedule the message. The response returns 202 with "status": "scheduled" and a scheduled_at field.
curl
Invalid or past scheduled_at timestamps are silently ignored and the email is sent immediately. send_at is a deprecated alias for scheduled_at. It is still accepted on requests for backward compatibility, but responses always return scheduled_at. When both are supplied, scheduled_at wins. Use scheduled_at in new integrations. Cancel a scheduled message before it sends with DELETE /email_messages/{email_id}/schedule:
curl
A successful cancel returns 200 with the message in "status": "cancelled". The scheduled_at value persists on the record even after cancellation.

Idempotency

Pass an optional Idempotency-Key HTTP header to safely retry a send without creating duplicates. Generate a unique UUID v4 for each logical request, then reuse the same key only when retrying that operation with the same request body. Keys are retained for up to 24 hours, and only successful responses are replayed. Do not include sensitive data in the key.
curl
The first successful request returns its normal status and response body. A retry with the same key and request returns the stored status and body and adds this response header:
The header is omitted for first-time requests and error responses. Reusing a key with a different request returns 422 with code 10027; sending the same key while the original request is still running returns 409 with code 10036. Empty, duplicate, malformed, or overlong key headers return 400 with code 10015. If Edge cannot provide idempotency protection for a keyed request, it fails closed with 503 and code 10016. idempotency_key is not a request-body field. Put the key only in the Idempotency-Key HTTP header.

Response

A successful send returns 202 Accepted with the message in data:
Key response fields:
  • status — the message lifecycle status (see below).
  • id — the message UUID. Use it with GET /email_messages/{id}, GET /email_messages/{id}/events, and DELETE /email_messages/{id}/schedule.
  • created_at — when the message was created.
  • scheduled_at — present when a future send was scheduled.
  • recipient_statuses — a map of per-recipient status to count, present once recipient rows exist.
  • sandbox — present when the message was created in sandbox mode.
Message status, recipient status, and event types are three different taxonomies. A message is a request; each recipient has its own delivery outcome; engagement signals such as opens and clicks are events, never message statuses.

Message status

The parent message status is request-lifecycle only — it does not describe delivery.

Recipient status

Each recipient carries its own delivery outcome. These are surfaced in recipient_statuses and through the recipients endpoints. The Events API uses a separate event-type taxonomy — several recipient statuses map lossily to a different stored event type (for example, expired, gw_reject, and injection_timeout all produce a failed stored event). For exact recipient status, use recipient_statuses or the recipient endpoints.

Event types

Events record what happened and when. Query them with GET /email_messages/{id}/events or GET /email_events.

Suppressed recipients

When one or more recipients are suppressed at send time, the 202 response includes a top-level suppressed array describing each suppressed recipient. The message is still created for the non-suppressed recipients (if any).
If all recipients are suppressed, the request returns 422 with a recipient_suppressed error and the suppressed array (see Errors).

Tracking

Open, click, and unsubscribe tracking are configured through the tracking object on an email domain:
curl
Tracking is scoped to the sending profile, not to an individual domain. The domain endpoint is the API surface, but the setting is stored on your account’s default sending profile. Every domain that shares that profile shares one tracking configuration, so updating tracking through one domain changes it for all of them. Registering a domain with tracking in POST /email_domains therefore only works for the first domain on the account — once a profile exists, the request is rejected with a validation error and you must use PATCH /email_domains/{id} instead. Defaults for a new domain: open_tracking: false, click_tracking: false, unsubscribe_tracking: true. Open and click tracking are opt-in; one-click unsubscribe is on by default because Gmail and Yahoo bulk-sender rules require RFC 8058 unsubscribe support — disable it only if you handle unsubscribes yourself. The tracking endpoints themselves are public (no auth) because email clients and browsers hit them directly.

Open tracking

When open_tracking is enabled, messages with an HTML body get a 1×1 transparent tracking pixel injected before the closing tag (or appended to the HTML when there is no). When the recipient’s email client loads the image, Telnyx records an email.opened event.

Click tracking

When click_tracking is enabled, Telnyx rewrites href links in the HTML body to point through a tracking redirect. When a recipient clicks, Telnyx records an email.clicked event and redirects (HTTP 302) to the original URL. Links already pointing to the tracking service are not double-wrapped.

Unsubscribe tracking

When unsubscribe_tracking is enabled (the default), Telnyx adds List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) headers to outgoing messages, with a signed, unguessable unsubscribe URL. A recipient can unsubscribe via a link (GET) or one-click (POST, sent automatically by supporting email clients). On unsubscribe, Telnyx records an email.unsubscribed event and creates a suppression for the recipient.

Tracking events

Tracking events — email.opened, email.clicked, and email.unsubscribed — are stored as message events and delivered to configured webhooks. Query them per message:
curl
Or list events across the account:
curl
The email.opened event payload includes a first_open boolean that is true for the first open and false for subsequent opens. The email.clicked payload includes the clicked url, and email.unsubscribed includes the method (link or one_click). A dedicated deep-dive on email webhooks is coming soon. Until then, event data is available via the endpoints above.

Errors

API errors return structured JSON. See the full Error Codes reference for all codes and troubleshooting. The standard error envelope:

Common error codes

recipient_suppressed (422) uses a non-standard envelope with the suppressed array alongside errors:
reputation_suspended (429) uses a string code rather than a numeric Telnyx code:

Batch sending

Send up to 50 messages in a single request with POST /email_messages/batch. Each item in the messages array is a full CreateEmailRequest payload.
curl
The Idempotency-Key applies to the entire batch request. Reuse it only when retrying the same batch body; do not add per-message idempotency keys inside messages.
  • 207 — all batch responses use 207 Multi-Status. When all messages succeed, errors is empty and every item is in data. When one or more fail, the response contains a data array for successes (which may be empty) and an errors array for failures.
The errors array for a batch uses a per-item shape — each entry has index (zero-based position in the request array), code, and message (not detail):
Batch item error codes: bad_request, unprocessable_entity, not_found, forbidden, service_unavailable, validation_error, recipient_suppressed, and reputation_suspended. unprocessable_entity is returned when an individual message exceeds the size limits — the rest of the batch still processes.

Migrate to Telnyx

Source: https://developers.telnyx.com/docs/messaging/email/migrate-to-telnyx.md
Move your email sending from an existing provider — SendGrid, Mailgun, Amazon SES, Postmark, or any other ESP — to Telnyx Email. This guide maps the concepts you already use to their Telnyx equivalents, then walks the migration end to end. All requests use the production base URL https://api.telnyx.com/v2 and an Authorization header with the Bearer authentication scheme.

Concept mapping

Every ESP exposes the same core primitives under different names. Here’s how they map to Telnyx: There is no POST /v2/suppressions endpoint on Telnyx. Suppressions live under email blocksPOST /v2/email_blocks for a single manual block, and POST /v2/email_blocks/import for a bulk CSV migration from your old provider. See Suppressions & Unsubscribes.

Event-type mapping

Telnyx publishes an event for each delivery-lifecycle transition. If you already handle ESP events, this is the mapping you need: Several Telnyx behaviors differ from most ESPs and are worth handling explicitly:
  • email.queued and email.sent are separate stages. email.queued fires when the API accepts and persists the message; email.sent fires later, when the message is successfully injected into the outbound MTA. Most ESPs don’t split these. SendGrid, for example, has no event equivalent to email.sent — its earliest delivery event is processed (“accepted and can deliver the message”), which is closer to Telnyx’s email.queued (acceptance) than to email.sent (MTA injection). Treat email.queued as acceptance only: a message can sit in queued and still fail before it is ever sent.
  • email.failed only applies to an accepted message. A request that fails validation is rejected synchronously with 422 and never creates a message, so it produces no lifecycle webhook at all. Handle input errors from the HTTP response; use email.failed only for failures after acceptance.
  • email.bounced covers four underlying causes. Hard bounces, retry expiration, administrative bounces, and out-of-band bounces all surface as email.bounced, distinguished by a bounce_category of permanent, transient, admin, or oob. If your current code branches on separate bounce and drop events, collapse that logic onto email.bounced + bounce_category.
  • email.deferred is not terminal. A deferred message is still in flight and will retry. Don’t treat it as a failure or trigger a resend.
If you’re coming from SendGrid specifically, the event names map like this: If your SendGrid integration keys off processed as “we sent it,” note that the equivalent Telnyx signal is email.queued, not email.sent. Subscribe to email.sent as well if you want to know when the message actually reached the outbound MTA — SendGrid gives you no such event today. Telnyx additionally has an injection_timeout recipient status for the ambiguous case where the MTA handoff timed out and Telnyx cannot yet tell whether the message was accepted. Do not resend on this status — retrying risks a duplicate. If the MTA did accept the message, a later callback reconciles the recipient from injection_timeout to sent. The polling API (GET /v2/email_events) uses bare event names — delivered, bounced, opened — while webhook subscriptions use the email.-prefixed types above. Filter accordingly: ?event_type=delivered,bounced.

Migrating from SendGrid

SendGrid has the largest install base, so here it is in detail. Notes for Mailgun, SES, and Postmark follow each section.

API concepts

Sending a single email

SendGrid nests recipients under personalizations and bodies under content. Telnyx flattens both:
SendGrid
Telnyx
Key differences:
  • to, cc, and bcc are top-level arrays. Each item is either a plain email string or an {email, name} object — both forms work.
  • from accepts a plain string or {email, name}.
  • Bodies are html_body and text_body, not a content array.
  • A successful send returns 202 Accepted with "status": "queued" — not 200. Update any response-code assertions.
  • html_body, text_body, headers, tags, and metadata are write-only — they are not echoed back in responses.
Mailgun: replace the multipart from/to/html/text form fields with the JSON payload above; o:tag becomes tags, and v:my-var custom variables become metadata. Amazon SES: Destination.ToAddressesto, Message.Body.Html.Datahtml_body, Message.Body.Text.Datatext_body. Postmark: From/To/HtmlBody/TextBody map almost one to one; lowercase the field names and wrap to in an array.

Idempotent retries

Telnyx supports optional idempotency on send via an Idempotency-Key HTTP header, so a retried request doesn’t produce a duplicate email:
curl
  • Generate a unique UUID v4 per logical request and reuse it only when retrying that same operation with the same body.
  • Keys are retained for 24 hours, and only successful responses are replayed. A replayed response carries the Idempotent-Replayed: true header; the header is omitted for first-time requests and for error responses.
  • Reusing a key with a different request body returns 422 with code 10027.
  • Sending the same key while the original request is still processing returns 409 with code 10036.
  • An empty, duplicate, malformed, or overlong key returns 400 with code 10015.
  • Idempotency is enforced at the Telnyx Edge and fails closed — if that protection is unavailable for a keyed request, you get 503 with code 10016. Retry with the same key.
idempotency_key is not a request-body field. A body field by that name is ignored — only the Idempotency-Key HTTP header takes effect. A request sent without the header is not deduplicated, so an unkeyed retry after a timeout or a 5xx can produce a duplicate email. Full details, including the batch behavior, are in Send Email and Error Codes.

Batch sending

SendGrid’s personalizations array sends one request with many recipient blocks. Telnyx uses an explicit messages array where each item is a complete send payload:
SendGrid
Telnyx
  • The batch limit is 50 messages per request. An empty array or more than 50 items returns 400. If you currently send 1,000-recipient personalization blocks, chunk them into groups of 50.
  • 207 is always returned for batch responses. When all messages succeed, errors is empty. When one or more fail, the response carries a data array of successes and an errors array of failures.
  • Batch item errors use a different shape from single-send errors: each entry has index (zero-based position in your request array), code, and message — note message, not detail. Match failures back to inputs by index.
  • An Idempotency-Key header applies to the whole batch request, not to individual messages — there are no per-message keys inside messages. Reuse the key only when retrying the identical batch body. On a partial (207) result, do not replay the original batch: build a new request containing only the failed index entries, with a new key.

Webhooks

The biggest structural difference: SendGrid’s Event Webhook is configured once per account, and Telnyx webhooks are scoped to a sending domain. If you send from three domains and want events from all three, create three subscriptions.
curl
  • events is a required allowlist with at least one entry — there is no subscribe-to-everything default. Events whose type isn’t listed are never delivered to that URL.
  • Telnyx signs deliveries with Ed25519, not the HMAC scheme SendGrid uses. Verify the telnyx-signature-ed25519 and telnyx-timestamp headers against the raw request body. Your existing SendGrid verification code will not carry over — see Webhooks & Events for verified examples in Node, Python, and Go.
  • SendGrid posts a JSON array of events per request. Telnyx posts a single event per request under data, with the event type at data.event_type and the message snapshot at data.payload.
If you don’t have a public HTTPS endpoint, skip webhooks entirely and poll instead:
curl
Polling is cursor-based: read meta.page_cursor and pass it as page_cursor on the next request. The field is omitted entirely — not null — once you reach the end of the results.

Templates

SendGrid uses Handlebars ({{name}} with {{#if}} blocks); Telnyx uses Liquid. Simple variable interpolation looks identical, but conditionals, loops, and filters need rewriting.
SendGrid
Telnyx
Telnyx creates the template and its content in one request — there’s no separate template/version step. Fields are name, subject, html_body, text_body, and an optional variables array; when you omit variables, Telnyx extracts them from the subject and bodies automatically. Creation returns 201. Liquid syntax is validated at create time, so a malformed template fails immediately with 422 rather than at send time. Preview a template without sending:
curl
Then send with it by passing template_id and template_variables. When you use a template, subject on the send is optional — the template’s subject renders instead. Three different fields are easy to confuse when porting templates. They are not interchangeable: The render and send paths do not behave the same way for a malformed value. POST /v2/email_templates/{id}/render tolerates a non-object and previews with {}; POST /v2/email_messages rejects it with 422. A mis-serialized value — for example sending "{\"first_name\":\"Ada\"}" as a JSON string rather than an object — will preview fine but fail at send time. Send an object, not a stringified object. Mailgun: Mailgun templates also use Handlebars — the same rewrite to Liquid applies. Postmark: Mustachio templates map cleanly to Liquid for basic interpolation.

Suppressions

Migrate your existing list in bulk rather than replaying it address by address. POST /v2/email_blocks/import accepts a CSV and auto-detects the provider from the header row — SendGrid, Mailgun, and Amazon SES exports are recognized natively:
curl
Export each SendGrid suppression list (bounces, blocks, spam reports, unsubscribes, invalid emails) and import them as-is. The importer maps SendGrid’s type column onto the Telnyx reason taxonomy: bounceshard_bounce, spam_reportsspam_complaint, unsubscribesunsubscribe, invalid_emailsinvalid, and blocksmanual_block. The endpoint returns 202 with an async job; poll GET /v2/email_blocks/import/{id} until status is completed or failed. Limits: 25 MiB decoded and 250,000 rows. Import your suppression list before your first production send. Sending to addresses that bounced or complained at your previous ESP is the fastest way to damage a new domain’s reputation.

Migration checklist

Create a key in the Mission Control Portal under Auth → API Keys. Send it as Authorization: Bearer YOUR_API_KEY on every request. If you plan to bypass overridable suppressions with ignore_suppression, the key needs the email:override scope (granted by full_access or email:admin).
curl
Save the returned id, fetch the generated records with GET /v2/email_domains/{domain_id}/dns_records, publish them at your DNS provider, then trigger validation:
curl
Telnyx generates its own DKIM keypair, so you’ll publish a new DKIM record alongside your existing ESP’s. Both can coexist during the migration — keep the old records live until you’ve fully cut over. See Email Domains & DKIM. Import your ESP’s bounce, complaint, and unsubscribe exports via POST /v2/email_blocks/import before sending anything. If you use subscription groups, recreate them with POST /v2/email_unsubscribe_groups and add members with POST /v2/email_unsubscribe_groups/{id}/suppressions, then pass the matching group_id on sends.
curl
Expect 202 with "status": "queued". Save the id and confirm the timeline with GET /v2/email_messages/{id}/events. Create a webhook per sending domain with an explicit events allowlist, and implement Ed25519 signature verification against the raw request body. Or skip webhooks and poll GET /v2/email_events. Use GET /v2/email_events/stats for the aggregate counts and rates that replace your ESP’s stats dashboard. Recreate each template with POST /v2/email_templates, rewriting Handlebars or Mustachio syntax to Liquid. Verify the output with POST /v2/email_templates/{id}/render before switching production traffic, then map your old template IDs to the new Telnyx UUIDs in your application config. Chunk any bulk sends into batches of 50 or fewer, switch to the messages array shape, and handle 207 partial-success responses by matching the errors[].index back to your input array. A new sending domain has no reputation history at the mailbox providers, even if your old one did. Ramp volume gradually rather than moving 100% of traffic on day one, and watch bounce and complaint rates via GET /v2/email_events/stats. See Deliverability and Domain Warm-up.

Differences to plan for

Running both providers in parallel during cutover is the safest path: keep your old ESP’s DNS records published, route a small percentage of traffic to Telnyx, compare delivery and bounce rates via GET /v2/email_events/stats, then ramp. Full endpoint details are in the API reference, and error codes are catalogued in Error Codes.

Rate Limits

Source: https://developers.telnyx.com/docs/messaging/email/rate-limits.md
Rate limits protect the platform and ensure fair resource allocation. This page covers request and sending limits enforced by the Email API, and how to request increases.

Request and message size limits

Three different ceilings apply at three different layers. They are frequently confused — only the first two reject a message. 8 MB is not the request body limit. It is the Edge gateway’s replay cap for idempotency-keyed requests. A keyed request over 8 MB is rejected at the Edge with 413 Payload Too Large — it never reaches the Email API. Unkeyed requests bypass this cap entirely. The limits that actually reject a message are the 1 MB decoded body and 25 MB total message, enforced by the Email API, which return 422. Attachments are base64-encoded in the request, so a 25 MB message occupies roughly 33 MB on the wire. The Email API measures decoded bytes, so budget against the decoded size, not the encoded payload. A batch send with more than 50 messages is rejected with 400:

Request rate limits

API requests are rate-limited at the Telnyx API edge. Exact per-endpoint rates depend on your account tier and are not fixed platform-wide constants — if you need a specific sustained request rate, contact support to confirm or raise your account’s limits. When you exceed the limit, you’ll receive 429 Too Many Requests. Do not assume a Retry-After header is present. The Email API’s own 429 responses — daily send limit and reputation suspension — do not set Retry-After. Honor the header when it is present (edge-level rate limiting may supply it) and fall back to exponential backoff with jitter when it is absent. Never block on parsing a header that may not be there.

Not every 429 is a rate limit

Two very different conditions return 429, and they need opposite handling. Branch on the error code, never on the status alone. Both 10011 and reputation_suspended are rejected before message creation — no message record, no billing, no MTA injection.

Reputation-based suspension

Separate from request rate limiting, sending can be suspended when a domain’s reputation band drops to poor. This returns 429 with code reputation_suspended:
See Deliverability and Domain Warm-up for reputation guidance and recovery steps.

Daily send limit

When an account exceeds its daily send quota, the send is rejected with 429 and code 10011:
The quota is counted in recipients, not API requests — a single request to five addresses consumes five slots. Suppressed recipients are filtered out before the count, so they don’t consume quota. Sandbox sends are exempt.

Sending quotas

Sending quotas vary by account tier. Contact your account manager for your current quota. Sandbox mode (sandbox_mode: true) lets you test the full send flow — validation, event creation, webhook firing — without actually delivering the message. Sandbox sends do not consume daily quota and are not billed.

Billing

Outbound messages are billed per recipient accepted into the outbound MTA queue. A message addressed to five recipients is therefore up to five billable sends — one for each recipient the MTA accepts. Recipients that fail before reaching the queue are not billable:
  • Suppressed recipients (filtered before the send).
  • Gateway rejections — the MTA refused the recipient at injection.
  • Sandbox sends — nothing is delivered.
  • System failures and cancellations that occur while the recipient is still pre-queue.
So a five-recipient send where two addresses are suppressed and one is rejected at injection bills for two, not five. Recipient-level outcomes are visible in the recipient_statuses counts on the message resource and in per-recipient webhooks. See your rate card or contact your account manager for pricing details.

High-volume sending patterns

Batch sending

For high-volume sending, use the batch endpoint to reduce API calls:
curl
  • Up to 50 messages per batch request.
  • The Idempotency-Key header applies to the entire batch — reuse it only for exact retries of the same batch body.
  • Partial success returns 207 Multi-Status with per-message results. See Error Codes.

Scheduled sending

Spread load over time using scheduled_at:
The message is saved with status: "scheduled" and dispatched at the specified time. scheduled_at must be a future ISO 8601 timestamp. A value in the past, or one that fails to parse, is silently ignored — the message is sent immediately as a normal send, with no error and no scheduled status. There is no validation error to catch, so validate the timestamp client-side before submitting. The legacy field name send_at is still accepted as a fallback, but scheduled_at is the canonical name. Scheduled sends do not consume daily quota at request time. Quota is reserved when the scheduled worker actually fires, not when you submit the request. This means a scheduled send can be accepted today and still be rejected at fire time if the daily limit is exhausted then — in which case the message is marked failed and a daily_limit_exceeded event is recorded rather than a 429 being returned to you. Poll for that event type via GET /email_events to detect it; it is not available as a webhook subscription. Sandbox sends are likewise exempt from request-time quota reservation.

Requesting limit increases

To increase your sending quotas or request rates:
  1. Contact your account manager or Telnyx support.
  2. Provide your expected sending volume (messages/day, messages/second).
  3. Have your domain(s) verified and warmed up (see Deliverability).
Contact your account manager for current quotas, pricing, and increase timelines.

Error Codes

Source: https://developers.telnyx.com/docs/messaging/email/error-codes.md
This page is a reference for error codes returned by the Telnyx Email API. It covers two distinct families: synchronous HTTP errors returned on the API request itself, and asynchronous delivery errors (the 30xxx taxonomy) reported later via webhooks and detail records. They are not interchangeable — a 30xxx code never appears in an HTTP response body, and an HTTP code never appears in error_evidence. For endpoint-specific errors, see the response examples in each endpoint’s API reference.

Error response format

Most errors follow the standard Telnyx v2 error shape (exceptions: batch errors use {index, code, message} — see Batch-specific errors — and some template render errors use {code, message}):

Request and message size limits

Size failures are a common source of confusion because three different ceilings apply at three different layers. They are not the same number. 8 MB is not a request-body limit. It is the Edge gateway’s request_body_cap_bytes for idempotency-keyed replay only. A keyed request over 8 MB is rejected at the Edge with 413 Payload Too Large — it never reaches the Email API. Unkeyed requests bypass this cap entirely. The limits that actually reject a message body are the 1 MB body and 25 MB total enforced by the Email API, which return 422. Attachments are base64-encoded in the request, so a 25 MB message occupies roughly 33 MB on the wire. The Email API measures decoded bytes.

HTTP status codes

Error code reference

Every code in this section is a synchronous error — returned in the HTTP response to your API request. Delivery failures that happen after a 202 Accepted use the separate 30xxx taxonomy.

400 — Bad Request

401 — Unauthorized

403 — Forbidden

404 — Not Found

409 — Conflict

422 — Unprocessable Entity

429 — Too Many Requests

Do not treat every 429 as a transient rate limit. Branch on the error code, not the status. 10011 clears on its own at midnight UTC. reputation_suspended does not clear by waiting — retrying it in a backoff loop will never succeed and worsens the reputation signal. Stop sending on that domain and remediate the underlying bounce/complaint rates first.

500 — Internal Server Error

503 — Service Unavailable

error_evidence structure

Failure events carry the normalized contract as error_evidence:
The same normalized error is also published in array form as errors[], where each entry adds a human-readable title and renames message to detail. See Error evidence on failure events. The webhook event type does not uniquely identify the failure. An ordinary bounce, a queue expiration, an administrative bounce, and an out-of-band bounce all publish email.bounced. Branch on error_evidence.code — for example 30001 versus 30005 — and read the recipient status for the authoritative outcome. bounce_category is an internal field: it is not part of normal recipient-scoped webhook payloads and is not written to normal recipient-scoped stored events. A legacy message-scoped fallback path may persist it in stored events, and the Events API sanitizer does not explicitly strip it. Do not build consumer logic that reads bounce_category from any public surface.

Idempotency-specific errors

When using the Idempotency-Key header, idempotency is enforced at the Telnyx Edge (API gateway) before the request reaches the Email API. The same key can produce three distinct outcomes, and they must be handled differently: Two further failure modes are specific to the idempotency layer itself: A replay only works while the gateway holds the stored response. Requests whose body exceeds the Edge 8 MB replay cap are rejected at the Edge with 413 Payload Too Large before reaching the Email API — see Request and message size limits.

Batch-specific errors

Batch requests (POST /email_messages/batch, up to 50 messages) return 207 Multi-Status when one or more messages fail — including an all-failed batch — with per-message errors:
Each batch error entry has index (position in your messages array), code, and message:

Codes owned by other services

The Telnyx email product spans several services. A few codes documented here are returned by services other than the Email API, which means their exact status, code, and detail can change independently of this page. Verify these against the owning service’s API reference before depending on the precise shape: Error codes are not globally unique across Telnyx email services. 10008 is the clearest example: the email-domains service returns it as a 403 Forbidden for shared-domain mutation, while the Email API returns the same code as a 503 Service Unavailable when diagnostics authentication is unavailable. Always interpret a code together with both the HTTP status and the endpoint that returned it — never on the code alone.

Troubleshooting

”Domain is not verified”

  1. Check GET /v2/email_domains to see the domain status.
  2. Ensure all required DNS records — ownership and DKIM, plus MX if inbound_enabled is true — are published and match the records returned by GET /v2/email_domains/{domain_id}/dns_records.
  3. Call POST /v2/email_domains/{domain_id}/verify after DNS propagates.
  4. For a controlled zero-setup test, use onboarding@ and send only to the account owner’s verified email address.

”sender address is not allowed”

The from address must be on a verified domain you own. Shared-domain sends must use onboarding@ and can target only the account owner’s verified email address.

”Idempotency-Key header is invalid”

  • Generate a UUID v4 (uuidgen or crypto.randomUUID()).
  • Pass it as an HTTP header: Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9326.
  • Do not include it in the JSON body — it’s a header only.
  • One key per logical request. Reuse only for exact retries.

Sending suspended (reputation_suspended)

Your domain’s reputation band dropped to poor — usually from high bounce or complaint rates. See Deliverability and Domain Warm-up for recovery guidance.

Choosing a retry strategy

Retry decisions belong on the error code, not the HTTP status. Two errors that share a status can need opposite handling. Never retry a 4xx other than 409 and 429 without changing the request — the outcome is deterministic. And when you do retry a send, always reuse the original Idempotency-Key so a retry that races a slow success cannot deliver the message twice.

Delivery failed after a 202 Accepted

A 202 only confirms acceptance for sending. If the message never arrived, the failure is asynchronous — look at the 30xxx delivery error on the recipient’s webhook or event, not at the HTTP response. Start with error_evidence.code:
  • 30001 — permanent rejection. Remove the address.
  • 30002 — temporary; Telnyx is already retrying. Do not resubmit.
  • 30004 — the recipient was suppressed before any attempt.
  • 30005 — retries were exhausted; the recipient status is expired.

Inboxes

Manage Inboxes

Source: https://developers.telnyx.com/docs/messaging/email/inboxes.md
Email inboxes let your application receive, read, and reply to email messages through the Telnyx API. Each inbox is an address on a verified sending domain that you own, or on Telnyx’s shared inbound subdomain for instant setup with no DNS configuration.

Create an inbox

Create an inbox by specifying a local part (username) and a domain you’ve already verified for inbound email. You can also omit domain_id to use the account’s shared inbound subdomain — no DNS setup required.
curl
The response returns the inbox object with its full email address (e.g. support@yourdomain.com), status, and settings. To create an instant inbox on the shared subdomain, omit both fields:
curl

List and search messages

List messages in an inbox with optional search and filtering:
curl
You can filter by:
  • Search textfilter[search] matches subject and message body
  • Senderfilter[from] matches the sender address
  • Labels — only messages with specific labels
  • Date range — messages received within a time window
  • Read/unread — filter by read status
Results are cursor-paginated, newest first. Message bodies are returned as text_body_url and html_body_url — URLs you fetch separately. Headers, attachments, and labels are included inline.

Reply to a message

Reply to a message with a single call. The original message’s In-Reply-To and References headers are set automatically:
curl
Use the reply_all action to reply to all recipients. Both text and html are optional, but at least one must be present.

Forward a message

Forward a message to new recipients. The forwarded message body is prepended with your optional note:
curl

Drafts

Create a draft, update it, and send it when ready:
curl
You can also create a reply draft linked to a specific message using the POST /email_inboxes/{inbox_id}/messages/{message_id}/drafts endpoint.

Threads

Messages are grouped into threads by conversation. List threads in an inbox:
curl
Get a thread with a page of its messages:
curl
You can also list threads across all inboxes in the account with GET /email_threads.

Labels

Organize messages and threads with labels:
curl
Labels are mutable string tags — use them for categories, priority, workflow state, or anything your application needs. Thread labels are managed with the equivalent /threads/{thread_id}/labels endpoints.

Sender filters

Control which senders can deliver to an inbox. Set a top-level type of allowlist or blocklist, and provide entries as an array of strings — exact email addresses or @domain wildcards:
curl
Use PUT to replace all filters at once, or DELETE to remove specific entries.

Mark messages as read or unread

Update the read state of a message. Set read_at to true for the current timestamp, an ISO-8601 string for a specific time, or null to mark as unread:
curl

Domains & Validation

Email Domains & DKIM

Source: https://developers.telnyx.com/docs/messaging/email/domains.md
Manage the sending domains that authenticate your email with the Telnyx Email API. This guide covers registering a domain, publishing the DNS records Telnyx generates for you, verifying them, reading domain health, how DKIM signing works, and the settings you can change after a domain is verified. All requests use the production base URL https://api.telnyx.com/v2 and an Authorization: Bearer *** header.

Why domain verification

Every email you send is authenticated by the receiving server against three DNS standards:
  • SPF — confirms Telnyx is an authorized sender for your domain.
  • DKIM — proves the message body and headers weren’t altered in transit, using a cryptographic signature keyed to a DNS record you publish.
  • DMARC — tells receivers what to do when SPF or DKIM alignment fails (report, quarantine, or reject).
Without these records, recipient providers flag your mail as spam or reject it outright. Telnyx generates the exact DNS records you need for every domain you register, so you only have to copy them to your DNS provider.

Custom vs shared domains

Telnyx provides two kinds of sending domains: Shared domains are a restricted zero-setup onboarding resource: they’re verified at provisioning time and need no DNS setup, but the from address must be onboarding@ and every recipient must match the account owner’s verified email address. Use a custom domain for arbitrary recipients, your own from address, and control over sender authentication and domain reputation. Trial accounts are restricted to the account owner’s verified email address as the recipient regardless of domain type — registering and verifying a custom domain does not lift the restriction. Sends to any other recipient are rejected with 403 and code 10007. Upgrade the account to send to arbitrary recipients. Shared domains are read-only for accounts that don’t own them. A PATCH, DELETE, or POST …/verify on a shared domain you don’t own returns 403 with error code 10008.

Register a domain

Register a custom sending domain with POST /email_domains. Only domain is required; the optional fields configure inbound routing, your DMARC policy, and tracking:
curl
A new domain starts with status: "pending" and usable_for_sending: false. Telnyx generates a DKIM key and the five DNS records at create time, and a background verification worker runs shortly after — but you’ll still need to publish the records first. The effective tracking defaults for a new domain are open_tracking: false, click_tracking: false, and unsubscribe_tracking: true. Open and click tracking are opt-in; one-click unsubscribe (RFC 8058) is on by default because Gmail and Yahoo bulk-sender rules require it. See the Send Email guide for what each toggle does. Tracking is scoped to the sending profile, not to the domain. Tracking settings are stored on your account’s default sending profile, which every domain on the account shares. Supplying tracking at registration therefore only succeeds for the first domain on an account — once a default profile exists, POST /email_domains with tracking is rejected with a validation error telling you to use PATCH /email_domains/{id} instead.

DNS records

Every domain gets five DNS records. Fetch them with GET /email_domains/{domain_id}/dns_records and publish them at your DNS provider:
curl
Each record in the response has host, value (what to publish), actual_value (what Telnyx last observed in DNS), record_type, priority, required, and status. The required records are ownership and DKIM — plus MX when inbound_enabled is true (mx shows not_required when inbound is off). SPF and DMARC are recommended (marked required: false) but strongly advised for deliverability — omitting them won’t block verification, but recipient providers will treat your mail less favorably. What each record does:
  • Ownership — proves you control the domain. The value contains the domain’s id.
  • SPF — authorizes Telnyx’s mail servers to send on your domain’s behalf. If you send through multiple providers, add the include:spf.telnyx.com directive to your existing SPF record rather than creating a second one (multiple SPF records invalidate each other).
  • DKIM — publishes the public key that corresponds to Telnyx’s DKIM signing key. The host uses the DKIM selector (default telnyx1).
  • MX — directs inbound mail to Telnyx. Required only when inbound_enabled is true; shows not_required in the health check when inbound is off.
  • DMARC — the advisory default is p=none (monitor only) with aggregate reports sent to mailto:dmarc@telnyx.com. You can publish a stronger policy (p=quarantine or p=reject) — Telnyx verifies any valid v=DMARC1 record, not just its own recommended value.

Verify and the status lifecycle

Once you’ve published the DNS records, trigger verification:
curl
The response returns the updated domain. A 200 does not by itself mean success — the same 200 can report verified, pending (records not yet observed), or failed (a required record is missing or wrong). Only "status": "verified" means all required records passed and the domain is usable for sending.

Status values

A domain’s status field can be one of: verifying is a reserved value in the status enum but is never assigned by any production code path. Verification is synchronous: POST /verify returns the domain already resolved to verified, failed, or pending. Don’t write client logic that waits for verifying. degraded is a soft failure. A degraded domain was once verified but a required record has since failed — Telnyx’s drift monitor re-checks verified domains periodically and transitions them to degraded when a previously-passing required record now fails. Only ownership and DKIM (plus MX when inbound is enabled) are marked required, so only those can degrade a domain; drift on the optional SPF or DMARC records is a deliverability warning that does not by itself affect sendability. A degraded domain cannot send. It can receive inbound mail only when inbound is enabled and the response still reports usable_for_inbound: true. Fix the DNS records and re-verify to return to verified. The drift monitor only re-checks domains in verified or degraded status. When a degraded domain’s records recover, it’s automatically transitioned back to verified. The lifecycle events email_domain.verified, email_domain.degraded, and email_domain.suspended are emitted to configured webhooks on each transition.

Domain health

For a compact, health-focused view, use GET /email_domains/{id}/health:
curl
The verification object shows the per-record status: verified, failed, pending, not_required (e.g. MX when inbound is off), or missing_optional (SPF/DMARC when the optional record is absent). Use GET /health for dashboards and monitoring — it’s lighter than the full domain object (no nested dns_records or dkim detail). Use GET /email_domains/{id} when you need the actual DNS record values to publish or the DKIM selector.

DKIM

DKIM (DomainKeys Identified Mail) lets receivers verify that a message wasn’t altered after it was sent. Telnyx signs every outgoing message with the domain’s active DKIM private key; the recipient looks up the corresponding public key in your DNS at ._domainkey..
  • Algorithm: rsa-sha256
  • Key length: 2048-bit RSA
  • Selector: telnyx1 (the default; appears in the DKIM record’s host)
  • DNS record value: v=DKIM1; k=rsa; p=
The active key is reflected on the domain object in dkim:

Key management

DKIM keys are generated and managed server-side by Telnyx. A 2048-bit RSA key pair is created when you register the domain, the private key never leaves Telnyx, and the matching public key is published in the DKIM DNS record you copy to your provider. There is no customer-facing key rotation endpoint — the public API exposes no route to rotate, revoke, or replace a DKIM key. The dkim.rotated_at field reflects the active key’s revocation timestamp and is null for a normal active key. If you need a key replaced, contact Telnyx support.

Domain settings

Update mutable settings with PATCH /email_domains/{id}:
curl
The domain itself, status, account_id, and profile_id are not settable via PATCH. Reputation fields are also rejected on the public API — they’re computed internally (see Reputation).

Delete a domain

curl
Verified or degraded domains require force=true; pending or failed domains can be deleted without it. A suspended domain can be deleted without force. A successful deletion returns 204 No Content; no domain body is returned. Deleting a shared domain you don’t own returns 403 (code 10008).

Troubleshooting

Verification fails to reach verified

If POST /verify returns a domain that’s still pending or failed, check the verification map and the dns_records array:
  • Propagation delay — DNS changes can take minutes to hours to propagate. Wait and retry. The actual_value field shows what Telnyx last observed; if it’s null, the record hasn’t been seen yet.
  • Wrong host format — publish the record under the exact host shown (e.g. telnyx1._domainkey.example.com for DKIM, not _domainkey.example.com). Don’t append your domain to an already-fully-qualified host.
  • Missing required records — ownership and DKIM must verify (plus MX if inbound_enabled). SPF and DMARC are optional but recommended.
  • SPF with multiple providers — if you already have an SPF record, merge the include:spf.telnyx.com directive into it. Two SPF records invalidate each other.
  • DMARC — Telnyx verifies any valid v=DMARC1 record, so a custom policy (e.g. p=reject with your own rua) verifies fine. A malformed record reports failed.

Recovering from degraded

A degraded domain was verified but its DNS drifted. To recover:
  1. Fetch the records: GET /email_domains/{id}/dns_records.
  2. Compare each record’s value (expected) against actual_value (observed) — any status: "failed" record has drifted.
  3. Correct the record at your DNS provider.
  4. Re-verify: POST /email_domains/{id}/verify. On success the domain returns to verified.
The drift monitor also re-checks degraded domains periodically and will auto-recover them once DNS matches. A suspended domain cannot be recovered via verification — the verifier short-circuits and won’t unsuspend. Suspension is an admin action; contact Telnyx support if your domain is suspended.

Reputation

Each domain response includes a reputation object computed by Telnyx from your sending behavior:
Reputation is read-only on the public API — it’s updated internally via a daily computation job, not via PATCH. A domain in the poor band will have sending suspended with a reputation_suspended (429) error; see the Send Email guide for error details.

Email Validation

Source: https://developers.telnyx.com/docs/messaging/email/validation.md
Validate email addresses before you send to reduce bounces, keep your sender reputation healthy, and avoid suppressing recipients unnecessarily. Telnyx runs five checks per address and returns a structured result you can act on. Typical uses:
  • Pre-send hygiene — check an address a user just typed in before you send to it, and offer a correction if they mistyped their provider.
  • List cleaning before import — run a batch over a list you’re about to import so you don’t seed suppressions with addresses that can’t deliver.
  • Reducing bounces — a high hard-bounce rate hurts your domain reputation and can trigger suppression of otherwise-good recipients; validating first lets you drop the ones that fail.
All requests use the base URL https://api.telnyx.com/v2 and an Authorization: Bearer *** header.

What’s checked

Every address is run through five checks. A failed syntax check short-circuits the rest (no DNS lookup is performed), and those checks return pass: false with a details note that they were skipped. A result is valid: true only when syntax, mx, and disposable all pass. role_based and typo never flip valid to false — they’re signals you choose how to act on.

Validate one address

POST /email_validations validates a single address and returns synchronously. Any non-empty string is accepted; invalid syntax returns valid: false rather than a request error.
curl
Response (200):
This is the classic “likely typo” result: the address is technically deliverable (valid: true, syntax and MX pass), but the domain is one edit away from gmail.com, so the typo check fails and contributes 0.1 to risk_score. Prompt the user to confirm did_you_mean before sending. A typo domain is not automatically a valid domain. Some common misspellings — gmial.com among them — are on the disposable blocklist, which makes disposable.pass: false and flips the whole result to valid: false with risk_score: 0.7 (0.6 disposable + 0.1 typo). Always read valid and the individual checks rather than assuming a typo suggestion implies an otherwise-good address.

Result fields

When syntax fails, the remaining checks are skipped and return pass: false with details: "Skipped: syntax failed". risk_score is 1.0 in that case.

Validate in batch

POST /email_validations/batch creates an asynchronous job for up to 1,000 addresses. The request returns immediately (202) with a batch ID you poll for results. Optionally pass a webhook_url to receive a POST when the batch completes.
curl
Response (202):

Batch input rules

  • Max size: 1,000 emails. Requests with more (before or after dedup) return 400.
  • Deduplication: emails are trimmed, blank strings are dropped, and the remainder is deduplicated case-insensitively (so "User@Example.com" and "user@example.com" count as one). duplicates_removed is the number of duplicates plus blanks discarded.
  • All-blank input: returns 400 (emails array must contain at least one email address).
  • webhook_url: optional HTTP(S) URL, max 2048 characters. Empty string is treated as omitted. SSRF-protected — private/reserved IPs and internal hostnames are rejected at creation and re-checked at delivery time. webhook_url is omitted from both responses entirely (not null) when unset.

Poll for results

GET /email_validations/batch/{id} returns the batch status and, once completed, a results map keyed by email address.
curl
While processing (status: "pending" or "processing"):
When complete (status: "completed"), results and completed_at appear:
Batch status values: pending, processing, completed, failed. If the worker exhausts its retries, the batch is marked failed; a failed batch has no results map. Each result object has the same email, valid, risk_score, did_you_mean?, and checks shape as the single-address response. Note that the GET response does not include duplicates_removed (that’s only on the create response).

Completion webhook

When you provide a webhook_url, the worker sends a POST to it once the batch finishes processing. The payload is a summary — the full per-address results come from polling the GET endpoint:
Webhook delivery is best-effort: a non-2xx response or a network failure is logged but doesn’t fail the batch. The webhook is sent before the batch record is marked completed, so a worker crash during delivery can cause Oban to retry and resend the webhook.

Interpreting results

Use the checks to decide what to do with an address — validation is a strong signal, not a delivery guarantee. Validation checks DNS records and known blocklists at validation time. It does not verify that a mailbox exists or that a message will be accepted by the receiving server. A valid: true address can still bounce if the mailbox is full, disabled, or behind a greylisting/accept-all policy. Treat validation as pre-send hygiene, not a delivery guarantee.

Deliverability

Deliverability & Warm-up

Source: https://developers.telnyx.com/docs/messaging/email/deliverability.md
Good deliverability is the difference between email that reaches the inbox and email that lands in spam — or never arrives. This guide covers the practices that matter most for Telnyx Email API senders.

Verify your sending domain

The single most important deliverability step is domain verification. Recipient providers (Gmail, Outlook, Yahoo) check SPF, DKIM, and DMARC records before accepting mail.
  1. Register your domain via POST /v2/email_domains.
  2. Publish the DNS records Telnyx generates for you — ownership (TXT) and DKIM (TXT on ._domainkey.) are required — plus MX when inbound_enabled is true; SPF and DMARC are strongly recommended. Fetch the exact values from GET /v2/email_domains/{domain_id}/dns_records and publish them as-is.
  3. Verify with POST /v2/email_domains/{domain_id}/verify after DNS propagates (usually 5–15 minutes; can take up to 48 hours).
  4. Use a shared domain for a controlled onboarding test — it needs no DNS setup, but requires onboarding@ as the sender and the account owner’s verified email address as every recipient.
Two separate background workers watch your DNS, on different schedules. Initial verification retries every 30 minutes after registration, so a domain whose records propagate late will verify on its own without another API call. Once verified, a distinct drift monitor re-checks the published records every 6 hours and transitions the domain to degraded if they stop matching. A record you delete or change after verification is therefore detected within hours, not immediately. You can always force an immediate check with POST /v2/email_domains/{domain_id}/verify. Unverified domains cannot send. A send from an unverified domain returns 403 with error code 10007 and a domain-not-verified detail — verify the domain first.

DKIM signing

Telnyx attempts to DKIM-sign every outbound message using the key generated for your domain at registration time. DKIM signatures let receiving servers verify the message wasn’t tampered with in transit. DKIM signing is best-effort for custom domains, not guaranteed. The outbound MTA fails open for custom sending domains: if no key is available or the signing operation errors, the message is still sent — unsigned — rather than being rejected. A verified custom domain with a correctly published DKIM record signs reliably, but you should not assume every delivered message carried a valid DKIM signature. The shared sending domain mail.telnyx.com requires successful DKIM signing. If signing fails for a shared-domain send, the message is rejected rather than sent unsigned. This does not affect custom domains. Verify real-world signing with a seed test to a mailbox you control and inspect the received headers, or monitor DMARC aggregate reports.
  • Dynamic DKIM: Telnyx generates a DKIM keypair automatically during domain registration. No manual key generation is needed.
  • Key rotation: Keys are generated at provisioning time. There is no automatic rotation schedule and no customer-facing rotation endpoint in the current service. Key storage is append-only — creating a new key retires the previous one to a retiring state — but that path is operator-initiated, not scheduled. If a key is ever rotated, the DKIM DNS record’s value changes and you must republish it; the drift monitor will transition the domain to degraded until the published record matches. See Domains & DKIM for details.

SPF

Publish the SPF record Telnyx generates exactly as specified:
If you send through multiple providers, add the include:spf.telnyx.com directive to your existing SPF record rather than creating a second one — multiple SPF records invalidate each other.

DMARC

DMARC tells receiving servers what to do when SPF and/or DKIM fail. DMARC is advisory — it is not required for verification — but publishing it is strongly recommended. Telnyx generates a recommended DMARC record for your domain, and it is returned alongside your other records by GET /v2/email_domains/{domain_id}/dns_records. The service default is:
The default aggregate-report address is dmarc@telnyx.com, which routes reports to Telnyx. Publish the generated value as-is if you want Telnyx to receive them. If you’d rather collect reports yourself, point rua at your own mailbox instead — for example rua=mailto:dmarc@yourdomain.com, or list both addresses comma-separated. Always publish the record returned by the API rather than hand-authoring one, so the policy tags stay aligned with what Telnyx expects.
  • p=none is a monitoring-only policy — collect reports without affecting delivery.
  • Move to p=quarantine or p=reject only after reports show alignment is high.

Manage your sender reputation

Every sending domain builds a reputation with receiving providers. High reputation → inbox. Low reputation → spam folder or rejection.

Warm up your sending domain

Inbox providers build reputation for both the sending IP and the authenticated domain. Telnyx manages the shared sending infrastructure; you build your domain reputation through recipient quality, consistent volume, and wanted email. Warm up when you:
  • Send from a new domain or subdomain.
  • Move an established domain to Telnyx or change sending providers.
  • Resume sending after a long inactive period.
  • Increase your normal volume substantially.
Low-volume transactional senders may warm naturally as real usage grows. Do not generate artificial traffic just to follow a schedule.

Before you increase volume

  • Send from a dedicated subdomain, such as mail.example.com, that you do not use for employee email.
  • Keep transactional and marketing traffic on separate subdomains when possible.
  • Verify the domain and publish the SPF, DKIM, and DMARC records returned by Telnyx.
  • Start with recipients who explicitly opted in and recently engaged with your product.
  • Remove invalid addresses and honor all bounces, complaints, and unsubscribes.
  • Configure webhooks or event polling before the first production send.
  • Register your authenticated domain with Google Postmaster Tools and use any other mailbox-provider reporting available to you. Provider-reported spam rates are not the same as Telnyx complaint events.

Conservative starting schedule

There is no universal warm-up schedule. Mailbox providers evaluate Gmail, Microsoft, Yahoo, and other traffic independently, so review each provider before advancing. Use this as a starting point for a new domain with a clean, opted-in audience: These are reputation-based ceilings, not Telnyx account quotas. If your account’s sending quota is lower, the lower limit applies. See Rate Limits & Quotas. In this schedule, one message means one recipient delivery. Every address in to, cc, and bcc counts toward the daily ceiling for that recipient’s mailbox provider. It does not mean one API request. The Email API rejects the same normalized recipient address appearing more than once across those fields. A schedule is a ceiling, not a promise. Do not increase volume simply because another day passed. Hold or reduce it when delivery signals worsen. If your normal traffic is below these amounts, send only real, wanted traffic appropriate to that sending stream. Never purchase recipients, send to fake addresses, or create synthetic engagement to warm a domain.

Migrating an established domain

An established domain may retain some reputation history, but a new sending provider introduces new infrastructure and a changed sending pattern. Keep the previous provider available during the transition, then move a small portion of wanted traffic to Telnyx and increase it gradually. Preserve your recognizable From address and authentication alignment throughout the migration.

Decide whether to increase, hold, or pause

Review delivery events by mailbox provider at least daily during warm-up: GET /v2/email_events does not provide a mailbox-provider filter. Group events using the domain in payload.recipient and, when present, payload.mx_hostname. Multiple recipient domains can belong to the same provider—for example, Outlook, Hotmail, and Live are all Microsoft traffic. A receiving server accepting a message means it was delivered to that server, not necessarily placed in the inbox. Provider reputation tools and controlled inbox-placement tests can supply additional visibility. Use provider-reported spam rates and Telnyx complaint events together. Google’s thresholds refer to the spam rate reported in Google Postmaster Tools, while Yahoo calculates its spam rate from mail delivered to the inbox. Telnyx’s complaint_rate is the percentage of delivered recipients for whom Telnyx received an individual feedback report. Reporting coverage still differs by provider, so the metrics are not directly comparable.

Avoid these warm-up mistakes

  • Sending your full audience on the first day.
  • Warming with purchased, scraped, old, or unengaged lists.
  • Sending only to Gmail while assuming other mailbox providers are warming too.
  • Retrying permanent failures or suppressed recipients.
  • Mixing risky marketing campaigns with password resets, receipts, or other transactional mail.
  • Increasing volume while deferrals, bounces, or complaints are getting worse.

Keep bounce rates low

  • Hard bounces (permanent failures like 5.1.1 User unknown): Remove the recipient immediately. High hard-bounce rates destroy reputation.
  • Soft bounces (temporary failures like 4.2.1 Mailbox full): Telnyx retries deferred deliveries automatically. Do not resubmit the message in response to an email.deferred event.
email.bounced is broader than “the receiver permanently rejected it.” Four distinct outcomes publish the same event: an ordinary bounce, a queue expiration (retries exhausted), an administrative bounce, and an asynchronous out-of-band bounce. The per-recipient status and error_evidence.code tell you which actually happened — expiration resolves the recipient to expired (code 30005), an administrative bounce to failed, and the others to bounced (code 30001). Don’t infer “invalid address” from the event type alone; read the evidence. See Webhooks & Events and the 30xxx taxonomy.

What actually gets auto-suppressed

Telnyx does not suppress every bounced recipient. Auto-suppression is deliberately conservative so a transient or sender-side failure doesn’t permanently block a valid address: So a recipient can bounce, be recorded with status: "bounced", and still not be added to your suppression list. Check the suppression list rather than assuming a bounce implies suppression, and remove invalid addresses from your own source of truth regardless.

Handle complaints

When Telnyx receives an individual spam feedback report from a participating mailbox provider, Telnyx:
  1. Records an email.complained event.
  2. Auto-suppresses the recipient (added to suppression list).
  3. Fires a webhook if configured.
Not every mailbox provider supplies individual feedback reports, so email.complained events may not capture every user spam report. Monitor provider dashboards in addition to Telnyx events. Treat a provider-reported spam rate approaching 0.1% as a signal to stop increasing volume and investigate. Gmail and Yahoo require senders to remain below 0.3%, but operating close to that limit puts deliverability at risk.

Monitor sending volume

Sudden spikes in volume trigger spam filters. Use scheduled_at to schedule sends across time windows rather than blasting everything at once. See Scheduled sending.

Content best practices

Include a plain-text alternative

Provide a plain-text version for accessibility and email clients that prefer it. Keep the HTML and plain-text versions consistent:

Use a recognizable from name

Recipients who recognize the sender don’t mark mail as spam. Use a consistent from address and name:
Every marketing email must include a visible, working unsubscribe link. Telnyx supports RFC 8058 one-click unsubscribe — when a recipient clicks unsubscribe in their email client, Telnyx:
  1. Records an email.unsubscribed event.
  2. Auto-suppresses the recipient.
  3. Fires a webhook if configured.

Keep content clear and recognizable

  • Use an honest subject line that matches the message content.
  • Avoid deceptive formatting, misleading links, and artificial urgency.
  • Use accessible layouts, descriptive link text, and alt text for meaningful images.
  • Keep branding and sender identity consistent so recipients recognize the message.
  • Test rendering across the email clients your recipients use.

Tracking and monitoring

Telnyx provides delivery events across the message lifecycle. Key events for deliverability monitoring: Delivery webhooks are recipient-scoped: one recipient produces one callback with its own recipient_id and its own status. A send to five addresses produces five email.delivered callbacks, not one. See Webhooks & Events.

Webhooks

Configure webhooks on your sending domain to receive delivery events in real time. See Webhooks & Events.

Event polling

Poll delivery events with GET /v2/email_events — useful for agents and services without a public webhook URL:
curl
The message filter parameter is email_id, not message_id or email_message_id. Unrecognized query parameters are silently ignored, so a wrong name returns every event for the account rather than an error.

Message size limits

Oversized messages are rejected before sending, and large messages hurt deliverability regardless. Three ceilings apply: Attachments are base64-encoded in transit, so a 25 MB message occupies roughly 33 MB on the wire — Telnyx measures the decoded size. Keep production messages far below these ceilings: large images inflate spam scores and slow rendering. Host images and link to them rather than attaching them. See Error Codes for the full breakdown.

Shared domains

If you don’t want to manage DNS, use a Telnyx shared domain as a restricted onboarding option:
  • Pre-verified — no DNS setup required.
  • From-address restricted — use onboarding@.
  • Recipient restricted — every recipient must match the account owner’s verified email address.
  • Read-only — you can’t modify or delete a shared domain.
  • Ideal for controlled onboarding tests to the account owner’s verified address.
Register a custom domain when you need your own from address, control over sender authentication and domain reputation, and higher sending volume. See Domains & DKIM.

Deliverability Best Practices

Source: https://developers.telnyx.com/docs/messaging/email/deliverability-best-practices.md
This page explains why deliverability works the way it does — the mental model behind inbox placement, and how to read Telnyx delivery signals when mail fails. For step-by-step setup instructions, see Deliverability and Domain Warm-up. Deliverability is not a feature you enable. It is a judgment that thousands of independent receiving systems make about your mail, message by message, using signals you control only indirectly. Understanding how that judgment is formed is what lets you diagnose a problem instead of guessing at it.

How receiving providers evaluate mail

No mailbox provider publishes its filtering algorithm, but the evaluation consistently happens across four layers. A message must satisfy all four — strength in one layer does not compensate for failure in another.

Authentication: proving the mail is really yours

Authentication answers a single question: is this sender permitted to use this domain? Providers check three mechanisms, and each proves something different. SPF authorizes sending IPs. Your domain publishes a DNS record naming which servers may send on its behalf, and the receiver compares the connecting IP against that list. SPF alone is fragile — it breaks when mail is forwarded, because the forwarding server isn’t in your record. DKIM proves message integrity. Telnyx signs each outbound message with a private key; the receiver fetches the matching public key from ._domainkey. and verifies the signature. Because the signature travels with the message, DKIM survives forwarding where SPF does not. This is why DKIM is the load-bearing authentication mechanism for most senders. DMARC ties the first two to the domain your recipient actually sees. SPF and DKIM validate technical identifiers that a recipient never reads. DMARC requires that one of them aligns with the visible From: domain, and tells receivers what to do when alignment fails. DMARC is the only mechanism that constrains the From: header your recipients see. A message can pass SPF and DKIM for an attacker-controlled domain while displaying your brand in the From: field — alignment is what closes that gap. This is why publishing SPF and DKIM without DMARC leaves the impersonation problem unsolved.

Reputation: the accumulated record

Authentication proves identity. Reputation determines whether that identity is welcome. Providers maintain scores for both the sending IP and your authenticated domain, built from behavior over time. The signals that matter most are the ones recipients generate: opening messages, replying, moving mail out of spam — and the negative ones, marking as spam or deleting unread. Bounce rate matters heavily because sending to addresses that don’t exist is the clearest available evidence that a list was not built from genuine consent. Reputation is asymmetric. It accrues slowly through consistent, wanted mail and collapses quickly after a bad send. It is also per-provider — a strong Gmail reputation tells Microsoft nothing.

Content: what the message itself signals

Content filters look for the statistical fingerprint of unwanted mail: mismatches between subject line and body, link shorteners obscuring destinations, image-only messages carrying no analyzable text, malformed HTML, and missing plain-text alternatives. The underlying logic is that legitimate senders have no reason to obscure what they’re sending. Most content heuristics detect evasion rather than specific words.

Infrastructure: whether the sending setup looks legitimate

Receivers check operational hygiene: does the sending IP have a reverse DNS (PTR) record that resolves back consistently? Does the domain have valid MX records? Is TLS offered? Is volume steady, or does it spike unpredictably? Telnyx operates and maintains this layer for you. It matters to your mental model because it explains why a brand-new domain with perfect DNS still doesn’t reach the inbox reliably: the infrastructure is trusted, but your domain’s history on it is empty.

The delivery lifecycle and where it fails

A Telnyx send passes through distinct stages, and knowing which stage failed determines what — if anything — you should do about it. email.queued does not mean the message reached the MTA. It fires as soon as the API has validated the request and persisted the message — before the message is produced to the internal queue and before the outbound MTA has seen it. email.sent does not mean the recipient received the message either: it means the MTA accepted the message for delivery. Remote acceptance is reported separately by email.delivered. Treating queued or sent as success is the most common source of inflated delivery numbers. Two boundaries are worth internalizing: Deferred is not failure. A 4xx response means “not now” — the remote server is throttling, greylisting, or temporarily unavailable. Telnyx retries automatically. Resubmitting a deferred message creates duplicates and worsens the throttling that caused the deferral. Delivered is not “in the inbox.” A 250 response means the receiving server accepted custody. Placement — inbox, promotions, or spam — happens after acceptance and is never reported back over SMTP. This is why provider-side tools like Google Postmaster Tools show you something your delivery events structurally cannot.

Suppression: the send that never happens

When a recipient is on your suppression list, the message is not attempted at all — and the recipient is removed from the send before any per-recipient record exists. Telnyx suppresses automatically after a qualifying hard bounce or a spam complaint, so the platform enforces the list hygiene that protects your reputation. Suppressed addresses are stripped from to, cc, and bcc during request handling, so they never get a recipient row and never produce a delivery attempt. If every recipient on the request is suppressed, the API rejects the request with recipient_suppressed and no message, recipient row, or detail record is created at all. The response includes a top-level suppressed array naming the blocked addresses.

Understanding delivery error codes

Telnyx reports failures at two distinct levels. Confusing them sends you looking in the wrong place.

API-level errors (10xxx)

These occur before a message exists — your request was rejected. They are about your API call, not about deliverability. 10007 is a service-wide code, not a domain-authentication signal. Several unrelated policy failures share it, so branching on the code alone will mislead you — always inspect the detail string (and, where present, source) to identify the actual cause before acting on it. 10007 and reputation_suspended are the two that most often carry genuine deliverability meaning: the first frequently indicates your domain isn’t verified or your from address isn’t authorized, the second says your reputation has already degraded far enough that Telnyx stopped sending. See Error Codes for the complete reference.

Delivery-level errors

These occur after the message was accepted — the send happened and delivery failed. bounce_category appears on email.bounced events and identifies why the bounce occurred: permanent is the default category assigned to any Bounce record — it is derived from the record type, not from the SMTP code. A permanent bounce is therefore not the same thing as a confirmed invalid address, and it does not by itself mean the recipient was suppressed. Suppression is decided separately, by narrower enhanced-code rules, and it happens asynchronously. transient as a bounce category is likewise not the same as an email.deferred event. A deferral is still being retried. A transient bounce means retrying already happened and failed — the message is permanently undelivered even though every individual failure was temporary. error_evidence is the structured error field on the Email Detail Record (EDR). It is populated for every error status — bounced, failed, deferred, expired, suppressed, and gw_reject — and is null for successful ones: error_evidence.code is always a normalized 30xxx code — never a raw SMTP code. The raw SMTP response lives in smtp_status. If your integration matches on code == "550" it will never fire; match on code == "30001" and read smtp_status when you need the remote server’s exact response.

Normalized delivery error codes (30xxx)

Raw SMTP codes are a poor integration surface: they vary per remote MX, they collide across unrelated failure modes, and they are absent entirely for failures that never reached SMTP (queue expiry, suppression, gateway rejection). Telnyx therefore normalizes every delivery failure into a small product-level taxonomy. The status is the primary axis of classification; the SMTP code only disambiguates where one status covers both a permanent and a transient failure. A bounced recipient carrying a 4xx response is classified 30002 (retryable) rather than 30001, because the remote server’s own code says the rejection was temporary. The message field is the most diagnostically useful and the least structured. It carries whatever delivery detail the callback supplied, and its provenance depends on who generated the failure: an SMTP rejection carries the receiving provider’s own free text — frequently a URL explaining a block, or a specific reason the numeric code cannot convey — while an operator-initiated failure such as an admin bounce carries Telnyx-generated text instead. Do not assume the string came from the remote host. Failures that never produced a callback at all — suppression and gateway rejection — have no delivery detail to carry, so message is null there rather than fabricated. Enhanced codes drive suppression. Telnyx uses the X.Y.Z class — not the 30xxx code — to decide whether a bounce reflects a bad address or a policy decision. Codes in the 5.1.x (bad destination address) and 5.2.x (mailbox status — disabled, full) ranges indicate a recipient-side problem and qualify for auto-suppression. Codes in the 5.7.x range are security and policy rejections and are not suppressed, because the address may be perfectly valid while your sending configuration is not. A bounce with no enhanced code at all falls back to the raw SMTP code: 5xx qualifies for suppression, anything else does not. That distinction matters when you are diagnosing a spike in bounces. A wave of 5.1.1 responses means your list has decayed. A wave of 5.7.x responses means the receiver is rejecting your mail on security or policy grounds and the recipients themselves are probably fine.

Record status values

The EDR carries a status field describing the recipient’s delivery outcome. For outbound mail the possible values are queued, sending, sent, deferred, delivered, bounced, failed, expired, suppressed, cancelled, and gw_reject. Inbound records use received and delivered. Three values are easy to misread. failed means a sender-side or operator-side non-delivery — the remote MX never rejected the recipient — which is why an admin bounce maps to failed rather than bounced. expired is its own terminal status, not a flavor of failed: it means the MTA exhausted its retries and gave up (30005). gw_reject means the message was refused before entering the queue, and is never billable.

injection_timeout: a recipient state with no detail record

injection_timeout is a recipient and webhook state, not an EDR status. It occurs when the injection request to the MTA times out ambiguously — the MTA may or may not have accepted the recipient, and Telnyx cannot tell which. Retrying would risk a duplicate send, so the recipient is parked in this terminal state instead. Because the outcome is genuinely unknown, no detail record is emitted. The recipient enters a terminal injection_timeout state. Note: injection_timeout is not currently available as a subscribable webhook event type. If the MTA did in fact accept the message, its later Reception callback reconciles the recipient to sent and a detail record is published then. If no callback ever arrives, the recipient stays in injection_timeout and no EDR is ever produced. Reconcile on the webhook, not on the absence of a record. An injection_timeout webhook with no corresponding EDR does not mean the message failed — it means the outcome is not yet known. Treat the delivery outcome as unknown and do not retry — a later sent transition may reconcile the state, but if no callback arrives, injection_timeout remains terminal and no definitive delivery outcome is available.

Best practices, and the reasoning behind them

Authentication — Publish SPF, DKIM, and DMARC, and verify your domain. DMARC is what makes the other two meaningful to a receiver evaluating the From: header your recipient reads. Reputation — Increase volume gradually and keep bounce and complaint rates low. Providers evaluate rate, not count: a hundred bounces out of a hundred sends is catastrophic, while a hundred out of a million is unremarkable. Content — Always send a plain-text alternative alongside HTML, keep subject lines honest, and avoid obscuring links. Filters are detecting evasion; give them nothing to detect. List hygiene — Remove hard bounces, honor unsubscribes including RFC 8058 one-click, and let suppression lists do their job. Every send to a dead address is evidence that your consent process is weak. Monitoring — Subscribe to bounce and complaint webhooks before your first production send. Deliverability problems compound: by the time you notice degraded delivery without instrumentation, the reputation damage is already done.

What to do when delivery fails

The general rule: 4xx means wait, 5xx means stop. Telnyx handles the waiting for you, and handles the stopping by suppressing recipients whose bounces qualify under the enhanced-code rules above. Your job is the layer neither of those can address — understanding why the address was bad or the content unwanted in the first place.

Templates & Suppression

Templates

Source: https://developers.telnyx.com/docs/messaging/email/templates.md
Email templates store reusable subject and body content so you can send the same message to many recipients without re-sending the content on every request. Templates use Liquid variables — {{first_name}} — so each send renders recipient-specific content from variables you supply at send time. Templates are useful when you:
  • Send the same message repeatedly — onboarding sequences, receipts, password resets.
  • Separate content from code — let designers edit email copy in a template while your application only passes variables.
  • Build agent workflows — an AI agent generates variable values, the template governs structure, and you render to validate the result before sending.
All requests use the base URL https://api.telnyx.com/v2 and an Authorization: Bearer *** header.

Create a template

Create a template with POST /email_templates. Only name is required; subject, html_body, and text_body are optional Liquid template strings.
curl
A successful create returns 201 Created with the template in data:
The variables array is stored on the template and returned on every read. Save the id — you’ll use it to render, send with, update, and delete the template. The variables field is descriptive metadata, not a constraint. Passing template_variables with keys that aren’t listed here is not an error — only the Liquid tags actually present in your template’s content are substituted. Use variables to document what a caller should supply.

Manage templates

List templates

List templates for your account with GET /email_templates. Pagination is cursor-based: page_size (default 25, clamped 1–100) and page_cursor.
curl
page_cursor in meta is the opaque cursor for the next page. It is omitted (not null) when there is no next page. Pass it back as the page_cursor query parameter to fetch the next page.

Get a template

Fetch a single template by id with GET /email_templates/{id}:
curl
Returns 200 with the template in data, or 404 if the template doesn’t exist or belongs to another account.

Update a template

Update a template with PATCH /email_templates/{id} or PUT /email_templates/{id}. Both verbs are served by the same partial-update handler, accept the same UpdateEmailTemplateRequest body, and return 200 with the updated template.
curl
Updatable fields: name, subject, html_body, text_body, variables. When you change subject or a body and don’t supply variables, the variable list is auto-extracted again from the new content. PUT is not a full replacement. PUT and PATCH are routed to the same handler, and both update only the fields you send — omitted fields are left unchanged on either verb. Sending PUT with a partial body will not clear the fields you left out. To clear a field, send it explicitly as null.

Delete a template

Delete a template with DELETE /email_templates/{id}:
curl
Returns 204 No Content with an empty body. Deleting a template does not affect messages already sent from it; it only prevents future sends and renders that reference the template_id.

Variables

Template content uses Liquid syntax. The renderer is the Solid Liquid engine.

What’s substitutable

Variable substitution applies to all three content fieldssubject, html_body, and text_body. Each is rendered independently from the same template_variables map.

Syntax

Missing-variable behavior

A variable you don’t supply renders as an empty string — it does not raise an error. If your template contains {{first_name}} and you send template_variables: {} (or omit the key entirely), the rendered output has an empty string in place of {{first_name}}. The render or send succeeds. This is by design: templates degrade gracefully so a partial variable set never blocks a send. If you need to enforce that a variable is present, validate the rendered output with the render endpoint before sending, or check your template_variables map in application code.

Auto-extraction limits

When you omit variables, Telnyx extracts the list from your template content with a small set of regexes. It recognizes exactly three shapes:
  • Unfiltered output tags — {{first_name}}first_name
  • Dot-notation roots in unfiltered output tags — {{user.name}}user
  • For-loop collections — {% for item in items %}items (the loop variable item is extracted only when separately referenced in an unfiltered output tag such as {{item.title}})
{% assign %} targets are excluded, since the template creates them rather than receiving them. Anything else is not extracted. Most importantly, an output tag containing a filter is skipped entirely: A filtered expression like {{user_input | escape}} renders correctly at send time but is not auto-extracted, so variables comes back empty for it. Because variables is descriptive metadata only, this never breaks a send — but it does make the template under-report what a caller must supply. Pass variables explicitly whenever your template uses filters, if/unless conditions, or other complex Liquid expressions.

Escaping and HTML safety

The renderer substitutes values as-is — it does not HTML-escape variables. If a variable value can contain untrusted content (for example, user-generated display names), escape it in your application before passing it in template_variables, or use Liquid’s escape filter in the template: {{user_input | escape}}.

Render and preview

POST /email_templates/{id}/render renders a template with a set of variables and returns the Liquid-rendered, pre-send content — without sending anything. This is the key capability that makes templates safe for automated and agent-driven workflows: you can inspect the rendered output, run assertions against it, and catch malformed variables before a single message goes out. The render output is the Liquid-rendered template. The send pipeline subsequently applies CSS inlining (when inline_css: true), click-link rewriting, and open-tracking pixel injection. Final recipient HTML may therefore differ from the preview. Use render to validate template structure and variable substitution, not as a byte-identical preview of the delivered message.

Request

Supply template_variables in the body. The body itself is optional — if you omit it, the template renders with an empty variable set.
curl

Response

A successful render returns 200 with the full template object plus the rendered subject, html_body, and text_body replacing the template-string versions:
The response merges the stored template (record type, id, name, variables, timestamps) with the rendered output. Compare the rendered subject/html_body/text_body against the stored Liquid versions to confirm substitution worked.

Errors

A 422 response from the standalone render endpoint uses the standard error envelope:
Note that missing variables are not an error — they render as empty strings. A 422 from the standalone render endpoint means the Liquid itself is syntactically broken or a filter threw; it uses the 10015 standard error envelope shown above.

Use cases

Build a preview pane in any email composer by calling render with draft variables. Because the response is plain JSON, you can render server-side and drop the HTML straight into an “ or your preview component — no client-side Liquid engine needed. The Liquid-rendered output matches the send path’s variable substitution; note that the send pipeline may further modify HTML (CSS inlining, tracking pixel) before final delivery. When an AI agent generates variable values, render the template before sending to confirm the output is well-formed — that names aren’t blank, that loop bodies populated, that no stray Liquid tags leaked into the rendered text. Treat the render call as a gate: only send once the rendered subject and body pass your assertions. Call render in a test or CI step against a fixture variable set whenever a template changes. A non-empty errors array, or a rendered body that diverges from a recorded snapshot, fails the check before the template reaches production.

Send with a template

Send using a stored template with POST /email_messages. Pass template_id and template_variables; the API renders the template server-side and sends the result. You only supply the addressing — from, to, and any send options like scheduled_at or inline_css. For safe retries, pass an Idempotency-Key HTTP header rather than a request-body field.
curl
When you send with template_id, subject is optional — the template’s rendered subject is used. If the template has no subject or it renders to an empty string, the request returns 400.

Precedence: template content and inline content are mutually exclusive

You cannot mix a template with inline content. If the request contains template_id and any of subject, html_body, or text_body, the API returns 400 with:
There is no merge or override — the presence of template_id means the template fully governs subject, html_body, and text_body. Other top-level fields (from, to, cc, bcc, reply_to, attachments, headers, tags, metadata, scheduled_at, inline_css) combine with the template normally. Use scheduled_at to schedule a templated send. send_at is a deprecated alias kept for backward compatibility — when both are supplied, scheduled_at wins, and responses always report the field as scheduled_at. Want to preview before sending? Call POST /email_templates/{id}/render with the same template_variables, inspect the output, then send. The render and send paths use the same Liquid engine and the same template, so the variable substitution matches. Note that the send pipeline may apply additional transformations (CSS inlining, click-link rewriting, open-tracking pixel) before final delivery.

Render errors at send time

If the template’s Liquid fails to render at send time, the request returns 422 with the send endpoint’s non-standard {code: "render_error", message: "..."} error shape. This differs from the standalone render endpoint, which returns 10015 with title and detail. A template that renders with missing variables as empty strings will still send — only a genuine Liquid parse or filter failure blocks the send.

inline_css with templates

inline_css is a send-time option on POST /email_messages, not a template property. When you set inline_css: true on a send that uses a template, the rendered HTML body has its “ blocks inlined into element style attributes before the message is sent. This works the same way whether you send inline HTML or via a template.

Suppressions & Unsubscribes

Source: https://developers.telnyx.com/docs/messaging/email/suppressions.md
Manage the Telnyx Email suppression list (blocklist) to protect your sender reputation. Suppressions stop Telnyx from sending to addresses that have bounced, complained, unsubscribed, or that you’ve manually blocked. This guide covers the suppression model, scope, CRUD operations, CSV import/export, send-time override behavior, unsubscribe groups, and the RFC 8058 one-click unsubscribe flow. All requests use the production base URL https://api.telnyx.com/v2 and an Authorization: Bearer *** header.

Suppression model

A suppression (block) is a record that prevents email from being sent to a recipient address. Telnyx creates suppressions automatically in response to deliverability signals, and you can create them manually via the API. Every suppression has a reason — the canonical enum string that describes why the block exists — and a source that records how it was created.

Reasons

The three non-overridable reasons (hard_bounce, spam_complaint, invalid) can never be bypassed by ignore_suppression — Telnyx will not deliver to those recipients regardless of the flag. The two overridable reasons (unsubscribe, manual_block) can be bypassed when you send with ignore_suppression: true and an API key that has the email:override scope. See Send-time behavior.

Sources

How auto-suppressions are created

Telnyx’s delivery event pipeline classifies incoming KumoMTA log records and enqueues a suppression worker for suppressible events:
  • Spam complaint — an ARF Feedback record creates a spam_complaint suppression.
  • Hard bounce — a Bounce or OOB record with a 5.1.x or 5.2.x enhanced status code, or a 5xx SMTP code with no enhanced code, creates a hard_bounce suppression.
  • Soft-bounce escalation — repeated TransientFailure (deferred) records for the same recipient increment a counter; when the threshold is breached, Telnyx escalates to a hard_bounce suppression.
  • Expiration — a KumoMTA Expiration record (system gave up retrying) creates a hard_bounce suppression.
  • Unsubscribe — a recipient unsubscribes via the RFC 8058 one-click flow or the unsubscribe link, creating an unsubscribe suppression (see RFC 8058 one-click unsubscribe).
AdminBounce records, 5.7.x enhanced codes (sender-side policy), and other unclassified records are not suppressed. The public API can only create manual_block suppressions. Any reason value you send to POST /email_blocks is ignored — the server forces reason: manual_block and source: manual. Auto-suppressions (hard_bounce, spam_complaint, unsubscribe) are created by Telnyx internally. CSV import is the one other path that can produce manual_block rows, with source: import.

Scope

A suppression’s scope determines which sends it blocks. Scope is derived server-side from the suppression’s domain_id and from fields — it’s never trusted from the client. There are exactly three scopes: At send time, Telnyx checks the recipient against all matching active suppressions. Account-scoped blocks match across all domains, but a non-null from still restricts matching to that sender. Domain-scoped blocks match when the send’s domain matches. Address-scoped blocks match when both domain and sender match.

group_id is a filter, not a scope

group_id is not a fourth scope — it’s an independent association that narrows which sends a suppression applies to, and it combines with whichever of the three scopes the record already has. A suppression with group_id: null applies to every send regardless of group; a suppression with group_id set applies only to sends that carry the same group_id. See Unsubscribe groups.

Manage suppressions

List suppressions

GET /email_blocks returns the account’s suppressions with offset or cursor pagination, sort, and filters.
curl
Offset-mode response (default):
Cursor-mode response (when you use page[after] or page[before]):
Cursor meta always carries page_size, next_cursor, previous_cursor, has_next, and has_previous. All five keys are always present in the response; when there is no next or previous page, the corresponding cursor is null (not omitted).

Retrieve a suppression

GET /email_blocks/{id} returns a single suppression by ID.
curl
Returns 200 with the suppression in data, or 404 if the ID doesn’t exist or belongs to another account.

Create a manual suppression

POST /email_blocks creates a manual_block suppression. The server forces reason: manual_block and source: manual regardless of any values you send.
curl
expires_at is not currently enforced. The field is accepted, stored, and returned, but no expiration process runs against it: the send-time suppression check matches on status = "active" alone and never compares expires_at to the current time. A suppression with a past expires_at keeps blocking sends. Treat expires_at as informational metadata and remove suppressions explicitly with DELETE /email_blocks/{id} when you want them to stop applying. Creating a suppression that already exists and is still active (same account, scope, recipient, reason, domain, from, and group) is idempotent — the API returns 200 with the existing record and creates no new audit event. If the matching record was previously removed, the create reactivates it instead: the existing row flips back to status: "active", the request’s expires_at is re-applied, a fresh created audit event is appended, and the API returns 201. The row is reused rather than duplicated, so the audit trail reads created → removed → created on the same suppression ID.

Delete a suppression

DELETE /email_blocks/{id} soft-deletes a suppression. The record is retained with status: "removed" and a removed audit event is appended.
curl
Returns 200 with the suppression (now status: "removed"). Deleting an already-removed suppression is idempotent — returns 200 with no new event.

Suppression audit events

GET /email_blocks/{id}/events lists the audit trail for a suppression. Each event records a lifecycle transition.
curl
Uses offset pagination (page[number] default 1, page[size] default 50, max 100). Events are ordered by occurred_at descending.

CSV import & export

Migrate an existing suppression list from SendGrid, Mailgun, or Amazon SES, or import a generic CSV. Export your Telnyx suppressions as CSV for backup or transfer.

Import a CSV

POST /email_blocks/import accepts a multipart upload with a CSV file and returns 202 with an async import job. The provider format is auto-detected from the CSV header row.
curl
Limits: decoded CSV up to 25 MiB and 250,000 data rows. Exceeding either returns 413. All imported suppressions are account-scoped (scope: account, domain_id: null, from: null).

Provider auto-detection

The importer detects the provider from the CSV header row (normalized to lowercase, trimmed): Detection order is SendGrid → Mailgun → SES → generic. An undetectable or header-only CSV returns 400. Rows with a missing or blank email address, or an unmappable reason, are skipped and recorded in the import’s errors map (keyed by row number, 1-indexed with the header as row 1).

Poll an import job

GET /email_blocks/import/{id} returns the current state of an import job. Poll this until status is completed or failed.
curl
When status is completed, the response includes processed_rows, created_count, existing_count, skipped_count, and error_count. Rejected rows are counted in skipped_count and detailed in an errors object that maps row numbers to skip reasons. error_count tracks a separate failure class that the importer does not currently populate, so it stays 0 even when rows were rejected:
Use skipped_count and errors — not error_count — to detect rows the importer rejected. A row with a missing/blank email address or an unmappable reason increments skipped_count and gets an entry in errors; error_count remains 0. Also note block_ttl_days only writes an expires_at value onto imported manual_block rows; because expires_at is not enforced, those rows keep blocking after the TTL date passes until you delete them.

Export suppressions as CSV

GET /email_blocks/export streams the account’s suppressions as a chunked CSV response with Content-Type: text/csv. Filters affect which rows are exported; sort and page parameters are accepted but ignored.
curl
The CSV columns, in order: id,to,from,reason,source,scope,status,domain_id,created_at,updated_at,expires_at,group_id.
Export → import is lossy — it is not a round-trip. The export writes all twelve columns, but the generic importer only consumes to/to_address/email and reason. Everything else in the file is discarded: from, domain_id, group_id, source, status, id, and the exported expires_at are all dropped on re-import. Consequences of re-importing your own export:
  • Domain- and address-scoped suppressions are broadened to account scope. Every imported row is written with from_address: null, domain_id: null, scope: account, so a block that previously applied to one sending domain now blocks that recipient across your whole account.
  • Group-scoped opt-outs become global. group_id is not read on import, so a newsletter-only unsubscribe re-imports as a suppression that blocks every send to that recipient, including transactional mail.
  • Expiry metadata is not preserved. Imported manual_block rows get a fresh expires_at derived from block_ttl_days; every other reason gets expires_at: null.
  • Removed rows come back active. status is ignored, so an exported tombstone re-imports as an active suppression.
Use the export for backup, reporting, or migration to another system — not as a way to restore Telnyx state. To recreate scoped or group-scoped suppressions faithfully, replay them through POST /email_blocks (with domain_id / from) or POST /email_unsubscribe_groups/{id}/suppressions.

Send-time behavior

When you send an email, Telnyx checks every recipient against the suppression list before creating the message. Suppressed recipients are reported in the response — they’re never silently dropped.

Partial suppression (202)

When some recipients are suppressed but others are not, the send proceeds for the non-suppressed recipients. The 202 response includes a top-level suppressed array describing each suppressed recipient:
Each entry in the suppressed array has:

All recipients suppressed (422)

When every recipient is suppressed, the request returns 422 with a recipient_suppressed error code and the suppressed array:

Batch sends

In a batch (POST /email_messages/batch), suppression is checked per message. A message whose recipients are all suppressed produces a per-item error with code: "recipient_suppressed" in the batch response’s errors array (indexed by position in the request).

Overriding suppressions

Set ignore_suppression: true on a send to bypass overridable suppressions (unsubscribe and manual_block). This requires an API key with the email:override scope — without it, the request returns 403:
When ignore_suppression: true is honored:
  • Recipients with only overridable suppressions (override_allowed: true) proceed — the send goes through and an override_used audit event is recorded on each bypassed suppression.
  • Recipients with any non-overridable suppression (override_allowed: false) remain blocked — ignore_suppression cannot bypass hard_bounce, spam_complaint, or invalid.
The scope hierarchy grants email:override to keys with full_access or email:admin. Keys with only email:send or email:read cannot override.
curl
ignore_suppression can only bypass unsubscribe and manual_block. It will never override a hard_bounce, spam_complaint, or invalid suppression — those recipients stay blocked and appear in the suppressed array even when the flag is set.

Unsubscribe groups

Unsubscribe groups let you suppress a recipient for one category of email without blocking all your sends to them. This is the standard pattern for separating newsletter opt-outs from transactional email. For example, a recipient who unsubscribes from your “Weekly Newsletter” group should still receive password-reset emails. Group-scoped suppressions only block sends that specify the same group_id.

Create a group

POST /email_unsubscribe_groups creates a group.
curl

List, retrieve, update, and delete groups

  • GET /email_unsubscribe_groups — list groups (offset pagination, page[size] default 25, max 100).
  • GET /email_unsubscribe_groups/{id} — retrieve a group.
  • PATCH /email_unsubscribe_groups/{id} — update name and/or description. PUT is not supported.
  • DELETE /email_unsubscribe_groups/{id} — delete a group. If the group has active suppressions, the request returns 409 unless you pass ?force=true, which soft-deletes the suppressions first.

Manage suppressions in a group

  • GET /email_unsubscribe_groups/{id}/suppressions — list suppressions in a group (offset pagination).
  • POST /email_unsubscribe_groups/{id}/suppressions — add a recipient to the group’s suppression list. The server forces reason: unsubscribe, source: manual, and group_id: . Only to is read from the request body.
  • DELETE /email_unsubscribe_groups/{id}/suppressions/{email} — remove a recipient from the group’s suppression list. Returns 204.
curl

Send with a group

Pass group_id on a send to scope the suppression check to that group. The send will check account-, domain-, and address-scoped suppressions as usual. Records with group_id: null apply to every send regardless of group; records with group_id set additionally require the same group_id to match.
curl
The group_id you set on a send is persisted on the message. When the recipient later unsubscribes via the RFC 8058 one-click flow, the resulting suppression is scoped to that same group — so the opt-out only affects future sends to that group, not your transactional email.

RFC 8058 one-click unsubscribe

RFC 8058 defines a one-click unsubscribe mechanism: supporting email clients (Gmail, Yahoo, Apple Mail, and others) send an automatic POST to a unsubscribe URL when the recipient clicks the unsubscribe button, without the recipient having to visit a web page.

How it works

  1. Headers on outgoing mail. When the sending domain has unsubscribe_tracking enabled (the default), Telnyx adds two headers to every outgoing message:
    • List-Unsubscribe: — a signed, unguessable URL.
    • List-Unsubscribe-Post: List-Unsubscribe=One-Click — signals RFC 8058 support to the mailbox provider.
  2. Recipient unsubscribes. A recipient can unsubscribe two ways:
    • One-click (POST) — the email client sends an automatic POST to the unsubscribe URL (RFC 8058). Telnyx records the unsubscribe with method: "one_click".
    • Link (GET) — the recipient clicks the unsubscribe link and lands on a confirmation page. Telnyx records the unsubscribe with method: "link".
  3. Suppression created. Telnyx enqueues a worker that creates an unsubscribe suppression for the recipient. When the original send specified a group_id, the suppression is scoped to that group.
  4. Event recorded. Telnyx records an email.unsubscribed event on the message (with the methodlink or one_click). These events also flow to configured webhooks.
Both POST and GET always return 200, even for invalid tokens, to avoid leaking information about which message IDs exist (per RFC 8058 §3).

Enabled by default

unsubscribe_tracking defaults to true for every email domain. This is intentional: Gmail and Yahoo require RFC 8058 one-click unsubscribe support for bulk senders. You can disable it per domain if you handle unsubscribes yourself, but doing so risks deliverability problems with those providers.
curl
Open and click tracking default to false; unsubscribe tracking defaults to true. The one-click unsubscribe flow only fires when the sending domain has unsubscribe_tracking enabled. The List-Unsubscribe URL carries a signed HMAC token, not a bare message ID. If the signing key is missing or misconfigured, Telnyx omits the unsubscribe headers entirely rather than emitting an invalid token — recipients can’t unsubscribe from a message with no headers, but they also won’t see a broken unsubscribe button.

Unsubscribe Groups

Source: https://developers.telnyx.com/docs/messaging/email/unsubscribe-groups.md
Unsubscribe groups let recipients opt out of specific categories of email without unsubscribing from all your messages. For example, a recipient can unsubscribe from your marketing newsletter but still receive transactional receipts.

How it works

  1. Create a group — each group represents a category of email (e.g., “Marketing”, “Product Updates”, “Billing”)
  2. Send with a group — include group_id in your send request
  3. Recipients unsubscribe — clicking the unsubscribe link in the email immediately opts the recipient out of the group associated with that message, then shows a generic confirmation page
  4. Suppression is automatic — future sends to that recipient with the same group are blocked

Create an unsubscribe group

curl

Send with a group

Include the group_id field when sending a message to associate it with an unsubscribe group:
curl
When a recipient clicks the unsubscribe link, they are opted out of that specific group only. If group_id is omitted, the unsubscribe becomes global — the recipient is suppressed from all future sends.

List group suppressions

View which recipients have unsubscribed from a specific group:
curl

Add a suppression manually

Add a single recipient to a group’s suppression list (e.g., via a support request):
curl

Remove a suppression

Remove a suppression to allow sending again:
curl

Best practices

  • Use groups for every marketing send — transactional emails can use a group too, but recipients should rarely want to unsubscribe from those
  • Keep group names clear — group names are for your internal organization
  • Don’t create too many — 3-5 groups is typical; more creates unnecessary complexity
  • Monitor suppression counts — high unsubscribe rates signal content or frequency issues
  • Always include group_id for marketing — without it, unsubscribes become global and block all future sends to that recipient

Events & Webhooks

Webhooks & Events

Source: https://developers.telnyx.com/docs/messaging/email/webhooks-events.md
Every email you send through Telnyx emits events as it moves through the delivery lifecycle — from 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), 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


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. The per-recipient status recorded on the recipient row can diverge from the webhook event type. Expiration resolves the recipient to 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:
Inbound emails (messages received at a domain with inbound enabled) emit a single 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/tracking email.* 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. Every webhook delivery is an HTTP 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:@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 webhooks live under the domains surface at /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
Response 201 Created:
A domain can have multiple webhooks. Each event is delivered to every webhook on that domain whose 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
List responses use offset pagination (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

The events 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:
Filtering happens before delivery: an event whose type isn’t on the allowlist is never sent to that endpoint. You can update the allowlist at any time with 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

Node
Python
Go
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

A message changes state or a recipient interacts, and Telnyx emits an event. 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. Return a 2xx status code to acknowledge receipt. 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
Response (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=. For compatibility with the Telnyx v2 bracket convention, the service also accepts the legacy alias filter[message_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 by occurred_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, use GET /email_messages/{email_id}/events. This is scoped to a message you own and returns its events in chronological order.
curl
Response (200):
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
Response (200):
Stats count 10 event types: 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)

Poll GET /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.

Choosing and combining both

Most production setups use both surfaces together:
  1. Webhooks for real-time reactions — subscribe each domain to the event types your app cares about (email.delivered, email.bounced, email.complained are the common minimum). Verify signatures, return 2xx fast, and process asynchronously. Remember each callback is one recipient: key your handler on payload.recipient_id, not on payload.id.
  2. Polling for reconciliation and backfill — run a periodic GET /email_events (or GET /email_events/stats for dashboards) to recover overlapping stored outbound message events missed during webhook downtime and to compute aggregate metrics. It cannot recover email.received or email_domain.* webhooks, and it does not return error_evidence.
  3. Per-message lookups for support — use GET /email_messages/{email_id}/events to show a single message’s delivery timeline on demand.
  4. The inbound WebSocket for received mail — use /email_events/ws only for inbound email.received streaming; 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.

Event Lifecycle

Source: https://developers.telnyx.com/docs/messaging/email/event-lifecycle.md
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.

API Reference (Email)

Email Messages

Email Inboxes

Email Drafts

  • List drafts in an inbox: Lists drafts newest first using stable cursor pagination. All access is scoped
  • Create a draft: Creates an unsent draft in the inbox. Every field is optional — a draft is a
  • Retrieve a draft: Returns a single draft. Drafts that have been sent remain retrievable, so the
  • Update a draft: Updates the supplied fields on a draft. account_id and inbox_id are
  • Update a draft (alias): Identical to PUT; both apply a partial update to the supplied fields.
  • Delete a draft: Permanently deletes an unsent draft. Drafts that are being sent or have been sent
  • Send a draft: Sends the draft through the standard send pipeline — the same domain resolution,
  • Create a reply draft: Creates an unsent reply draft for an inbound message. Unlike the

Email Threads

Email Domains

Email Domain DNS Records

Email Webhooks

Email Validations

Email Templates

Email Suppressions

Email Suppression Imports

Email Unsubscribe Groups

Email Events