# Telnyx Messaging: Email — Full Documentation > Complete page content for Email (Messaging section) of the Telnyx developer docs (https://developers.telnyx.com). > This file: https://developers.telnyx.com/docs/development/llms/messaging-email-llms-full-txt.md · Root index: https://developers.telnyx.com/llms.txt ## Getting Started ### Overview > Source: https://developers.telnyx.com/docs/messaging/email/overview.md The Telnyx Email API is a full email platform — not just a send endpoint. It combines transactional sending, bi-directional inboxes, deliverability tooling, and event tracking into a single API built on the same infrastructure that powers Telnyx's global messaging network. ## What you can build | Capability | What it does | |------------|-------------| | **Send email** | Transactional and marketing email with HTML, attachments, scheduling, idempotency, and batch sending | | **Receive email** | Full inboxes with threads, labels, drafts, replies, forwarding, and sender filters | | **Track everything** | Open tracking, click tracking, unsubscribe tracking, and a complete event lifecycle from queued to delivered | | **Manage reputation** | Automatic suppressions, unsubscribe groups, deliverability monitoring, and domain reputation scoring | | **Use templates** | Liquid-templated email templates with variable auto-extraction | | **Validate addresses** | Single and batch email validation to reduce bounces before you send | | **Own your domain** | Full DKIM/SPF/DMARC support with automated DNS record generation and verification | | **Instant onboarding** | Shared domain for two-minute sends with zero DNS setup | ## Architecture Telnyx Email runs on the same global infrastructure that powers Telnyx's messaging network. You get: - **REST API** for all operations — sending, inbox management, domain configuration, suppressions, and events - **Webhooks** for real-time delivery and engagement events (queued, sent, delivered, bounced, opened, clicked, unsubscribed) - **Polling** for on-demand event retrieval via `GET /email_events` - **Fully managed** — no mail server setup, no IP warmup, no MTA configuration ## Getting started The quickest path to your first send: 1. **[Quickstart: Send Your First Email](/docs/messaging/email/quickstart)** — two-minute send with a shared domain, or full custom domain setup 2. **[Sending Email with the Telnyx API](/docs/messaging/email/send-email)** — complete send guide with attachments, templates, scheduling, idempotency, tracking, and batch sending 3. **[Manage Inboxes](/docs/messaging/email/inboxes)** — receive, read, reply, forward, and organize email through the API 4. **[Webhooks & Events](/docs/messaging/email/webhooks-events)** — subscribe to real-time delivery and engagement events ## API surfaces | Surface | Endpoint prefix | Guide | |---------|----------------|-------| | Messages | `/email_messages` | [Sending Email](/docs/messaging/email/send-email) | | Inboxes | `/email_inboxes` | [Manage Inboxes](/docs/messaging/email/inboxes) | | Threads | `/email_threads` | [Manage Inboxes](/docs/messaging/email/inboxes) | | Domains | `/email_domains` | [Email Domains & DKIM](/docs/messaging/email/domains) | | Validation | `/email_validations` | [Email Validation](/docs/messaging/email/validation) | | Templates | `/email_templates` | [Email Templates](/docs/messaging/email/templates) | | Suppressions | `/email_blocks` | [Suppressions & Unsubscribes](/docs/messaging/email/suppressions) | | Unsubscribe Groups | `/email_unsubscribe_groups` | [Unsubscribe Groups](/docs/messaging/email/unsubscribe-groups) | | Events | `/email_events` | [Webhooks & Events](/docs/messaging/email/webhooks-events) | **Email API is in invite-only beta.** Access is limited to accounts with the `email.beta_access` capability enabled. [Contact us](mailto:support@telnyx.com) to request access. --- ### 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](mailto:support@telnyx.com) to request access. Send your first email in two minutes with zero DNS setup, or go the full route with your own verified domain. The Telnyx Email API handles sending, receiving, tracking, deliverability, and suppression management — this quickstart gets you to your first send as fast as possible. ## Prerequisites - A [Telnyx account](https://telnyx.com/sign-up) (free to create) - An API key from [API Keys](https://portal.telnyx.com/#/app/api-keys) in the portal (the **Auth** section) 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "from": "onboarding@mail.telnyx.com", "to": ["your-email@example.com"], "subject": "Hello from Telnyx", "text_body": "This is a test email sent with the Telnyx shared domain — no DNS setup required." }' ``` 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](#4-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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "domain": "example.com" }' ``` 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. ```bash curl curl https://api.telnyx.com/v2/email_domains/{domain_id}/dns_records \ -H "Authorization: Bearer YOUR_API_KEY" ``` 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains/{domain_id}/verify \ -H "Authorization: Bearer YOUR_API_KEY" ``` 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`: ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9326" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "subject": "Hello from Telnyx", "text_body": "This is a test email." }' ``` 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: ```json { "data": { "record_type": "email_message", "id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b", "status": "queued", "from": { "email": "sender@example.com" }, "to": [ { "email": "recipient@example.com" } ], "subject": "Hello from Telnyx", "created_at": "2026-07-06T12:00:00.000000Z" } } ``` 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: ```bash curl curl https://api.telnyx.com/v2/email_messages/{id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` List events for the message — `queued`, `sent`, `delivered`, `bounced`, `opened`, `clicked`, and more as delivery progresses: ```bash curl curl https://api.telnyx.com/v2/email_messages/{id}/events \ -H "Authorization: Bearer YOUR_API_KEY" ``` You've sent your first email. To go further — HTML bodies, attachments, scheduling, idempotency, and tracking — see the [Send Email](/docs/messaging/email/send-email) guide. --- ## Sending Email ### Email API Guide > Source: https://developers.telnyx.com/docs/messaging/email/send-email.md The Telnyx Email API is a full email platform: transactional and marketing sending with attachments, templates, scheduling, and batch operations — plus open/click/unsubscribe tracking, automatic suppressions, group-scoped unsubscribe management, and a complete event lifecycle from queued to delivered. This guide covers the sending surface in depth. For receiving email, managing inboxes, or tracking events, see the [Email API Overview](/docs/messaging/email/overview). 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`. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "from": { "email": "sender@example.com", "name": "Telnyx Notifications" }, "to": [ { "email": "recipient@example.com", "name": "Ada Lovelace" } ], "subject": "Welcome", "html_body": "

Welcome, Ada!

Thanks for signing up.

