> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email Webhooks & Events

> Track every email event with Telnyx — subscribe via webhooks or poll the Events API. Complete event-type reference, webhook CRUD, Ed25519 signature verification, and polling patterns.

Every email you send through Telnyx emits events as it moves through the delivery lifecycle — from `queued` to `sent` to `delivered` (or `bounced`/`failed`), plus engagement signals like `opened` and `clicked`. Your email domains also emit lifecycle webhooks (`email_domain.verified`, `email_domain.suspended`, …). This guide covers three related surfaces: **webhooks** (push, real time, recipient-scoped), **message-event polling** (pull, on demand, mixed cardinality), and an **inbound WebSocket** (push, real time, inbound only).

Polling is a first-class option for stored outbound message events: if you don't have a publicly reachable HTTPS endpoint — common for agents, batch jobs, and on-prem services — you can query `GET /email_events` with no webhook infrastructure. Polling and webhooks overlap, but their event taxonomies and payload shapes are not identical — see [Event schemas by surface](#event-schemas-by-surface).

## Prerequisites

* A [Telnyx account](https://telnyx.com/sign-up) with an [email domain](/docs/messaging/email/domains) configured and verified
* Your [API key](https://portal.telnyx.com/#/app/api-keys)
* For webhooks: a publicly accessible HTTPS endpoint (or [ngrok](/development/development-tools/ngrok-setup) for local development) and your [public key](https://portal.telnyx.com/#/app/api-keys) for signature verification

***

## Event model

An email message moves through a series of states. Each transition emits an event carrying the event type, a timestamp, the email message ID, and an optional payload with delivery/engagement details.

### Lifecycle of a message

Outbound emails typically progress:

```
email.scheduled → email.queued → email.sent → email.delivered
                                                  ↘ email.deferred (temporary) → email.delivered
                                                  ↘ email.bounced (terminal non-delivery)
                                                  ↘ email.failed
              ↘ email.sandbox   (sandbox mode only — no delivery attempted)
```

<Callout type="warning">
  `email.bounced` is **not** limited to a permanent remote rejection. Four distinct MTA outcomes — an ordinary bounce, a queue expiration, an administrative bounce, and an asynchronous out-of-band (OOB) bounce — all publish the same `email.bounced` webhook. The webhook event type alone does not tell you which one happened. Read `payload.error_evidence.code` (the 30xxx normalized error code) to distinguish them. The recipient's authoritative `status` on the recipient row may differ from the webhook's `status` field, which carries the event slug.
</Callout>

The per-recipient status recorded on the recipient row can diverge from the webhook event type. Expiration resolves the recipient to `expired`, while an administrative bounce resolves it to `failed` — both still publish `email.bounced`:

| MTA outcome              | Webhook event type | Recipient `status` | `error_evidence.code`                    |
| ------------------------ | ------------------ | ------------------ | ---------------------------------------- |
| Ordinary bounce          | `email.bounced`    | `bounced`          | `30001` (or `30002` on a 4xx)            |
| Queue expiration         | `email.bounced`    | `expired`          | `30005`                                  |
| Administrative bounce    | `email.bounced`    | `failed`           | `30003` (or `30099` without SMTP status) |
| Out-of-band (OOB) bounce | `email.bounced`    | `bounced`          | `30001`                                  |

There is no `email.expired` webhook event type — `expired` is a recipient status, not an event type.

After delivery, engagement events fire as recipients interact:

```
email.delivered → email.opened → email.clicked
                                ↘ email.complained
                                ↘ email.unsubscribed
```

Inbound emails (messages received at a domain with inbound enabled) emit a single `email.received` event.

Separately, your email domains emit lifecycle events — `email_domain.created` when a domain is registered, `email_domain.verified` when its DNS records validate, and `email_domain.degraded` / `email_domain.suspended` / `email_domain.deleted` as its health changes.

### Webhook event-type reference

Email webhook subscriptions accept **19 event types**: 13 outbound/tracking `email.*` events handled by the email API (12 published plus `email.sending` which is accepted but never delivered), `email.received` published by the inbound pipeline, and 5 `email_domain.*` domain-lifecycle events published by the domains service. This is the complete webhook subscription allowlist. The polling API uses a separate 16-type message-event taxonomy described under [Consume via polling](#consume-via-polling).

<Callout type="warning">
  `email.sending` is accepted by the subscription allowlist but **never published** as a webhook. The service records a stored `sending` event when a message is handed to the outbound MTA, but explicitly suppresses its webhook publication (`skip_webhook: true`). You can subscribe to it, but you will never receive an `email.sending` callback. The `sending` event *is* returned by the Events API — see [Consume via polling](#consume-via-polling). Only **18** of the 19 allowed event types are actually delivered as webhooks.
</Callout>

#### Message lifecycle (`email.*`)

| Event type        | Trigger                                                                                                                 | Payload highlights                                                                                                                                                                                    |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email.scheduled` | A message with a future `scheduled_at` has been accepted and queued for later sending.                                  | `scheduled_at` timestamp.                                                                                                                                                                             |
| `email.queued`    | The message has entered the outbound queue.                                                                             | Recipient-scoped snapshot: `id`, `recipient_id`, `status`, `occurred_at`, `from`, the single recipient field, and `subject`.                                                                          |
| `email.sandbox`   | A message was accepted in sandbox mode — no delivery is attempted.                                                      | Recipient-scoped snapshot with `status: "sandbox"`.                                                                                                                                                   |
| `email.sent`      | The recipient was accepted by the Telnyx outbound MTA for delivery. Remote acceptance is reported by `email.delivered`. | Recipient-scoped snapshot.                                                                                                                                                                            |
| `email.delivered` | The receiver accepted the message for this recipient (SMTP success).                                                    | Recipient-scoped snapshot with `status: "delivered"`.                                                                                                                                                 |
| `email.deferred`  | Delivery temporarily failed; the MTA will retry.                                                                        | Recipient-scoped snapshot with `status: "deferred"`, plus `error_evidence` and `errors[]`.                                                                                                            |
| `email.bounced`   | Terminal non-delivery for this recipient — an ordinary bounce, queue expiration, administrative bounce, or OOB bounce.  | Recipient-scoped snapshot with `status: "bounced"` (always the event slug, not the recipient status), plus `error_evidence` and `errors[]`. Use `error_evidence.code` to distinguish bounce subtypes. |
| `email.failed`    | The message could not be sent for this recipient (sender-side failure).                                                 | Recipient-scoped snapshot with `status: "failed"`, plus `error_evidence` and `errors[]`.                                                                                                              |
| `email.received`  | An inbound message arrived at one of your enabled inbound domains.                                                      | Inbound sender, recipients, subject, and stored-content references.                                                                                                                                   |

#### Engagement & tracking (`email.*`)

| Event type           | Trigger                                                                                   | Payload highlights                                     |
| -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `email.opened`       | A recipient opened the message (tracking pixel fired).                                    | Recipient, first-open flag, IP address, user-agent.    |
| `email.clicked`      | A recipient clicked a tracked link in the message.                                        | Recipient, link URL, user-agent.                       |
| `email.complained`   | Telnyx received an individual spam feedback report from a participating mailbox provider. | Recipient.                                             |
| `email.unsubscribed` | The recipient unsubscribed via a tracked unsubscribe link.                                | Recipient, IP address, user-agent, unsubscribe method. |

#### Domain lifecycle (`email_domain.*`)

| Event type               | Trigger                                                                  | Payload highlights                                                                 |
| ------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `email_domain.created`   | A new email domain was registered to your account.                       | Domain resource snapshot (`id`, `domain`, `status`, DNS and configuration fields). |
| `email_domain.verified`  | The domain's DNS records passed verification and it's ready for sending. | Domain resource snapshot.                                                          |
| `email_domain.degraded`  | A previously verified domain failed a later DNS drift check.             | Domain resource snapshot.                                                          |
| `email_domain.suspended` | The domain was suspended (reputation or policy).                         | Domain resource snapshot.                                                          |
| `email_domain.deleted`   | The domain was deleted.                                                  | Domain resource snapshot.                                                          |

<Callout type="info">
  The five `email_domain.*` events are emitted by the Telnyx email-domains service, `email.received` is emitted by the inbound pipeline, and the other thirteen `email.*` events are handled by the email API (twelve are published as webhooks; `email.sending` is accepted by the subscription schema but never delivered). A webhook subscription can span the full 19-type allowlist; only 18 types are actually delivered. `GET /email_events` stores and returns only its separate outbound message-event taxonomy; it does not return domain lifecycle events or `email.received`.
</Callout>

***

## Consume via webhooks

Webhooks push events to your HTTPS endpoint in real time. Email webhooks are **scoped to a domain**: you create a subscription on a specific email domain, and it fires only for the event types you allowlist on that subscription.

### Webhook payload envelope

<Callout type="warning">
  **Delivery webhooks are recipient-scoped, not message-scoped.** One recipient produces one webhook event carrying one status and one address. The payload never contains arrays of `to`/`cc`/`bcc`. A message sent to three recipients produces three separate `email.delivered` callbacks, each with its own `recipient_id` and its own outcome. This differs from the Events API, which returns stored events with mixed cardinality (message-scoped admission events plus per-recipient delivery transitions) — see [Event schemas by surface](#event-schemas-by-surface).

  In rare cases where a callback carries no durable recipient ID (for example, an ownership mismatch or a pre-recipient-ID legacy message), the webhook falls back to a message-scoped payload without `recipient_id`. Treat `recipient_id` as present in the normal case but do not crash on its absence.
</Callout>

Every webhook delivery is an HTTP `POST` with a JSON body in this shape:

```json theme={null}
{
  "data": {
    "event_type": "email.delivered",
    "id": "f3a2c1d0-1234-4abc-9def-67890abcdef0",
    "occurred_at": "2026-07-06T18:24:00.000Z",
    "payload": {
      "id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b",
      "recipient_id": "7c1e9a44-2f60-4d1b-9a0e-3c7b5e8d1f22",
      "status": "delivered",
      "occurred_at": "2026-07-06T18:24:00.000Z",
      "from": { "email": "sender@example.com", "name": "Telnyx" },
      "to": { "email": "recipient@example.com", "name": null, "kind": "to" },
      "subject": "Welcome"
    },
    "record_type": "event"
  },
  "meta": {
    "attempt": 1
  }
}
```

| Field              | Description                                                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data.event_type`  | One of the 18 published webhook event types above (`email.sending` is accepted but never delivered).                                                               |
| `data.id`          | Unique identifier for this event delivery. Recipient-scoped lifecycle events derive a stable per-recipient id, so each recipient's callback has its own `data.id`. |
| `data.occurred_at` | ISO 8601 timestamp of when the event occurred.                                                                                                                     |
| `data.payload`     | Recipient-scoped event data. See the field table below.                                                                                                            |
| `data.record_type` | Always `"event"`.                                                                                                                                                  |
| `meta.attempt`     | Delivery attempt number (starts at 1).                                                                                                                             |

#### Recipient lifecycle payload fields

| Field                 | Type              | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| --------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | UUID              | The email **message** id. Use this to correlate all recipients of one send, and to query the Events API.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `recipient_id`        | UUID              | The per-recipient row id. Unique per recipient per message — this is what makes each callback distinct.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `status`              | string            | The webhook event slug for this transition: `queued`, `scheduled`, `sent`, `delivered`, `deferred`, `bounced`, `failed`, `opened`, `clicked`, `unsubscribed`, `complained`, `received`, or `sandbox`. This is the event type name, not the authoritative recipient status — for `email.bounced` the status is always `bounced` even when the recipient row is `expired` or `failed`. Use `error_evidence.code` (the 30xxx taxonomy) to distinguish failure subtypes. The authoritative recipient status is available via `recipient_statuses` on the message or the recipient endpoints. |
| `occurred_at`         | ISO 8601          | When this recipient's transition occurred.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `from`                | object            | `{email, name}` of the sender.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `to` \| `cc` \| `bcc` | object            | **Exactly one** of these keys is present, matching this recipient's kind. The value is a single object `{email, name, kind}` — never an array.                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `subject`             | string            | The message subject.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `error_evidence`      | object \| absent  | Normalized error contract. Present only on error statuses. See below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `errors`              | array \| absent   | Array form of the same normalized error. Present only on error statuses. See below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `smtp_code`           | integer \| absent | Raw SMTP status from the remote MX, preserved additively for ops triage.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `smtp_response`       | string \| absent  | Raw SMTP response text, preserved additively.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

<Callout type="info">
  **BCC addresses are redacted.** A webhook for a BCC recipient carries a `bcc` key whose `email` is an HMAC-SHA256 sentinel of the form `redacted+sha256:<16-hex>@bcc.invalid`, with `name: null`. The sentinel is stable for the same address, so support tooling can correlate records without the plaintext address ever leaving Telnyx. You cannot recover the original BCC address from a webhook.
</Callout>

#### Error evidence on failure events

Error statuses (`bounced`, `failed`, `deferred`, `expired`, `suppressed`, `gw_reject`) carry a normalized error contract in **two equivalent shapes**. Prefer `error_evidence` for programmatic branching; `errors[]` mirrors the platform-wide Telnyx error array convention.

```json theme={null}
{
  "error_evidence": {
    "code": "30001",
    "message": "550 5.1.1 <user@example.com>: Recipient address rejected",
    "enhanced_code": "5.1.1",
    "source": "smtp",
    "smtp_status": 550,
    "retryable": false
  },
  "errors": [
    {
      "code": "30001",
      "title": "Hard bounce",
      "detail": "550 5.1.1 <user@example.com>: Recipient address rejected",
      "source": "smtp",
      "smtp_status": 550,
      "enhanced_code": "5.1.1",
      "retryable": false
    }
  ]
}
```

| Field                              | Description                                                                                                                                                                                             |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`                             | The **normalized 30xxx delivery error code** — never a raw SMTP status. See [Asynchronous delivery errors](/docs/messaging/email/error-codes#asynchronous-delivery-errors-30xxx) for the full taxonomy. |
| `message` (`detail` in `errors[]`) | The raw SMTP response text, when the failure reached SMTP.                                                                                                                                              |
| `enhanced_code`                    | The DSN enhanced status code (e.g. `5.1.1`), when the remote supplied one.                                                                                                                              |
| `source`                           | Where the failure was observed: `smtp`, `mta`, or `api`.                                                                                                                                                |
| `smtp_status`                      | The raw SMTP status as an integer, or `null` for failures that never reached SMTP (queue expiry, suppression, gateway rejection).                                                                       |
| `retryable`                        | Whether resubmitting the same message could succeed. Only `30002` (Deferred) is retryable.                                                                                                              |

<Callout type="warning">
  **`bounce_category` is an internal classification field.** It does not appear in normal recipient-scoped webhook payloads and is not part of the public webhook contract. In the normal callback path it never reaches the stored event row (the correlation map does not include it). A legacy message-scoped fallback path may persist it in stored events, but the Events API sanitizer does not explicitly strip it. Do not build consumer logic that reads `bounce_category` from any public surface. Use `error_evidence.code` (the 30xxx taxonomy) to distinguish failure types programmatically — for example `30001` for a hard bounce versus `30005` for a queue expiry.
</Callout>

### Event schemas by surface

The three consumption surfaces do **not** share a schema. They differ in cardinality (one event per recipient for webhooks versus mixed cardinality for the Events API) and in field set. Do not write a single parser that assumes all three are interchangeable.

| Surface                                | Cardinality                                                                                              | Shape                                                                                                                                                                                                                              | Carries `error_evidence`                              |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Recipient lifecycle webhook            | One event **per recipient** per transition                                                               | `data.payload` with `recipient_id` and a single `to`/`cc`/`bcc` object                                                                                                                                                             | Yes, on error statuses                                |
| Events API (`GET /email_events`)       | Mixed cardinality: message-scoped admission events and **per-recipient** delivery/engagement transitions | `{id, type, occurred_at, email_id, email, payload}` where `email` is a message summary with recipient **arrays**. Recipient transitions produce multiple rows per message even though the response does not expose `recipient_id`. | No — internal fields are stripped before the response |
| Inbound WebSocket (`/email_events/ws`) | One frame per inbound message                                                                            | `{event_type, account_id, inbox_id, occurred_at, payload}`                                                                                                                                                                         | Not applicable (inbound only)                         |

<Callout type="warning">
  The Events API strips observability-only and internal fields from the stored event payload before returning it. Fields you see in a webhook — including `error_evidence` — are not guaranteed to be present in the Events API response. Treat the webhook as the source of delivery-failure detail.
</Callout>

### Subscribe to events (webhook CRUD)

<Callout type="info">
  **Webhook configuration is owned by the email-domains service**, not the Email API. The `/email_domains/{domain_id}/webhooks` CRUD routes are served by the Telnyx email-domains service; the Email API only *resolves* a sending domain's webhook list at publish time to decide where to fan out. Because the two services version independently, treat the email-domains API reference as authoritative for these routes' request/response schemas and error codes. See [Domains & DKIM](/docs/messaging/email/domains) for domain setup.
</Callout>

Email webhooks live under the domains surface at `/email_domains/{domain_id}/webhooks`. A subscription requires a `url` and an `events` allowlist (at least one event type) — there is no default-to-all.

#### Create a webhook

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_domains/123e4567-e89b-12d3-a456-426614174000/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "url": "https://example.com/webhooks/email",
    "events": ["email.sent", "email.delivered", "email.bounced"]
  }'
```

Response `201 Created`:

```json theme={null}
{
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174003",
    "record_type": "email_webhook",
    "url": "https://example.com/webhooks/email",
    "events": ["email.sent", "email.delivered", "email.bounced"],
    "domain_id": "123e4567-e89b-12d3-a456-426614174000",
    "created_at": "2026-06-18T12:00:00Z",
    "updated_at": "2026-06-18T12:00:00Z"
  }
}
```

A domain can have multiple webhooks. Each event is delivered to every webhook on that domain whose `events` allowlist includes the event type — so you can route different event types to different endpoints by creating separate subscriptions.

#### List webhooks for a domain

```bash curl theme={null}
curl https://api.telnyx.com/v2/email_domains/123e4567-e89b-12d3-a456-426614174000/webhooks \
  -H "Authorization: Bearer ***"
```

List responses use offset pagination (`page[number]`, `page[size]`) with a `meta` block of `{ page_number, page_size, total_pages, total_results }`.

#### Retrieve, update, and delete

| Operation    | Method & path                                     | Notes                                                                                           |
| ------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Retrieve one | `GET /email_domains/{domain_id}/webhooks/{id}`    | Returns the full `EmailWebhook` object.                                                         |
| Update       | `PATCH /email_domains/{domain_id}/webhooks/{id}`  | `url` and/or `events` (optional; `domain_id` is not mutable). `events` must still have ≥1 item. |
| Delete       | `DELETE /email_domains/{domain_id}/webhooks/{id}` | Returns the deleted webhook in `data`.                                                          |

All four operations return `404` if the domain or webhook is not found; create/update return `422` on validation failure.

### Event filtering per subscription

The `events` array on each webhook is an **allowlist** — Telnyx only delivers events whose type appears in that list to that webhook's URL. To receive every event type, list all 18 published types (19 are accepted, but `email.sending` is never delivered). To receive only delivery confirmations and failures:

```json theme={null}
{ "events": ["email.delivered", "email.bounced", "email.failed"] }
```

Filtering happens before delivery: an event whose type isn't on the allowlist is never sent to that endpoint. You can update the allowlist at any time with `PATCH`.

### Signature verification

Telnyx signs every webhook delivery with **Ed25519 public-key cryptography** so you can verify that a request genuinely came from Telnyx. **This is strongly recommended for production.**

Each webhook request includes two headers:

| Header                     | Description                                    |
| -------------------------- | ---------------------------------------------- |
| `telnyx-signature-ed25519` | Base64-encoded Ed25519 signature.              |
| `telnyx-timestamp`         | Unix timestamp of when the request was signed. |

The signature is computed over the string `{timestamp}|{raw_json_body}`. This is the standard Telnyx webhook signing scheme — email events are delivered by the same central delivery service that signs all Telnyx webhooks.

<Callout type="info">
  Outbound message events are published to RabbitMQ by the email API, <code>email.received</code> by the inbound pipeline, and domain lifecycle events by the email-domains service. Telnyx's central <strong>event\_dispatcher</strong> service signs payloads (Ed25519 / <code>TelnyxSigner</code>), performs HTTP delivery with retries, and tracks delivery status. The signing and retry logic is shared across all Telnyx webhook products — email uses the identical scheme as messaging webhooks.
</Callout>

#### Get your public key

Find your public key in the [Mission Control Portal](https://portal.telnyx.com/#/app/api-keys) under **Keys & Credentials → Public Key**.

#### Verification examples

<CodeGroup>
  ```javascript Node theme={null}
  import express from 'express';
  import Telnyx from 'telnyx';

  const app = express();
  const telnyx = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });

  // Use express.raw to get the exact request body — the signature is
  // computed over the raw JSON bytes, not a re-serialized object.
  app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['telnyx-signature-ed25519'];
    const timestamp = req.headers['telnyx-timestamp'];
    const payload = req.body.toString('utf8');

    try {
      const event = telnyx.webhooks.constructEvent(
        payload,
        signature,
        timestamp,
        process.env.TELNYX_PUBLIC_KEY
      );
      console.log('Verified email event:', event.data.event_type);
      res.sendStatus(200);
    } catch (err) {
      console.error('Signature verification failed:', err.message);
      res.sendStatus(403);
    }
  });

  app.listen(5000, () => console.log('Server running on port 5000'));
  ```

  ```python Python theme={null}
  from flask import Flask, request
  import telnyx

  app = Flask(__name__)
  telnyx.api_key = "YOUR_API_KEY"
  telnyx.public_key = "YOUR_PUBLIC_KEY"

  @app.route('/webhooks', methods=['POST'])
  def webhooks():
      payload = request.data  # raw bytes
      signature = request.headers.get('telnyx-signature-ed25519')
      timestamp = request.headers.get('telnyx-timestamp')

      try:
          event = telnyx.Webhook.construct_event(payload, signature, timestamp)
          print(f"Verified email event: {event['data']['event_type']}")
          return '', 200
      except telnyx.error.SignatureVerificationError:
          return 'Invalid signature', 403
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/ed25519"
  	"encoding/base64"
  	"fmt"
  	"io"
  	"net/http"
  	"os"
  )

  func verifySignature(payload, signature, timestamp string, publicKey ed25519.PublicKey) bool {
  	signedPayload := timestamp + "|" + payload
  	sigBytes, err := base64.StdEncoding.DecodeString(signature)
  	if err != nil {
  		return false
  	}
  	return ed25519.Verify(publicKey, []byte(signedPayload), sigBytes)
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	signature := r.Header.Get("telnyx-signature-ed25519")
  	timestamp := r.Header.Get("telnyx-timestamp")

  	pubKeyBytes, _ := base64.StdEncoding.DecodeString(os.Getenv("TELNYX_PUBLIC_KEY"))
  	publicKey := ed25519.PublicKey(pubKeyBytes)

  	if !verifySignature(string(body), signature, timestamp, publicKey) {
  		http.Error(w, "Invalid signature", http.StatusForbidden)
  		return
  	}

  	fmt.Println("Webhook verified")
  	w.WriteHeader(http.StatusOK)
  }

  func main() {
  	http.HandleFunc("/webhooks", webhookHandler)
  	http.ListenAndServe(":5000", nil)
  }
  ```
</CodeGroup>

<Callout type="warning">
  **Verify the raw body.** The signature is computed over the exact bytes Telnyx sent. Re-serializing a parsed JSON object (e.g. `JSON.stringify(req.body)` after body-parser) can reorder keys and break verification. Read the raw request body, as shown above.
</Callout>

<Callout type="warning">
  **Replay protection.** Reject webhooks where `telnyx-timestamp` is more than 5 minutes outside your server's clock to prevent replay attacks.
</Callout>

### Delivery and retries

<Steps>
  <Step title="An event occurs">
    A message changes state or a recipient interacts, and Telnyx emits an event.
  </Step>

  <Step title="Telnyx sends a POST">
    The event\_dispatcher service signs the payload and sends an HTTP `POST` to every webhook URL on the matching domain whose `events` allowlist includes the event type.
  </Step>

  <Step title="Your server responds">
    Return a `2xx` status code to acknowledge receipt.
  </Step>

  <Step title="Retries on failure">
    If your endpoint doesn't return `2xx`, Telnyx retries the delivery. Delivery uses the standard Telnyx webhook retry behavior with backoff.
  </Step>
</Steps>

<Callout type="info">
  Retries, URL health checking, and delivery-status tracking are handled by the central event\_dispatcher service shared across all Telnyx webhook products. Email webhooks inherit the same delivery guarantees as messaging webhooks.
</Callout>

***

## Consume via polling

If you don't have a public HTTPS endpoint — or you want to pull stored outbound message history on demand — use the Events API. Polling is built for agents, batch jobs, on-prem services, and reconciliation of outbound message events after a webhook outage.

The polling model accepts these 16 bare event types: `queued`, `deferred`, `scheduled`, `cancelled`, `sandbox`, `sending`, `sent`, `failed`, `delivered`, `bounced`, `complained`, `rejected`, `opened`, `clicked`, `unsubscribed`, and `daily_limit_exceeded`. Unlike webhook subscriptions, polling includes `sending`, `cancelled`, `rejected`, and `daily_limit_exceeded`, but excludes `received` and every `email_domain.*` event.

<Callout type="info">
  `daily_limit_exceeded` is a **scheduled-send admission failure**, not a webhook subscription event. It is persisted when a scheduled send fires and the account's daily send limit no longer has room for it — the message is rejected at fire time rather than at submission time. It cannot be selected in a webhook `events` allowlist; poll for it or query it via `filter[event_type]=daily_limit_exceeded`.
</Callout>

### List account events

`GET /email_events` returns events for the authenticated account, oldest-first within a time window, with cursor pagination and server-side filtering.

```bash curl theme={null}
curl "https://api.telnyx.com/v2/email_events?page_size=50&event_type=delivered,bounced&from=2026-07-01T00:00:00Z" \
  -H "Authorization: Bearer ***"
```

Response (`200`):

```json theme={null}
{
  "data": [
    {
      "record_type": "email_event",
      "id": "a1b2c3d4-5678-9012-abcd-ef1234567890",
      "type": "delivered",
      "occurred_at": "2026-07-06T18:24:00.000Z",
      "email_id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b",
      "email": {
        "from": { "email": "sender@example.com", "name": "Telnyx" },
        "to": [{ "email": "recipient@example.com" }],
        "cc": [],
        "subject": "Welcome"
      },
      "payload": { "recipient": "recipient@example.com" }
    }
  ],
  "meta": {
    "page_size": 50,
    "time_range": {
      "from": "2026-07-01T00:00:00Z",
      "to": "2026-07-31T00:00:00Z"
    },
    "page_cursor": "eyJvY2N1cnJlZF9hdCI6IjIwMjYtMDctMDZUMTg6MjQ6MDBaIn0="
  }
}
```

#### Filters

| Parameter     | Type                | Description                                                                                                       |
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `page_size`   | integer             | Page size (default 25, clamped 1–100).                                                                            |
| `page_cursor` | string              | Opaque cursor returned in the previous page's `meta.page_cursor`. Omit on the first request.                      |
| `event_type`  | string \| string\[] | Comma-separated list **or** repeated query param. Filters to these event types. Unknown values return no matches. |
| `email_id`    | UUID                | Filter to events for a single email message. Invalid UUIDs are silently ignored.                                  |
| `from`        | ISO 8601 timestamp  | Include events at or after this time.                                                                             |
| `to`          | ISO 8601 timestamp  | Include events at or before this time.                                                                            |

The `event_type` filter accepts the **bare event names** (e.g. `delivered`, `bounced`, `opened`) — not the `email.`-prefixed webhook event types. So `event_type=delivered,bounced` filters to delivery and bounce events.

<Callout type="warning">
  **The message filter parameter is `email_id`, not `message_id`.** Filter a single message's events with `?email_id=<email-id>`. For compatibility with the Telnyx v2 bracket convention, the service also accepts the legacy alias `filter[message_id]=<email-id>`, which normalizes to `email_id` — but note that `filter[email_id]` is **not** recognized. Unrecognized filter params are silently ignored and return unfiltered results, so a typo looks like a working query that returns everything.
</Callout>

#### Pagination and ordering

Events are ordered by `occurred_at` ascending, then by `id`. Pages are cursor-based: each response includes `meta.page_cursor` when more results are available, and **omits the field entirely** (rather than `null`) when you've reached the end of the result set. Pass that cursor as `page_cursor` on the next request.

The response `meta` always includes `time_range` — the resolved `{from, to}` query window, not the timestamps of the first and last returned events. With neither bound, `from` defaults to 30 days ago and `to` is `null`. With only `from`, `to` is capped at 30 days after `from`.

<Callout type="info">
  The `email` summary block (`{from, to, cc, subject}`) is present when the underlying message is available for preload, and **omitted** (not `null`) when it isn't. Don't rely on it always being present — guard for its absence.
</Callout>

### Per-message event history

To get the event timeline for a single message, use `GET /email_messages/{email_id}/events`. This is scoped to a message you own and returns its events in chronological order.

```bash curl theme={null}
curl "https://api.telnyx.com/v2/email_messages/b0c7e8cb-6227-4c74-9f32-c7f80c30934b/events?page_size=50" \
  -H "Authorization: Bearer ***"
```

Response (`200`):

```json theme={null}
{
  "data": [
    {
      "type": "queued",
      "occurred_at": "2026-07-06T18:20:00.000Z"
    },
    {
      "type": "sent",
      "occurred_at": "2026-07-06T18:20:02.000Z"
    },
    {
      "type": "delivered",
      "occurred_at": "2026-07-06T18:20:05.000Z",
      "payload": { "recipient": "recipient@example.com" }
    }
  ],
  "meta": {
    "page_size": 50
  }
}
```

Returns `404` if the email message doesn't exist or belongs to another account. The per-message endpoint supports `page_size` and `page_cursor` but **not** the `event_type`/`from`/`to`/`email_id` filters (they don't apply — you're already scoped to one message).

### Event statistics

`GET /email_events/stats` returns aggregate counts and derived rates for the authenticated account over a time window.

```bash curl theme={null}
curl "https://api.telnyx.com/v2/email_events/stats?from=2026-07-01T00:00:00Z&to=2026-07-07T00:00:00Z" \
  -H "Authorization: Bearer ***"
```

Response (`200`):

```json theme={null}
{
  "data": {
    "record_type": "email_event_stats",
    "counts": {
      "queued": 12500,
      "sent": 12480,
      "delivered": 12310,
      "deferred": 42,
      "bounced": 128,
      "opened": 7800,
      "clicked": 2100,
      "complained": 4,
      "unsubscribed": 35,
      "failed": 2
    },
    "rates": {
      "delivery_rate": 98.48,
      "bounce_rate": 1.02,
      "deferred_rate": 0.34,
      "open_rate": 63.36,
      "click_rate": 26.92,
      "complaint_rate": 0.03
    },
    "time_range": {
      "from": "2026-07-01T00:00:00Z",
      "to": "2026-07-07T00:00:00Z"
    }
  }
}
```

Stats count **10 event types**: `queued`, `sent`, `delivered`, `deferred`, `bounced`, `opened`, `clicked`, `complained`, `unsubscribed`, and `failed`. Counts and rates are recipient-level: every address in `to`, `cc`, and `bcc` counts separately, while repeated events of the same type for the same message and recipient count once. Partial MTA injection results count successful recipients as `sent` and unsuccessful recipients as `failed`. The Email API rejects duplicate normalized recipient addresses across `to`, `cc`, and `bcc`. Other polling event types (such as `scheduled`, `cancelled`, `sandbox`, `sending`, `rejected`, and `daily_limit_exceeded`) are not included in stats. The rates are percentages:

| Rate             | Formula                  |
| ---------------- | ------------------------ |
| `delivery_rate`  | `delivered / queued`     |
| `bounce_rate`    | `bounced / queued`       |
| `deferred_rate`  | `deferred / queued`      |
| `open_rate`      | `opened / delivered`     |
| `click_rate`     | `clicked / opened`       |
| `complaint_rate` | `complained / delivered` |

`from` and `to` are optional ISO 8601 timestamps. With neither bound, `from` defaults to 30 days ago and `to` is `null`; the response reports those resolved query bounds.

### Polling patterns

#### Cursor loop (backfill or steady-state tail)

Poll `GET /email_events` with a `from` timestamp, then walk the cursor until `page_cursor` disappears. Because `from` is inclusive, persist the latest `occurred_at` and the event IDs seen at that timestamp. Start the next poll from the same timestamp and deduplicate the boundary events by `id`; using only the timestamp would reprocess them.

```python Python theme={null}
import os
import requests

API = "https://api.telnyx.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['TELNYX_API_KEY']}"}

def poll_events(
    since_iso,
    boundary_ids=(),
    event_types=("delivered", "bounced", "failed"),
):
    """Return new events and the next inclusive `(timestamp, IDs)` checkpoint."""
    params = {
        "page_size": 100,
        "from": since_iso,
        "event_type": ",".join(event_types),
    }
    boundary_ids = set(boundary_ids)
    events = []
    latest_at = since_iso
    latest_ids = set(boundary_ids)

    while True:
        r = requests.get(f"{API}/email_events", headers=HEADERS, params=params)
        r.raise_for_status()
        body = r.json()
        for event in body["data"]:
            occurred_at = event["occurred_at"]
            event_id = event["id"]

            # `from` is inclusive: skip events already processed at the boundary.
            if occurred_at == since_iso and event_id in boundary_ids:
                continue

            events.append(event)
            if occurred_at > latest_at:
                latest_at = occurred_at
                latest_ids = {event_id}
            elif occurred_at == latest_at:
                latest_ids.add(event_id)

        cursor = body.get("meta", {}).get("page_cursor")
        if not cursor:
            break
        params["page_cursor"] = cursor

    return events, latest_at, latest_ids

# Persist both checkpoint values after processing `events`, then pass them back
# on the next run:
# events, since_iso, boundary_ids = poll_events(since_iso, boundary_ids)
```

#### When to poll vs. webhook

| Use polling when…                                                                    | Use webhooks when…                                             |
| ------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| You have no public HTTPS endpoint (agents, on-prem, behind NAT).                     | You want real-time push with no polling latency.               |
| You run a batch job that reconciles state on a schedule.                             | You need to react to events within seconds.                    |
| You're reconciling stored outbound message events after a webhook endpoint was down. | Your endpoint is always reachable and can keep up with volume. |
| You want to inspect historical events without storing every webhook.                 | You want Telnyx to handle retries and delivery status.         |

Use webhooks for real-time reactions and polling to reconcile the overlapping stored outbound message events. Polling does not backfill `email.received` or `email_domain.*` webhooks, and it additionally exposes `sending`, `cancelled`, `rejected`, and `daily_limit_exceeded` message events that cannot be selected in a webhook subscription.

***

## Consume via WebSocket (inbound only)

`GET /email_events/ws` upgrades to an authenticated WebSocket that streams **inbound** email in real time.

<Callout type="warning">
  **The WebSocket is inbound-only.** It streams `email.received` events for messages arriving at your enabled inbound domains. It does **not** carry outbound delivery lifecycle events — you will never receive `email.delivered`, `email.bounced`, `email.deferred`, or any tracking event on this socket. To consume outbound delivery events, use [webhooks](#consume-via-webhooks) or the [Events API](#consume-via-polling).
</Callout>

Subscription is account-scoped. On connect the server pushes a `connected` frame naming the account and the active filter:

```json theme={null}
{ "event_type": "connected", "account_id": "...", "inbox_id": null }
```

Each matching inbound event then arrives as a single JSON text frame:

```json theme={null}
{
  "event_type": "email.received",
  "account_id": "...",
  "inbox_id": "...",
  "occurred_at": "2026-07-24T12:00:00Z",
  "payload": { }
}
```

Send `{"type":"ping"}` to receive `{"event_type":"pong"}`. Native WebSocket ping/pong control frames are handled by the server automatically. Any other client frame is answered with an `error` frame; unparseable frames never close the connection. Pass an optional `inbox_id` query parameter to filter to a single inbox — a non-UUID value is rejected with code `10002`.

***

## Choosing and combining both

Most production setups use both surfaces together:

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

<Callout type="tip">
  Keep your webhook `events` allowlist tight. Subscribing to every event type is valid, but most applications only need a handful — filtering at the subscription reduces your endpoint's load and the noise you have to sift through. Because delivery webhooks are recipient-scoped, a broad allowlist on a large multi-recipient send multiplies your callback volume by the recipient count.
</Callout>