", "text_body": "Welcome, Ada! Thanks for signing up." }' ``` ### Address fields | Field | Type | Notes | |-------|------|-------| | `from` | string | object | A plain email string or `{email, name}`. | | `from_name` | string | Optional display name when `from` is a string; overrides `from.name`. | | `to` | array (min 1) | Each item is a string or `{email, name}`. | | `cc` | array | Same item shape as `to`. | | `bcc` | array | Same item shape as `to`. | | `reply_to` | string | object | Reply-to address. When provided as an object, only the email is stored. | ### Body, headers, and attachments | Field | Type | Notes | |-------|------|-------| | `subject` | string | Required unless `template_id` is supplied. | | `html_body` | string | HTML body. Omitted from create and list responses; returned by `GET /email_messages/{id}`. | | `text_body` | string | Plain-text body. Omitted from create and list responses; returned by `GET /email_messages/{id}`. | | `headers` | object of string keys to string values | Custom headers. Write-only — not returned in responses. | | `tags` | array of strings | Tags for categorization. Write-only. | | `metadata` | object | Custom metadata. Write-only. | | `attachments` | array | See below. | Send an attachment by base64-encoding its content: ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "subject": "Invoice attached", "text_body": "Your invoice is attached.", "attachments": [ { "filename": "invoice.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjQK..." } ] }' ``` **Request** `attachments[]` fields: | Field | Default | Notes | |-------|---------|-------| | `filename` | `"attachment"` | Attachment file name. | | `content_type` | `application/octet-stream` | MIME type. | | `content` | `""` | Base64-encoded file content. | | `disposition` | `"attachment"` | Use `inline` for images referenced from the HTML body. | | `content_id` | `null` | Content-ID for an inline attachment, referenced as `cid:` in the HTML body. | **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`. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "template_id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "template_variables": { "first_name": "Ada" } }' ``` 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "subject": "Scheduled reminder", "text_body": "This was scheduled.", "scheduled_at": "2026-08-01T09:00:00Z" }' ``` 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`: ```bash curl curl -X DELETE https://api.telnyx.com/v2/email_messages/{email_id}/schedule \ -H "Authorization: Bearer ***" ``` 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9326" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "subject": "Order confirmation", "text_body": "Order #12345 confirmed." }' ``` 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: ```http Idempotent-Replayed: true ``` 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`: ```json { "data": { "record_type": "email_message", "id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b", "status": "queued", "from": { "email": "sender@example.com" }, "to": [{ "email": "recipient@example.com" }], "subject": "Welcome", "created_at": "2026-07-06T12:00:00.000000Z" } } ``` 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. | Status | Meaning | |--------|---------| | `queued` | Accepted and waiting to be processed. | | `scheduled` | Held for a future `scheduled_at` time. | | `sending` | Being injected into the delivery pipeline. | | `completed` | Every recipient has reached a terminal state (not necessarily success). | | `cancelled` | A scheduled message was cancelled before sending. | | `sandbox` | Created in sandbox mode; never actually delivered. | | `failed` | The message failed before delivery injection, such as when a scheduled send exceeds the daily limit at fire time. | #### 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. | Status | Terminal | Meaning | |--------|----------|---------| | `queued` | No | Accepted, not yet injected. | | `sending` | No | Injection in progress. | | `sent` | No | Accepted by the delivery gateway. | | `deferred` | No | Temporary failure (4xx); will be retried. | | `delivered` | Yes | The receiving mail server accepted the message. | | `bounced` | Yes | Permanently rejected by the receiving server (5xx or out-of-band bounce). | | `failed` | Yes | Terminal system or operator-caused non-delivery. | | `expired` | Yes | Retries were abandoned after the maximum queue age. | | `gw_reject` | Yes | The delivery gateway refused the recipient before queueing. | | `cancelled` | Yes | Cancelled before delivery. | | `injection_timeout` | Yes | The injection attempt timed out with an ambiguous result. | #### Event types Events record what happened and when. Query them with `GET /email_messages/{id}/events` or `GET /email_events`. | Event type | Meaning | |------------|---------| | `queued`, `scheduled`, `sending`, `sent`, `cancelled`, `sandbox` | Lifecycle progress. | | `delivered`, `deferred`, `bounced`, `failed`, `rejected` | Delivery outcomes. | | `opened`, `clicked`, `unsubscribed`, `complained` | Engagement and feedback signals. | | `daily_limit_exceeded` | The account's daily send limit blocked the send. | ### 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). ```json { "data": { "record_type": "email_message", "id": "...", "status": "queued" }, "suppressed": [ { "to": "suppressed@example.com", "reason": "hard_bounce", "scope": "global", "override_allowed": false } ] } ``` If **all** recipients are suppressed, the request returns `422` with a `recipient_suppressed` error and the `suppressed` array (see [Errors](#errors)). ## Tracking Open, click, and unsubscribe tracking are configured through the `tracking` object on an email domain: ```bash curl curl -X PATCH https://api.telnyx.com/v2/email_domains/{domain_id} \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "tracking": { "open_tracking": true, "click_tracking": true, "unsubscribe_tracking": true } }' ``` **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: ```bash curl curl https://api.telnyx.com/v2/email_messages/{id}/events \ -H "Authorization: Bearer YOUR_API_KEY" ``` Or list events across the account: ```bash curl curl https://api.telnyx.com/v2/email_events \ -H "Authorization: Bearer YOUR_API_KEY" ``` 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](/docs/messaging/email/error-codes) for all codes and troubleshooting. The standard error envelope: ```json { "errors": [ { "code": "10015", "title": "Bad Request", "detail": "subject is required when not using a template" } ] } ``` ### Common error codes | HTTP | Code | Meaning | |------|------|---------| | 400 | `10015` | Validation failed — missing or invalid fields. | | 400 | `10015` | Invalid `Idempotency-Key` header — empty, duplicate, malformed, or overlong. | | 401 | `10006` | Authentication failed — check your API key. | | 403 | `10007` | Forbidden — the Email API's general sending-policy error. Covers an unverified, degraded, or suspended sending domain, a domain with no active DKIM key, a sender that doesn't match the domain's sending profile, and trial-account recipient restrictions. Inspect `detail` for the specific reason. | | 404 | `10001` | Not found — the message, domain, or resource doesn't exist. | | 409 | `10036` | A request with the same idempotency key is still being processed. | | 413 | `10015` | Request body exceeds 8,000,000 bytes. | | 422 | `10015` | Validation error (changeset). | | 422 | `10027` | The idempotency key was already used for a different request. | | 422 | `recipient_suppressed` | All recipients suppressed. Returns a top-level `suppressed` array. | | 429 | `reputation_suspended` | Sending suspended — the sending domain's reputation band is `poor`. | | 503 | `10016` | Service unavailable — an upstream dependency or Edge idempotency protection is unavailable. | `recipient_suppressed` (422) uses a non-standard envelope with the `suppressed` array alongside `errors`: ```json { "errors": [ { "code": "recipient_suppressed", "title": "Recipient Suppressed", "detail": "All recipients are suppressed. The email was not sent." } ], "suppressed": [ { "to": "suppressed@example.com", "reason": "hard_bounce", "scope": "global", "override_allowed": false } ] } ``` `reputation_suspended` (429) uses a string code rather than a numeric Telnyx code: ```json { "errors": [ { "code": "reputation_suspended", "title": "Sending Suspended", "detail": "Sender domain reputation is too low. Sending has been suspended. Please contact support to resolve deliverability issues." } ] } ``` ## 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: 20dbec69-bc70-4fed-aec7-2a70af37a8a6" \ -d '{ "messages": [ { "from": "sender@example.com", "to": ["recipient1@example.com"], "subject": "Hello 1", "text_body": "Message 1" }, { "from": "sender@example.com", "to": ["recipient2@example.com"], "subject": "Hello 2", "text_body": "Message 2" } ] }' ``` 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`): ```json { "data": [ { "record_type": "email_message", "id": "11111111-1111-1111-1111-111111111111", "status": "queued" } ], "errors": [ { "index": 1, "code": "bad_request", "message": "from, to, and subject are required" } ], "meta": { "total": 2, "succeeded": 1, "failed": 1 } } ``` 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: | What you're doing | Typical ESP surface | Telnyx equivalent | |---|---|---| | Authenticating | API key in a header (or SMTP credentials) | `Authorization: Bearer YOUR_API_KEY` on every request | | Sending one email | `POST /mail/send`, `POST /messages`, `SendEmail` | `POST /v2/email_messages` | | Sending many at once | Personalizations, recipient variables, bulk templated send | `POST /v2/email_messages/batch` (up to 50 messages per request) | | Looking up a message | Message/activity lookup | `GET /v2/email_messages/{id}` | | Storing reusable content | Dynamic/handlebars templates | `POST /v2/email_templates` + `template_id` / `template_variables` on send | | Previewing a template | Template preview | `POST /v2/email_templates/{id}/render` | | Receiving events | Event webhook / notification topic | Webhooks on `POST /v2/email_domains/{domain_id}/webhooks` | | Polling events | Activity/messages/events API | `GET /v2/email_events` | | Aggregate stats | Stats endpoint | `GET /v2/email_events/stats` | | Suppression list | Bounces, blocks, spam reports, unsubscribes | `POST /v2/email_blocks` (plus CSV import at `POST /v2/email_blocks/import`) | | Unsubscribe groups | ASM groups / subscription topics | `POST /v2/email_unsubscribe_groups` | | Domain authentication | Domain auth / verified identity | `POST /v2/email_domains` + `GET /v2/email_domains/{domain_id}/dns_records` + `POST /v2/email_domains/{domain_id}/verify` | | Scheduling | `send_at` / scheduled send | `scheduled_at` (preferred) or `send_at` on the send payload | There is no `POST /v2/suppressions` endpoint on Telnyx. Suppressions live under **email blocks** — `POST /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](/docs/messaging/email/suppressions). ### Event-type mapping Telnyx publishes an event for each delivery-lifecycle transition. If you already handle ESP events, this is the mapping you need: | What happened | Telnyx event type | |---|---| | Accepted by the API and queued | `email.queued` | | Handed to the outbound MTA and accepted for delivery | `email.sent` | | Accepted by the receiving server | `email.delivered` | | Temporary failure — Telnyx will retry | `email.deferred` | | Permanent failure (bounce, expiration, admin bounce, or out-of-band bounce) | `email.bounced` | | Recipient reported the message as spam (ARF feedback) | `email.complained` | | Sender-side failure after the message was accepted and persisted | `email.failed` | | Recipient opened the message | `email.opened` | | Recipient clicked a tracked link | `email.clicked` | | Recipient unsubscribed | `email.unsubscribed` | 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: | SendGrid event | Telnyx event | |---|---| | `processed` | `email.queued` (approximate — both mean "accepted") | | *(no equivalent)* | `email.sent` — fires on outbound MTA injection, a stage SendGrid does not report | | `delivered` | `email.delivered` | | `deferred` | `email.deferred` | | `bounce`, `dropped` | `email.bounced` (check `bounce_category`) | | `spamreport` | `email.complained` | | `open` | `email.opened` | | `click` | `email.clicked` | | `unsubscribe`, `group_unsubscribe` | `email.unsubscribed` | 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 | SendGrid | Telnyx | |---|---| | `POST /v3/mail/send` | `POST /v2/email_messages` | | `personalizations[]` | One message per recipient group, batched via `POST /v2/email_messages/batch` | | `content[{type, value}]` | `html_body` and `text_body` as separate top-level fields | | `dynamic_template_data` | `template_variables` | | Handlebars template syntax | Liquid template syntax | | `POST /v3/templates` | `POST /v2/email_templates` | | Event Webhook (account-wide) | Webhooks per domain — `POST /v2/email_domains/{domain_id}/webhooks` | | `GET /v3/suppression/bounces` | `GET /v2/email_blocks` | | ASM unsubscribe groups | `POST /v2/email_unsubscribe_groups` | | Domain authentication | `POST /v2/email_domains` + `verify` | | `send_at` (Unix timestamp) | `scheduled_at` (ISO 8601) | ### Sending a single email SendGrid nests recipients under `personalizations` and bodies under `content`. Telnyx flattens both: ```bash SendGrid curl -X POST https://api.sendgrid.com/v3/mail/send \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "personalizations": [ { "to": [{ "email": "recipient@example.com" }] } ], "from": { "email": "sender@example.com" }, "subject": "Welcome", "content": [ { "type": "text/plain", "value": "Welcome aboard." }, { "type": "text/html", "value": "

Welcome aboard.

" } ] }' ``` ```bash Telnyx curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "from": { "email": "sender@example.com", "name": "Example" }, "to": [{ "email": "recipient@example.com" }], "subject": "Welcome", "text_body": "Welcome aboard.", "html_body": "

Welcome aboard.

" }' ``` 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.ToAddresses` → `to`, `Message.Body.Html.Data` → `html_body`, `Message.Body.Text.Data` → `text_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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9326" \ -d '{ "from": "sender@example.com", "to": ["recipient@example.com"], "subject": "Order confirmation", "text_body": "Order #12345 confirmed." }' ``` - 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](/docs/messaging/email/send-email) and [Error Codes](/docs/messaging/email/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: ```bash SendGrid curl -X POST https://api.sendgrid.com/v3/mail/send \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "personalizations": [ { "to": [{ "email": "one@example.com" }], "dynamic_template_data": { "first_name": "Ada" } }, { "to": [{ "email": "two@example.com" }], "dynamic_template_data": { "first_name": "Grace" } } ], "from": { "email": "sender@example.com" }, "template_id": "d-abc123" }' ``` ```bash Telnyx curl -X POST https://api.telnyx.com/v2/email_messages/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "messages": [ { "from": "sender@example.com", "to": ["one@example.com"], "template_id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "template_variables": { "first_name": "Ada" } }, { "from": "sender@example.com", "to": ["two@example.com"], "template_id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "template_variables": { "first_name": "Grace" } } ] }' ``` - 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains/{domain_id}/webhooks \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "url": "https://example.com/webhooks/email", "events": ["email.sent", "email.delivered", "email.bounced", "email.complained"] }' ``` - `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](/docs/messaging/email/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: ```bash curl curl "https://api.telnyx.com/v2/email_events?page_size=50&event_type=delivered,bounced&from=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer ***" ``` 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. ```bash SendGrid curl -X POST https://api.sendgrid.com/v3/templates \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "name": "Welcome", "generation": "dynamic" }' ``` ```bash Telnyx curl -X POST https://api.telnyx.com/v2/email_templates \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "name": "Welcome", "subject": "Welcome, {{first_name}}", "html_body": "

Welcome, {{first_name}}!

", "text_body": "Welcome, {{first_name}}!" }' ``` 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_templates/{id}/render \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "template_variables": { "first_name": "Ada" } }' ``` 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: | Field | Where it's used | Type | Behavior | |---|---|---|---| | `variables` | `POST /v2/email_templates` (create/update) | Array of variable **name** strings | Declares the names used by the template. Omit it and Telnyx extracts the names from the subject and bodies automatically. | | `template_variables` | `POST /v2/email_templates/{id}/render` | Object of name → value | Supplies the values for a preview render. A missing or non-object value is treated as `{}`, so the template renders with empty substitutions rather than erroring. | | `template_variables` | `POST /v2/email_messages` (send) | Object of name → value | Supplies the values for the actual send. **Must be an object.** A non-object value (string, array, number) is rejected with `422` — the send does not happen. Omitting the field entirely is fine; it defaults to `{}`. | 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_blocks/import \ -H "Authorization: Bearer ***" \ -F "file=@sendgrid_bounces.csv" \ -F "block_ttl_days=30" ``` 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: `bounces` → `hard_bounce`, `spam_reports` → `spam_complaint`, `unsubscribes` → `unsubscribe`, `invalid_emails` → `invalid`, and `blocks` → `manual_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](https://portal.telnyx.com/#/app/api-keys) 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`). ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "domain": "example.com" }' ``` 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains/{domain_id}/verify \ -H "Authorization: Bearer ***" ``` 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](/docs/messaging/email/domains). 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "from": "sender@example.com", "to": ["you@example.com"], "subject": "Migration test", "text_body": "Hello from Telnyx." }' ``` 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](/docs/messaging/email/deliverability). ## Differences to plan for | Behavior | What to expect on Telnyx | |---|---| | Success status code | `202 Accepted` on send, not `200`. | | Batch size | Hard cap of 50 messages per `POST /v2/email_messages/batch` request. | | Partial batch failure | `207` with per-item `errors[]` carrying `index`, `code`, and `message`. | | Suppressed recipients | Never silently dropped — a `202` includes a top-level `suppressed[]` array; if *all* recipients are suppressed you get `422` with code `recipient_suppressed`. | | Bounce granularity | One `email.bounced` type with a `bounce_category` discriminator. | | Webhook scope | Per sending domain, not per account. | | Webhook signing | Ed25519 over `{timestamp}\|{raw_body}`, verified with your public key. | | Template language | Liquid, not Handlebars or Mustachio. | | Idempotency | Optional, via the `Idempotency-Key` HTTP header (enforced at Edge, 24-hour retention, successful responses replayed with `Idempotent-Replayed: true`). The body field `idempotency_key` is ignored. | | Write-only fields | `html_body`, `text_body`, `headers`, `tags`, and `metadata` are not returned in responses. | | Reputation enforcement | Sending is suspended with `429` and code `reputation_suspended` if the domain's reputation band falls to `poor`. | 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](/api-reference), and error codes are catalogued in [Error Codes](/docs/messaging/email/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. | Limit | Value | Enforced by | Failure | |-------|-------|-------------|---------| | Message body (`html_body` + `text_body`, decoded) | 1 MB | Email API | `422` with code `10015`, detail "body exceeds size limit (maximum 1 MB)" | | Total message (decoded body + decoded attachments) | 25 MB | Email API | `422` with code `10015`, detail "message exceeds size limit (maximum 25 MB)" | | HTTP request body | 150 MB | Phoenix request parser | `413` with code `10015` | | Idempotency-keyed replay capture | 8 MB | Telnyx Edge (API gateway) | `413 Payload Too Large` — keyed request over 8 MB is rejected at Edge. Unkeyed requests bypass this cap. | | Batch messages per request (`POST /email_messages/batch`) | 50 | Email API | `400` with code `10015` | | Batch validations per request (`POST /email_validations/batch`) | 1,000 | Email API | `400` with code `10015` | **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`: ```json { "errors": [ { "code": "10015", "title": "Bad Request", "detail": "messages must contain at most 50 items" } ] } ``` ## 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. | Code | Condition | What to do | |------|-----------|------------| | `10011` | The account's **daily send limit** was exceeded. The detail names the limit and states that it resets at **midnight UTC**. | Queue the work and resume after the daily reset. Exponential backoff over seconds or minutes will not help — the counter is daily. | | `reputation_suspended` | The sending domain's reputation band dropped to `poor`. | **Stop and remediate.** Retrying never succeeds and continued attempts worsen the signal. Reduce bounce and complaint rates, then confirm the band recovered. | | *(no email-specific code)* | Edge-level request rate limiting. | Back off exponentially with jitter and retry. | 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`: ```json { "errors": [ { "code": "reputation_suspended", "title": "Sending Suspended", "detail": "Sender domain reputation is too low. Sending has been suspended. Please contact support to resolve deliverability issues." } ] } ``` See [Deliverability and Domain Warm-up](/docs/messaging/email/deliverability) 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`: ```json { "errors": [ { "code": "10011", "title": "Too Many Requests", "detail": "Daily send limit of 1000 recipients exceeded. The limit resets at midnight UTC." } ] } ``` 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. ## Destination provider throttling Beyond the limits above (which protect the Telnyx platform), receiving providers like Gmail, Outlook, and Yahoo impose their own rate limits on incoming mail. The Telnyx outbound MTA handles destination-side pacing automatically — you do not need to build client-side throttling for provider rate limits. When you send faster than a destination provider accepts, the MTA automatically queues the overflow in its local queue and delivers it as the rate window opens. Queued messages are not returned as failures — they remain in the queue and deliver once the receiving provider accepts them. Destination throttling is automatic, but it is not a substitute for good sending practices. You still need to warm up new domains, monitor your reputation, and stay within your [account sending quota](#sending-quotas) and [API request rate limits](#request-rate-limits). A sustained burst that exceeds the 72-hour queue lifetime will cause messages to expire undelivered. ### Per-provider delivery rates The MTA paces outbound delivery based on the recipient's domain: | Recipient domain | Maximum delivery rate | Retry interval | Max interval between retries | |-------------|----------------------|----------------|-----------------| | `gmail.com`, `googlemail.com` | 500 messages/hour | 10 minutes | 4 hours | | `outlook.com`, `hotmail.com`, `live.com` | 500 messages/hour | 10 minutes | 4 hours | | All other domains | 1,000 messages/hour | 5 minutes | 2 hours | The retry interval is the delay between delivery attempts when a transient failure occurs. The max interval between retries is the upper bound for exponential backoff — the delay grows between retries up to this cap. Messages remain in the queue for up to **72 hours** total; only messages that exceed this lifetime are expired. **These caps match recipient domains, not the email provider behind them.** A company that uses Google Workspace on their own domain (e.g., `user@company.com`) is paced at the default rate (1,000/hour), not the Gmail rate — because the recipient domain is `company.com`, not `gmail.com`. ### What this means for senders The API accepts messages that pass validation and applicable limits (single sends return `202 Accepted`; batch sends return `207 Multi-Status` with per-message results). The actual delivery pace is managed downstream by the MTA — you can submit messages without worrying about exceeding destination-side rate limits. Rate-cap overflow is silent — the MTA holds excess messages in its local queue and delivers them as the rate window opens, following the normal `email.queued` → `email.sending` → `email.sent` → `email.delivered` event sequence. No separate event is emitted for queue pacing. If a receiving server returns a transient failure (e.g., a `4xx` SMTP response), the MTA retries automatically using exponential backoff (up to the max interval between retries in the table above) and emits an `email.deferred` event. Successful retry then emits `email.delivered`. If the message ultimately cannot be delivered within the 72-hour queue lifetime, it expires and emits an `email.bounced` event (the recipient status is `expired`, but the webhook event type is `bounced`). Do not resubmit deferred messages — the MTA handles retries automatically. **Destination throttling and your account sending quota operate on different time scales.** The per-provider caps above are hourly delivery rates; the [daily send limit](#daily-send-limit) is a 24-hour recipient count. Both apply independently — a provider cap doesn't reduce your daily quota, and your daily quota doesn't raise the provider cap. See [Sending quotas](#sending-quotas) for account-level limits. **Reputation-based throttling stacks on top of provider caps.** If your sending domain's reputation band drops to `warn`, the MTA halves the delivery rate for queues carrying that domain's mail. At `poor`, new sends are rejected at the API with `429 reputation_suspended` before reaching the MTA. See [Reputation-based suspension](#reputation-based-suspension) above and [Deliverability & Warm-up](/docs/messaging/email/deliverability) for reputation guidance. ## 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](/docs/messaging/email/webhooks-events). 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_messages/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: 20dbec69-bc70-4fed-aec7-2a70af3b9524" \ -d '{ "messages": [ { "from": "notifications@example.com", "to": [{"email": "user1@example.com"}], "subject": "Welcome", "text_body": "Welcome to our service!" }, { "from": "notifications@example.com", "to": [{"email": "user2@example.com"}], "subject": "Welcome", "text_body": "Welcome to our service!" } ] }' ``` - 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](/docs/messaging/email/error-codes#batch-specific-errors). ### Scheduled sending Spread load over time using `scheduled_at`: ```json { "from": "notifications@example.com", "to": ["user@example.com"], "subject": "Scheduled message", "text_body": "This was scheduled.", "scheduled_at": "2027-03-14T09:00:00Z" } ``` 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](/docs/messaging/email/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](#batch-specific-errors) — and some template render errors use `{code, message}`): ```json { "errors": [ { "code": "10015", "title": "Validation Failed", "detail": "subject can't be blank" } ] } ``` | Field | Description | |-------|-------------| | `code` | Machine-readable error code (see table below). | | `title` | Short human-readable summary. | | `detail` | Specific error message with context. | ## 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. | Limit | Value | Enforced by | Failure mode | |-------|-------|-------------|--------------| | Message body (`html_body` + `text_body`, decoded) | 1 MB | Email API | `422` with code `10015`, title "Bad Request", detail "body exceeds size limit (maximum 1 MB)" | | Total message (decoded body + decoded attachments) | 25 MB | Email API | `422` with code `10015`, title "Bad Request", detail "message exceeds size limit (maximum 25 MB)" | | HTTP request body | 150 MB | Phoenix request parser | `413` with code `10015` | | Idempotency-keyed replay capture | 8 MB | Telnyx Edge (API gateway) | `413 Payload Too Large` — an idempotency-keyed request over 8 MB is rejected at Edge before reaching the Email API. Unkeyed requests are not subject to this cap. | **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 | Status | Meaning | When | |-------|---------|------| | 200 | OK | Successful request or idempotent replay. | | 201 | Created | Resource created (e.g. template creation). | | 202 | Accepted | Email accepted for sending (queued). | | 207 | Multi-Status | All batch responses (including all-success). `data` contains successes, `errors` contains failures. | | 400 | Bad Request | Validation error, malformed body, or invalid `Idempotency-Key` header. | | 401 | Unauthorized | Missing or invalid API key. | | 403 | Forbidden | Domain not verified/suspended/degraded, sender address not allowed, shared domain is read-only, or the account is inactive. | | 404 | Not Found | Resource (email, template, domain) not found. | | 409 | Conflict | A request with the same `Idempotency-Key` is still being processed, or a resource conflict (e.g. deleting an unsubscribe group with active suppressions). | | 413 | Payload Too Large | Request body exceeds the 150 MB Phoenix parser ceiling. Responses use code `10015` with detail "The request payload exceeds the maximum allowed size". | | 422 | Unprocessable Entity | Changeset validation error, message/body size limit exceeded, idempotency fingerprint conflict, or all recipients suppressed. | | 429 | Too Many Requests | Daily send limit exceeded, or sending suspended due to poor domain reputation. | | 500 | Internal Server Error | Unexpected server error. | | 503 | Service Unavailable | Upstream dependency or Edge idempotency protection unavailable. | ## 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](#asynchronous-delivery-errors-30xxx). ### 400 — Bad Request | Code | Title | Cause | |------|-------|-------| | `10015` | Bad Request / Validation Failed | Missing or invalid fields in the request body (e.g. `email is required`, `subject must be a non-empty string`). | | `10015` | Invalid Idempotency-Key | The `Idempotency-Key` header is empty, duplicated, malformed, or exceeds the length limit. Generate a UUID v4 and pass it as a single header, not a body field. | ### 401 — Unauthorized | Code | Title | Cause | |------|-------|-------| | `10006` | Not authorized | The `Authorization` header is missing or the API key is invalid, expired, or revoked. | ### 403 — Forbidden | Code | Title | Cause | |------|-------|-------| | `10007` | Forbidden | Domain is not verified, suspended, degraded, or missing DKIM — or the `from` address is not authorized on the sending domain. | | `10007` | Forbidden | The request requires the `email:override` scope (used to send to a suppressed recipient) and your API key does not have it. | | `20012` | Account inactive | The account has been deactivated — commonly out of funds. This is a billing state, not a domain or permission problem. Resolve the account balance before retrying. | | `10008` | Forbidden | Attempted to `PATCH`, `DELETE`, or `POST …/verify` on a shared domain you don't own. Shared domains are read-only for non-owners. **Owned by the email-domains service** — see the caveat under [Codes owned by other services](#codes-owned-by-other-services). | ### 404 — Not Found | Code | Title | Cause | |------|-------|-------| | `10001` | Not Found | Email message, template, or domain not found. Check the ID and your account scope. Both explicit application 404s and framework-level routing 404s return `10001` — there is no string `"404"` fallback code. | ### 409 — Conflict | Code | Title | Cause | |------|-------|-------| | `10036` | Resource is being processed | A request with the same `Idempotency-Key` is **still being processed**. Retry later with the same key and the same request body. | | `40901` | Conflict | Deleting an unsubscribe group that has active suppressions (without `?force=true`). Remove the suppressions first or pass `force=true`. **Owned by the email-blocks service** — see [Codes owned by other services](#codes-owned-by-other-services). | ### 422 — Unprocessable Entity | Code | Title | Cause | |------|-------|-------| | `10015` | Validation Failed | Changeset validation error (e.g. `subject can't be blank`, invalid `webhook_url`, bad address format). | | `10015` | Bad Request | Message size limit exceeded — 1 MB decoded body or 25 MB total message. See [Request and message size limits](#request-and-message-size-limits). | | `10027` | Idempotency Conflict | The `Idempotency-Key` was already used for a **different** request body. Use a new key for a new request; reuse keys only for exact retries. | | `recipient_suppressed` | Recipient Suppressed | All recipients are suppressed — the email was not sent. The response includes a top-level `suppressed` array alongside `errors`, each entry carrying `to`, `reason`, `scope`, and `override_allowed`. | ### 429 — Too Many Requests | Code | Title | Cause | Retry? | |------|-------|-------|--------| | `10011` | Too Many Requests | The account's **daily send limit** was exceeded. The detail names the limit and states that it resets at midnight UTC. The send is rejected before message creation — no message record, no billing, no MTA injection. | Yes, after the daily reset. | | `reputation_suspended` | Sending Suspended | The sending domain's reputation band is `poor` — sending is suspended until reputation recovers. Rejected before message creation. | **No.** See [Deliverability](/docs/messaging/email/deliverability). | **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 | Code | Title | Cause | |------|-------|-------| | `10019` | Internal Server Error | Unexpected server-side error (e.g. `Failed to create batch validation`). Retry with the same `Idempotency-Key`. If persistent, contact support. | | `10000` | Internal Server Error | Returned by specific internal-failure paths, including a failed GDPR email-data deletion and email trace retrieval. Same handling as `10019`. | | `500` | Internal Server Error | Framework fallback error response. Same handling as `10019`. | ### 503 — Service Unavailable | Code | Title | Cause | |------|-------|-------| | `10016` | Service Unavailable | An upstream dependency (e.g. the email domain service) or Edge idempotency protection is temporarily unavailable. Retry with the same `Idempotency-Key`. | ### `error_evidence` structure Failure events carry the normalized contract as `error_evidence`: ```json { "error_evidence": { "code": "30001", "message": "550 5.1.1 : Recipient address rejected", "enhanced_code": "5.1.1", "source": "smtp", "smtp_status": 550, "retryable": false } } ``` | Field | Type | Description | |-------|------|-------------| | `code` | string | The normalized 30xxx code from the table above. Never a raw SMTP status. | | `message` | string \| null | The raw SMTP response text, when the failure reached SMTP. | | `enhanced_code` | string \| null | DSN enhanced status code (e.g. `5.1.1`), when the remote supplied one. | | `source` | string | Where the failure was observed: `smtp`, `mta`, or `api`. | | `smtp_status` | integer \| null | The raw SMTP status as an integer. `null` for failures that never reached SMTP — queue expiry, suppression, and gateway rejection. | | `retryable` | boolean | Whether resubmitting could succeed. Only `30002` is `true`. | 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](/docs/messaging/email/webhooks-events#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: | Outcome | HTTP | Code | What happened | Action | |---------|------|------|---------------|--------| | Original still in flight | 409 | `10036` | The first request with this key is **still being processed**. No second send occurred. | Wait and retry with the **same** key and the **same** body. Do not generate a new key — that would send twice. | | Same key, different body | 422 | `10027` | The key was already used with a **different request fingerprint**. The new request was rejected without sending. | Generate a new key. Reuse a key only for byte-identical retries of the same logical request. | | Original already completed | *original 2xx* | *original body* | The first request finished. The gateway **replays its stored response verbatim** — including the original status and body — and adds an `idempotent-replayed: true` response header. | Nothing. The message was sent exactly once. Check for the `idempotent-replayed` header if you need to distinguish a replay from a fresh send. | Two further failure modes are specific to the idempotency layer itself: | Code | HTTP | Cause | Action | |------|------|-------|--------| | `10015` | 400 | Malformed key (empty, duplicate header, overlong) | Generate a valid UUID v4 and pass it as a single `Idempotency-Key` header. | | `10016` | 503 | Idempotency protection unavailable (fail-closed) | Retry the same request with the same key. The gateway fails closed rather than risking a duplicate send. | 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](#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: ```json { "data": [/* successful messages */], "errors": [ { "index": 1, "code": "forbidden", "message": "Domain is not verified" } ], "meta": { "total": 2, "failed": 1, "succeeded": 1 } } ``` Each batch error entry has `index` (position in your `messages` array), `code`, and `message`: | Error code | Cause | |-----------|-------| | `bad_request` | Malformed message entry, missing required fields, a template render error, or a field validation failure for that message. | | `validation_error` | Field validation failed for that message. | | `unprocessable_entity` | That message exceeded a size limit — either the 1 MB decoded body or the 25 MB total message ceiling. Note this is a **per-message** failure: the rest of the batch still processes. See [Request and message size limits](#request-and-message-size-limits). | | `forbidden` | Domain not verified or from-address not allowed. | | `not_found` | Template ID or domain not found (for template sends). | | `recipient_suppressed` | All recipients of that message are suppressed. | | `reputation_suspended` | Sending suspended for the domain's reputation band. | | `service_unavailable` | Upstream dependency unavailable for that 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: | Area | Owning service | Notes | |------|----------------|-------| | Shared-domain mutation (`10008`) | telnyx-email-domains | Returned as `403 Forbidden` when mutating a shared domain you don't own. | | Unsubscribe-group deletion conflict (`40901`) | email-blocks | Returned by the suppressions/unsubscribe-group surface. | | `/v2/email_domains` troubleshooting and domain verification errors | telnyx-email-domains | Domain registration, DNS record generation, verification, and drift monitoring all live in the domains service. | | Webhook CRUD (`/email_domains/{domain_id}/webhooks`) | telnyx-email-domains | The Email API only resolves webhook targets at publish time; it does not serve these routes. | **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](/docs/messaging/email/deliverability) 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. | Error | Status | Retry? | How | |-------|--------|--------|-----| | `10011` (daily send limit) | 429 | Yes | The limit resets at midnight UTC. Queue the work locally and resume after the reset — backing off in seconds will not help. | | `reputation_suspended` | 429 | **No** | Waiting does not clear it. Stop sending on that domain, reduce bounce and complaint rates, then confirm the band recovered. | | `10036` (idempotency in flight) | 409 | Yes | Retry with the **same** key and body. A new key would send a duplicate. | | `10027` (idempotency fingerprint) | 422 | No | The body differs from the original. Fix the body or use a new key. | | `10016` (service unavailable) | 503 | Yes | Retry the same request with the same `Idempotency-Key`, with exponential backoff. | | `10019` / `10000` / `500` | 500 | Yes | Retry with the same `Idempotency-Key`. Contact support if persistent. | | `10015` (validation / size) | 400, 413, 422 | No | The request is malformed or too large. Retrying it unchanged always fails. | | `10007` / `20012` (forbidden / inactive) | 403 | No | Fix domain verification, sender authorization, scope, or account balance first. | | `recipient_suppressed` | 422 | No | Every recipient is suppressed. Remove them or send to different addresses. | 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](#asynchronous-delivery-errors-30xxx) 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_inboxes \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "username": "support", "domain_id": "{domain_id}" }' ``` 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_inboxes \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{}' ``` ## List and search messages List messages in an inbox with optional search and filtering: ```bash curl curl -X GET "https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages?filter[search]=invoice" \ -H "Authorization: Bearer YOUR_API_KEY" ``` You can filter by: - **Search text** — `filter[search]` matches subject and message body - **Sender** — `filter[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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages/{message_id}/actions/reply \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "text": "Thanks for reaching out. We'll get back to you within 24 hours.", "html": "

Thanks for reaching out. We'll get back to you within 24 hours.

" }' ``` 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages/{message_id}/actions/forward \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "to": [{"email": "billing@company.com"}], "text": "Forwarding this for your review." }' ``` ## Drafts Create a draft, update it, and send it when ready: ```bash curl # Create a draft curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/drafts \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "to": [{"email": "client@example.com"}], "subject": "Proposal follow-up", "text_body": "Hi, following up on our discussion..." }' # Send the draft curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/drafts/{draft_id}/send \ -H "Authorization: Bearer YOUR_API_KEY" ``` 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: ```bash curl curl -X GET https://api.telnyx.com/v2/email_inboxes/{inbox_id}/threads \ -H "Authorization: Bearer YOUR_API_KEY" ``` Get a thread with a page of its messages: ```bash curl curl -X GET https://api.telnyx.com/v2/email_inboxes/{inbox_id}/threads/{thread_id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` You can also list threads across all inboxes in the account with `GET /email_threads`. ## Labels Organize messages and threads with labels: ```bash curl # Add labels to a message curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages/{message_id}/labels \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{"labels": ["urgent", "billing"]}' # Remove labels curl -X DELETE https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages/{message_id}/labels \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{"labels": ["urgent"]}' ``` 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: ```bash curl # Allow only specific senders curl -X POST https://api.telnyx.com/v2/email_inboxes/{inbox_id}/filters \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "type": "allowlist", "entries": [ "trusted@partner.com", "@partner-domain.com" ] }' ``` 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: ```bash curl curl -X PATCH https://api.telnyx.com/v2/email_inboxes/{inbox_id}/messages/{message_id} \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{"read_at": true}' ``` --- ## 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: | | Custom domain | Shared domain | |---|---|---| | Owned by | Your account (BYOD) | Telnyx (system account) | | DNS setup | Required — you publish the 5 records | None — Telnyx publishes them | | Visible in `GET /email_domains` | Your domains only | Yes, visible to **all** accounts (`"type": "shared"`) | | Readable by any account | No — only the owner | Yes | | Mutable (PATCH/DELETE/verify) | Yes — by the owning account | No — read-only for non-owners | | From address | Any local-part on your domain | `onboarding@` only | | Recipients | Any valid recipient (trial accounts: account owner's verified email only) | Account owner's verified email address only | 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "domain": "example.com", "inbound_enabled": false, "dmarc_policy": { "p": "none", "rua": "mailto:dmarc@telnyx.com" }, "tracking": { "open_tracking": true, "click_tracking": true, "unsubscribe_tracking": true } }' ``` | Field | Type | Default | Notes | |-------|------|---------|-------| | `domain` | string | — | Required. Must be a domain you control; public email providers, disposable domains, IP addresses, and single-label hostnames are rejected. | | `inbound_enabled` | boolean | `false` | Enable inbound routing. When `true`, the MX record becomes required for verification. | | `dmarc_policy` | object \| null | `null` | Your DMARC policy. Omit or `null` for the advisory default. See [DMARC record](#dns-records). | | `tracking` | object | see below | Open, click, and unsubscribe tracking toggles. Stored on the account's sending profile — see the callout below. | 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](/docs/messaging/email/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: ```bash curl curl https://api.telnyx.com/v2/email_domains/{domain_id}/dns_records \ -H "Authorization: Bearer ***" ``` 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`. | # | Purpose | Type | Host | Value | Priority | Required | |---|---------|------|------|-------|----------|----------| | 1 | Ownership | TXT | `` | `telnyx-domain-verification=` | — | ✅ yes | | 2 | SPF | TXT | `` | `v=spf1 include:spf.telnyx.com ~all` | — | recommended | | 3 | DKIM | TXT | `._domainkey.` | `v=DKIM1; k=rsa; p=` | — | ✅ yes | | 4 | MX | MX | `` | `mx.telnyx.com` | 10 | when inbound enabled | | 5 | DMARC | TXT | `_dmarc.` | `v=DMARC1; p=none; rua=mailto:dmarc@telnyx.com` | — | recommended | 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: ```bash curl curl -X POST https://api.telnyx.com/v2/email_domains/{domain_id}/verify \ -H "Authorization: Bearer ***" ``` 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: | Status | Meaning | Can send? | Can receive (inbound)? | |--------|---------|-----------|-------------------------| | `pending` | Records not yet verified (or some still propagating). | No | No | | `verified` | All required records pass; DKIM signing material is available. | Yes | Yes, if `inbound_enabled` and MX verified | | `failed` | A required record (ownership or DKIM — and MX if inbound-enabled) is missing or wrong. | No | No | | `degraded` | Was verified, but a required record has since failed. | No | Only if `usable_for_inbound` remains `true` | | `suspended` | Blocked by Telnyx (e.g. for policy or abuse reasons). Admin-only — no public API sets this. | No | No | `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`: ```bash curl curl https://api.telnyx.com/v2/email_domains/{id}/health \ -H "Authorization: Bearer ***" ``` ```json { "data": { "id": "6a09cdc3-8948-47f0-aa62-74ac943d6c58", "record_type": "email_domain_health", "status": "verified", "usable_for_sending": true, "usable_for_inbound": false, "verification": { "ownership": "verified", "spf": "verified", "dkim": "verified", "dmarc": "missing_optional", "mx": "not_required" }, "checked_at": "2026-05-31T12:00:00Z" } } ``` 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`: ```json "dkim": { "selector": "telnyx1", "algorithm": "rsa-sha256", "key_length": 2048, "active": true, "rotated_at": null } ``` ### 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}`: ```bash curl curl -X PATCH https://api.telnyx.com/v2/email_domains/{domain_id} \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "inbound_enabled": true, "dmarc_policy": { "p": "reject", "rua": "mailto:dmarc@example.com" }, "tracking": { "open_tracking": false, "unsubscribe_tracking": true } }' ``` | Field | Notes | |-------|-------| | `inbound_enabled` | Toggles inbound routing. Enabling it makes the MX record required for verification. | | `dmarc_policy` | Updates the recommended `_dmarc` TXT record value and resets its verification to `pending` — republish the record and re-verify. Omit/`null` for the advisory default. | | `tracking` | Open, click, and unsubscribe tracking. Explicit values — including `false` — always override the defaults. **Scoped to the sending profile:** the setting is stored on the account's default profile, so a change made through one domain applies to every domain sharing that profile. | 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](#reputation)). ### Delete a domain ```bash curl curl -X DELETE "https://api.telnyx.com/v2/email_domains/{domain_id}?force=true" \ -H "Authorization: Bearer ***" ``` 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: ```json "reputation": { "band": "good", "breakdown": {}, "computed_at": "2026-07-05T00:00:00Z" } ``` | Field | Description | |-------|-------------| | `band` | Reputation band — e.g. `excellent`, `good`, `warn`, `poor`, or `insufficient_data` (for senders below the volume floor). | | `breakdown` | Component scores contributing to the band. | | `computed_at` | When the reputation was last calculated. `null` if not yet computed. | 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](/docs/messaging/email/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. | Check | What it does | `checks` key | |-------|-------------|--------------| | **Syntax** | Practical email syntax validation — an RFC 5322-inspired ASCII subset, not the full grammar. Regex match for `local@domain.tld` (local part allows `A–Z a–z 0–9 . _ % + -`; TLD ≥ 2 letters) plus a 254-byte length cap. Plus-addressing (`user+tag@…`) is accepted; Unicode internationalized domains must be supplied as Punycode (`xn--…`). Quoted local parts, comments, and other exotic RFC 5322 forms are rejected. | `syntax` | | **MX / deliverability signal** | DNS lookup for MX records on the address's domain. Falls back to an A record (RFC 5321). Treats RFC 7505 null-MX records and self-referencing wildcard-DNS MX records as "no records found." | `mx` | | **Disposable domain** | Checks the domain against a community-maintained blocklist (5,000+ domains, refreshed periodically), including subdomain matching (`sub.mailinator.com` matches `mailinator.com`). | `disposable` | | **Role-based address** | Checks the local part against a curated list of role prefixes (e.g. `admin`, `noreply`, `postmaster`, `support`). Informational — not a hard fail. | `role_based` | | **Typo suggestion** | Compares the domain against 21 popular provider domains using Damerau-Levenshtein distance of 1 (handles insertions, deletions, substitutions, and adjacent transpositions). Returns a `suggestion` when close. | `typo` | 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_validations \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "email": "user@gmai.com" }' ``` Response (`200`): ```json { "data": { "record_type": "email_validation", "email": "user@gmai.com", "valid": true, "risk_score": 0.1, "did_you_mean": "user@gmail.com", "checks": { "syntax": { "pass": true }, "mx": { "pass": true, "details": "1 MX record found" }, "disposable": { "pass": true }, "role_based": { "pass": true }, "typo": { "pass": false, "details": "Possible typo in domain", "suggestion": "gmail.com" } } } } ``` 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 | Field | Type | Notes | |-------|------|-------| | `record_type` | string | Always `"email_validation"`. | | `email` | string | The address as validated (leading/trailing whitespace trimmed). | | `valid` | boolean | `true` only when `syntax`, `mx`, and `disposable` all pass. | | `risk_score` | number | 0–1 weighted score. Higher is riskier: syntax fail contributes 1.0, mx fail 0.8, disposable 0.6, role-based 0.2, typo 0.1 — capped at 1.0. | | `did_you_mean` | string | A full corrected address (`local@suggested-domain`) when the typo check found a suggestion. **Omitted entirely** (not `null`) when there is none. | | `checks.syntax` | object | `{pass, details?}`. `details` present only when the check fails. | | `checks.mx` | object | `{pass, details?}`. `details` reports MX record count, the A-record fallback, null-MX, wildcard-DNS, or a DNS lookup failure. | | `checks.disposable` | object | `{pass, details?}`. `details: "Disposable email domain"` when the domain is on the blocklist. | | `checks.role_based` | object | `{pass, details?}`. `details: "Role-based email address"` when the local part matches a role prefix. | | `checks.typo` | object | `{pass, details?, suggestion?}`. `suggestion` holds the suggested domain when a close popular-domain match is found; omitted when nil. | 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_validations/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "emails": [ "user@gmail.com", " admin@yahoo.com ", "user@gmail.com", "admin@yahoo.com" ], "webhook_url": "https://example.com/webhooks/email-validation" }' ``` Response (`202`): ```json { "data": { "record_type": "email_validation_batch", "id": "3f0c1b2a-4d5e-6f70-8190-2a3b4c5d6e7f", "status": "pending", "total": 2, "duplicates_removed": 2, "webhook_url": "https://example.com/webhooks/email-validation" } } ``` ### 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. ```bash curl curl https://api.telnyx.com/v2/email_validations/batch/3f0c1b2a-4d5e-6f70-8190-2a3b4c5d6e7f \ -H "Authorization: Bearer ***" ``` While processing (`status: "pending"` or `"processing"`): ```json { "data": { "record_type": "email_validation_batch", "id": "3f0c1b2a-4d5e-6f70-8190-2a3b4c5d6e7f", "status": "processing", "total": 2 } } ``` When complete (`status: "completed"`), `results` and `completed_at` appear: ```json { "data": { "record_type": "email_validation_batch", "id": "3f0c1b2a-4d5e-6f70-8190-2a3b4c5d6e7f", "status": "completed", "total": 2, "completed_at": "2026-07-06T12:00:05Z", "results": { "user@gmail.com": { "email": "user@gmail.com", "valid": true, "risk_score": 0.0, "checks": { "syntax": { "pass": true }, "mx": { "pass": true, "details": "5 MX records found" }, "disposable": { "pass": true }, "role_based": { "pass": true }, "typo": { "pass": true } } }, "admin@yahoo.com": { "email": "admin@yahoo.com", "valid": true, "risk_score": 0.2, "checks": { "syntax": { "pass": true }, "mx": { "pass": true, "details": "3 MX records found" }, "disposable": { "pass": true }, "role_based": { "pass": false, "details": "Role-based email address" }, "typo": { "pass": true } } } } } } ``` 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: ```json { "batch_id": "3f0c1b2a-4d5e-6f70-8190-2a3b4c5d6e7f", "total": 2, "valid": 2, "invalid": 0, "completed_at": "2026-07-06T12:00:05Z", "status": "completed" } ``` 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. | Outcome | Suggested action | |---------|-----------------| | `valid: true`, all checks pass | Safe to send. | | `valid: true`, `role_based: false` | It's a shared mailbox (e.g. `support@`). Fine to send, but engagement is usually lower than for a personal address. | | `valid: true`, `typo.pass: false` with `did_you_mean` | The user likely mistyped their provider, but the domain they typed does resolve. Prompt them to confirm `did_you_mean`, or use it directly for low-risk signups. A failing typo check never flips `valid` on its own. | | `valid: false`, `mx.pass: false` | The domain has no mail exchanger (including RFC 7505 null-MX domains like `example.com`). Don't send — this will bounce. | | `valid: false`, `disposable.pass: false` | The domain is a known throwaway provider. Decide whether to allow it for your signup flow; it won't deliver reliably long-term. Note some typo domains are also blocklisted, so you can see `typo.pass: false` and `disposable.pass: false` together. | | `valid: false`, `syntax.pass: false` | The string isn't a valid address. Reject it; no other checks ran. | 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](/docs/messaging/email/domains) for details. ### SPF Publish the SPF record Telnyx generates exactly as specified: ```txt yourdomain.com TXT "v=spf1 include:spf.telnyx.com ~all" ``` 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: ```txt _dmarc.yourdomain.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@telnyx.com" ``` 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](/docs/messaging/email/webhooks-events) before the first production send. - Register your authenticated domain with [Google Postmaster Tools](https://postmaster.google.com/) 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](/docs/messaging/email/rate-limits). 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. | Stage | Suggested maximum | What to do | |-------|-------------------|------------| | Days 1–2 | 100–500 messages per major mailbox provider per day | Send only to your most recently engaged recipients. Spread sends throughout the day. | | Days 3–4 | Up to 2× the previous successful daily volume | Increase only where delivery signals remain healthy. | | Days 5–7 | Increase by 50–100% per day | Hold volume for providers showing increased deferrals or filtering. | | Week 2 onward | Increase by 20–50% per day | Continue toward normal volume. A high-volume domain may take 3–6 weeks to establish reputation. | 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. | Signal | Action | |--------|--------| | Delivery is stable; complaints and hard bounces remain low | Continue the planned increase. | | `email.deferred` events or provider throttling increase | Hold at the current volume or reduce it until performance normalizes. | | Hard bounces rise | Pause new volume, validate the source of your addresses, and remove invalid recipients. | | Provider-reported spam rate approaches 0.1%, or `email.complained` events increase | Pause the ramp and investigate consent, targeting, content, and frequency. Gmail and Yahoo require senders to remain below 0.3%, but you should operate well below that limit. | 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](/docs/messaging/email/webhooks-events) and the [30xxx taxonomy](/docs/messaging/email/error-codes#asynchronous-delivery-errors-30xxx). #### 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: | Outcome | Auto-suppressed? | Reason recorded | |---------|------------------|-----------------| | Spam complaint (feedback loop report) | **Yes**, always | `spam_complaint` | | Queue expiration (retries exhausted) | **Yes** | `hard_bounce` | | Bounce or OOB with a `5.1.x` or `5.2.x` enhanced code (bad or unavailable mailbox) | **Yes** | `hard_bounce` | | Bounce or OOB with no enhanced code but a 5xx SMTP status | **Yes** (fallback) | `hard_bounce` | | Bounce or OOB with a `5.7.x` enhanced code (security/policy) | **No** — sender-side policy, the address may be valid | — | | Bounce or OOB with any other enhanced code | **No** — not on the suppression allowlist | — | | Administrative bounce | **No** — operator-initiated, not a deliverability signal | — | So a recipient can bounce, be recorded with `status: "bounced"`, and still **not** be added to your suppression list. Check the [suppression list](/docs/messaging/email/suppressions) 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](/docs/messaging/email/rate-limits#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: ```json { "html_body": "

Welcome!

Thanks for signing up.

", "text_body": "Welcome! Thanks for signing up." } ``` ### Use a recognizable from name Recipients who recognize the sender don't mark mail as spam. Use a consistent `from` address and name: ```json { "from": { "email": "notifications@yourdomain.com", "name": "YourApp Notifications" } } ``` ### Include an unsubscribe link 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: | Event | When | Webhook event type | |-------|------|-------------------| | `queued` | Message accepted and queued for delivery | `email.queued` | | `sent` | Accepted by the Telnyx outbound MTA for delivery | `email.sent` | | `delivered` | Receiving server accepted the message | `email.delivered` | | `bounced` | Terminal non-delivery — ordinary bounce, queue expiry, admin bounce, or OOB bounce | `email.bounced` | | `expired` | Retries exhausted before delivery (recipient status; publishes `email.bounced`) | `email.bounced` | | `deferred` | Temporary failure, MTA will retry | `email.deferred` | | `complained` | Telnyx received an individual provider feedback report | `email.complained` | | `opened` | Recipient opened the message (tracking pixel) | `email.opened` | | `clicked` | Recipient clicked a tracked link | `email.clicked` | | `unsubscribed` | Recipient unsubscribed | `email.unsubscribed` | 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](/docs/messaging/email/webhooks-events). ### Webhooks Configure webhooks on your sending domain to receive delivery events in real time. See [Webhooks & Events](/docs/messaging/email/webhooks-events). ### Event polling Poll delivery events with `GET /v2/email_events` — useful for agents and services without a public webhook URL: ```bash curl curl "https://api.telnyx.com/v2/email_events?email_id=EMAIL_ID" \ -H "Authorization: Bearer ***" ``` 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: | Limit | Value | Failure | |-------|-------|---------| | Message body (`html_body` + `text_body`, decoded) | 1 MB | `422` | | Total message (decoded body + decoded attachments) | 25 MB | `422` | | HTTP request body | 150 MB | `413` | 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](/docs/messaging/email/error-codes#request-and-message-size-limits) 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](/docs/messaging/email/domains). --- ### 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](/docs/messaging/email/deliverability). 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. | Stage | What happened | Event | |-------|---------------|-------| | Acceptance | The API validated and persisted the message for sending | `email.queued` | | Handoff | Accepted by the Telnyx outbound MTA | `email.sent` | | Delivery | The remote MX returned SMTP success | `email.delivered` | | Transient failure | The remote MX returned a 4xx — will be retried | `email.deferred` | | Permanent failure | The remote MX returned a 5xx | `email.bounced` | | Expiration | Retries exhausted; the MTA gave up | `email.bounced` | | Complaint | A feedback report (ARF) arrived from the provider | `email.complained` | **`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. | Code | Meaning | Deliverability relevance | |------|---------|--------------------------| | `10015` | Validation failed / bad request | Malformed fields; nothing was sent | | `10007` | Forbidden | The Email API's generic Forbidden code. Covers domain not verified, suspended, degraded, missing an active DKIM key, a `from` address outside the domain's sending profile, trial-account recipient restrictions, and insufficient authorization scope. Read `detail` to find out which | | `10008` | Forbidden (shared domain) | Returned by the email **domains** service when you try to modify, verify, or delete a Telnyx-managed shared domain. Shared domains are usable for sending by every account but read-only for non-owners | | `10027` | Idempotency conflict | Same `Idempotency-Key` reused with a different body. Enforced at the API gateway, not by the Email service | | `10036` | Still processing | Same key, same body — the original request is in flight. Also gateway-enforced | | `reputation_suspended` | Sending suspended | The domain's reputation band dropped to `poor` | `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](/docs/messaging/email/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: | Value | Origin | Meaning | |-------|--------|---------| | `permanent` | KumoMTA `Bounce` record | The MTA classified the failure as a permanent bounce. This is the record category, **not** proof that the address is invalid | | `transient` | Retries exhausted | Repeated 4xx responses; the MTA gave up | | `admin` | Operator-initiated | Cancelled in flight — not a recipient-side signal | | `oob` | Out-of-band report | The remote MTA accepted, then asynchronously rejected | `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: | Field | Contents | |-------|----------| | `code` | The **normalized Telnyx delivery error code** (`30001`–`30099`), as a string | | `message` | The callback/MTA delivery detail text, when available. Usually the remote server's response, but operator-initiated failures carry Telnyx-generated text | | `enhanced_code` | The enhanced SMTP status code in `X.Y.Z` form, when the remote server supplied one | | `source` | Which layer produced the error: `smtp`, `mta`, `api`, `dns`, `dkim`, or `spam` | | `smtp_status` | The **raw SMTP response code** as an integer (for example `550`, `421`), when the failure reached SMTP | | `retryable` | Whether Telnyx will retry this failure automatically | `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. | Code | Title | Source | Retryable | What it means | |------|-------|--------|-----------|---------------| | `30001` | Hard bounce | `smtp` | No | The remote MX permanently rejected the recipient (5xx), or bounced without a usable code | | `30002` | Deferred | `smtp` | Yes | A transient rejection (4xx). Telnyx is still retrying | | `30003` | Injection failure | `mta` | No | The outbound MTA refused the injection | | `30004` | Suppressed recipient | `api` | No | The recipient was on a suppression list | | `30005` | Queue expiry | `mta` | No | Retries were exhausted and the MTA gave up | | `30006` | Gateway rejection | `api` | No | The message was refused before entering the queue | | `30099` | Internal error | `api` | No | The send failed before anything reached the MTA | 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 | Signal | What it means | Response | |--------|---------------|----------| | `email.deferred` | Temporary 4xx; retries in progress | Nothing — do not resend. Persistent deferrals against one provider indicate throttling; reduce volume to that provider | | `bounce_category: permanent` | The MTA classified the failure as a permanent bounce | Check `error_evidence.enhanced_code` before concluding the address is bad. Qualifying bounces (`5.1.x`, `5.2.x`, or a 5xx with no enhanced code) are auto-suppressed asynchronously — a suppression may not be visible the instant the webhook arrives | | `bounce_category: transient` | Retries exhausted | Check whether the provider was throttling you; this is a volume or reputation signal | | `bounce_category: oob` | Accepted, then asynchronously rejected | Treat as a hard bounce; the same enhanced-code suppression rules apply | | `error_evidence.enhanced_code` starting `5.7` | Security or policy rejection | Not auto-suppressed. Read `error_evidence.message` to tell authentication failure apart from reputation or content policy, then fix the indicated cause | | `email.complained` | A recipient reported the message as spam | Auto-suppressed (asynchronously). Review consent, targeting, and frequency | | `reputation_suspended` | Reputation band dropped to `poor` | Sending is halted. Contact support after correcting list quality | 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. ## Related documentation - [Deliverability and Domain Warm-up](/docs/messaging/email/deliverability) — setup and warm-up procedures - [Error Codes](/docs/messaging/email/error-codes) — complete error reference - [Webhooks & Events](/docs/messaging/email/webhooks-events) — event types and payloads - [Domains & DKIM](/docs/messaging/email/domains) — domain registration and DNS records - [Suppressions](/docs/messaging/email/suppressions) — suppression list behavior --- ## 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](https://shopify.github.io/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](#render-and-preview) 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_templates \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "name": "Welcome Email", "subject": "Welcome to Telnyx, {{first_name}}!", "html_body": "

Hi {{first_name}},

Thanks for signing up.

", "text_body": "Hi {{first_name}}, thanks for signing up." }' ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string | yes | Letters, numbers, spaces, hyphens, and underscores only (`^[A-Za-z0-9 _-]+$`). Unique per account. | | `subject` | string | no | Liquid template subject. | | `html_body` | string | no | Liquid template HTML body. | | `text_body` | string | no | Liquid template text body. | | `variables` | array of strings | no | Variable names the template uses. **Auto-extracted** from `subject`, `html_body`, and `text_body` when you omit it. The extractor is deliberately narrow (see [Auto-extraction limits](#auto-extraction-limits)) — supply it explicitly when your template uses filters or other complex Liquid. | A successful create returns `201 Created` with the template in `data`: ```json { "data": { "record_type": "email_template", "id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "name": "Welcome Email", "subject": "Welcome to Telnyx, {{first_name}}!", "html_body": "

Hi {{first_name}},

Thanks for signing up.

", "text_body": "Hi {{first_name}}, thanks for signing up.", "variables": ["first_name"], "created_at": "2026-07-06T12:00:00.000000Z", "updated_at": "2026-07-06T12:00:00.000000Z" } } ``` 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`. ```bash curl curl https://api.telnyx.com/v2/email_templates?page_size=10 \ -H "Authorization: Bearer ***" ``` ```json { "data": [ { "record_type": "email_template", "id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "name": "Welcome Email", "subject": "Welcome to Telnyx, {{first_name}}!", "html_body": "

Hi {{first_name}},

", "text_body": "Hi {{first_name}}", "variables": ["first_name"], "created_at": "2026-07-06T12:00:00.000000Z", "updated_at": "2026-07-06T12:00:00.000000Z" } ], "meta": { "page_size": 10, "page_cursor": "MjAyNi0wNy0wNlQxMjowMDowMHw3YTdjMW..." } } ``` `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}`: ```bash curl curl https://api.telnyx.com/v2/email_templates/7a7c1a2b-1111-4c72-8c21-2bbf3d40c123 \ -H "Authorization: Bearer ***" ``` 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. ```bash curl curl -X PATCH https://api.telnyx.com/v2/email_templates/7a7c1a2b-1111-4c72-8c21-2bbf3d40c123 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "subject": "Welcome aboard, {{first_name}}!" }' ``` 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}`: ```bash curl curl -X DELETE https://api.telnyx.com/v2/email_templates/7a7c1a2b-1111-4c72-8c21-2bbf3d40c123 \ -H "Authorization: Bearer ***" ``` 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](https://shopify.github.io/liquid/) syntax. The renderer is the [Solid](https://hexdocs.pm/solid) Liquid engine. ### What's substitutable Variable substitution applies to **all three content fields** — `subject`, `html_body`, and `text_body`. Each is rendered independently from the same `template_variables` map. ### Syntax | Construct | Example | Result | |-----------|---------|--------| | Output a variable | `{{first_name}}` | The value of `first_name`. | | Dot-notation access | `{{user.name}}` | The `name` field of a `user` object. | | For loop | `{% for item in items %}{{item.title}}{% endfor %}` | Iterates the `items` collection. | | Assign a local | `{% assign greeting = "Hi" %}{{greeting}}` | Creates a local for use in the rest of the template. | ### 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](#render-and-preview) 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: | Template content | Extracted | |------------------|-----------| | `Hi {{first_name}}` | `["first_name"]` | | `{{ user.name }}` | `["user"]` | | `{% for item in items %}{{item.title}}{% endfor %}` | `["item", "items"]` | | `{{user_input \| escape}}` | `[]` | | `{{ price \| round: 2 }}` | `[]` | | `{% if admin %}{{name}}{% endif %}` | `["name"]` (the `if` condition `admin` is missed) | 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](https://shopify.github.io/liquid/filters/escape/) 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. ```bash curl curl -X POST https://api.telnyx.com/v2/email_templates/7a7c1a2b-1111-4c72-8c21-2bbf3d40c123/render \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ***" \ -d '{ "template_variables": { "first_name": "Ada" } }' ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `template_variables` | object | no | Variable values for Liquid rendering. Defaults to `{}`. Non-object values are silently coerced to an empty map — this coercion is **specific to the render endpoint**; the same value on `POST /email_messages` returns `422 Validation Failed`. | ### 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: ```json { "data": { "record_type": "email_template", "id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123", "name": "Welcome Email", "subject": "Welcome to Telnyx, Ada!", "html_body": "

Hi Ada,

Thanks for signing up.

", "text_body": "Hi Ada, thanks for signing up.", "variables": ["first_name"], "created_at": "2026-07-06T12:00:00.000000Z", "updated_at": "2026-07-06T12:00:00.000000Z" } } ``` 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 | HTTP | Code | When | |------|------|------| | 404 | `10001` | The template id doesn't exist or belongs to another account. | | 422 | `10015` | Liquid parsing or rendering failed (malformed Liquid syntax or a filter error). The standard `detail` field carries the engine's error detail. | A 422 response from the standalone render endpoint uses the standard error envelope: ```json { "errors": [ { "code": "10015", "title": "Template Render Failed", "detail": "Liquid parse error: ..." } ] } ``` 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 `