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

# Webhooks

> Receive real-time Paubox Email API events for delivery, opens, failures, and inbound mail, with payloads, retry behavior, and signature verification.

Paubox webhooks push event notifications to an HTTPS URL you own. Configure them in the [Paubox Dashboard](https://next.paubox.com/emailapi/webhooks) or programmatically via the [Webhook Endpoints API](/email-api/webhooks/list).

<Warning>
  **Organization-wide scope:** Webhooks are triggered at the organization level. Events from **all domains** in your organization will be sent to the configured webhook URL; there is no per-domain filtering. Design your handler to inspect the `from` field in the payload (or `payload.data.domain` for inbound mail) if you need to route events by domain.
</Warning>

## Webhook endpoint fields

* **URL**: An HTTPS URL you own, to which webhook payloads are delivered.
* **Events**: One or more event types to subscribe to.
* **Signing key** (optional): Included as the `x-webhook-signing-key` header on each delivery so your handler can verify the request came from Paubox. Endpoints subscribed to inbound mail instead get a generated signing secret (`whsec_...`), shown once when you create the endpoint. See [Verifying webhook signatures](#verifying-webhook-signatures).
* **Active**: Whether the endpoint receives deliveries. Defaults to `true`.

## Available events

| **Event Name** | **Event Key** | **Trigger** |
| :- | :- | :- |
| Delivered | api\_mail\_log\_delivered | When an outbound message is delivered |
| Temporary Failure | api\_mail\_log\_temporary\_failure | On soft bounce of an outbound message |
| Permanent Failure | api\_mail\_log\_permanent\_failure | On hard bounce of an outbound message |
| Opened | api\_mail\_log\_opened | On opening of an outbound message |
| Inbound Mail Received | email.inbound.received | When a new inbound email arrives on a [receiving domain](/email-api/receiving) |

## Payloads

Every webhook notification includes an `event_name` or `event` key and a `payload` or `data` key. The payload structure depends on the event type.

### Outbound delivery events

```json theme={null}
{
  "event_name": "api_mail_log_permanent_failure",
  "payload": {
    "id": 5555555555,
    "subject": "Hello from the Paubox Email API",
    "header_message_id": "<XXX430aa-9b7c-42d5-9614-2e38f5a3f71f@XXXXXXXXX.com>",
    "source_tracking_id": "XXXef39e-b376-4a44-b2b9-85bdb406dXXX",
    "outbound_queue_id": "XXXyFY0y3Yz2XXX",
    "time": "2022-04-18T20:27:25.379Z",
    "from": "sender@example.com",
    "to": "recipient@example.com",
    "custom_headers": {
      "X-Custom-Header": "value"
    }
  }
}
```

### Inbound mail received

Each email that arrives on one of your receiving domains produces one `email.inbound.received` event, usually within seconds. Mail classified as spam is included, with `spam: true`.

```json theme={null}
{
  "id": "0192f0c4-7d1e-7a3b-9c1d-5e6f7a8b9c0d",
  "event_name": "email.inbound.received",
  "product": "email_api",
  "occurred_at": "2026-10-01T14:30:01+00:00",
  "payload": {
    "event": "email.inbound.received",
    "data": {
      "email_id": "01a0f9e7-9f8e-7303-b771-1bccbe704c4a",
      "envelope": {
        "from": "sender@example.com",
        "to": "inbox@yourcompany.inbound.paubox.email"
      },
      "from": [
        { "name": "Sender Name", "address": "sender@example.com" }
      ],
      "to": [
        { "name": null, "address": "inbox@yourcompany.inbound.paubox.email" }
      ],
      "cc": [],
      "subject": "New patient referral",
      "date": "2026-10-01T14:29:58Z",
      "received_at": "2026-10-01T14:30:01Z",
      "message_id": ["abc123@example.com"],
      "domain": "yourcompany.inbound.paubox.email",
      "text_body": "Please see the attached referral.",
      "html_body": "<p>Please see the attached referral.</p>",
      "attachments": [
        {
          "id": "56b40f62-bca1-5f96-b547-75e46c4c9f21",
          "filename": "referral.pdf",
          "content_type": "application/pdf",
          "size": 52400,
          "content_id": null,
          "download_url": "https://api.paubox.com/v1/email/receiving/01a0f9e7-9f8e-7303-b771-1bccbe704c4a/attachments/56b40f62-bca1-5f96-b547-75e46c4c9f21"
        }
      ],
      "size": 58200,
      "spam": false,
      "spam_score": 1.2,
      "authentication": {
        "spf": "pass",
        "dkim": "pass",
        "dmarc": "pass"
      }
    }
  }
}
```

**Envelope**

| Field | Type | Description |
| :- | :- | :- |
| `id` | string | Unique ID for this delivery. Retries of the same delivery keep the same ID, which is also sent as the `X-Paubox-Delivery-Id` header |
| `event_name` | string | `email.inbound.received` |
| `product` | string | `email_api` |
| `occurred_at` | string | When Paubox received the email |
| `payload.event` | string | `email.inbound.received` |
| `payload.data` | object | The email, described below |

**Email (`payload.data`)**

| Field | Type | Description |
| :- | :- | :- |
| `email_id` | string | Paubox ID for this email (a UUID). Use it with the [receiving API](/email-api/receiving) and to deduplicate events |
| `envelope.from` / `envelope.to` | string | SMTP envelope addresses |
| `from`, `to`, `cc` | array of `{name, address}` | Parsed header addresses |
| `subject` | string or null | Email subject line |
| `date` | string or null | `Date` header from the original message |
| `received_at` | string | Timestamp when Paubox received the message |
| `message_id` | array of strings or null | `Message-ID` header, parsed into IDs without angle brackets. Normally one element; `null` if the header is missing or can't be parsed |
| `domain` | string | The receiving domain |
| `text_body` / `html_body` | string or null | Plain text and HTML body content |
| `attachments` | array | Attachments, each with `id`, `filename`, `content_type`, `size`, `content_id` and `download_url` (see below) |
| `size` | integer | Total message size in bytes |
| `spam` | boolean | Whether the message was classified as spam |
| `spam_score` | number or null | Spam score (lower is better) |
| `authentication` | object | SPF, DKIM, and DMARC results |

**Attachments**

| Field | Type | Description |
| :- | :- | :- |
| `id` | string | Paubox ID for this attachment (a UUID) |
| `filename` | string or null | File name, if the sender provided one |
| `content_type` | string or null | MIME type |
| `size` | integer or null | Size in bytes |
| `content_id` | string or null | `Content-ID`, for images referenced from the HTML body |
| `download_url` | string | Download link. Request it with your API key, like any other Email API call (see [Authentication](/email-api/authentication)). The file is always served as a download |

## Managing webhook endpoints via the API

Webhook endpoints for outbound delivery events can be managed programmatically. See the [API reference](/email-api/webhooks/list) for full details. To subscribe to `email.inbound.received`, use the [Paubox Dashboard](https://next.paubox.com/emailapi/webhooks).

```bash theme={null}
curl --request POST \
  --url https://api.paubox.com/v1/email/webhook_endpoints \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "target_url": "https://example.com/webhooks/paubox",
    "events": ["api_mail_log_delivered"],
    "active": true
  }'
```

## Retry behavior

**Inbound mail events** are retried when your endpoint responds with `429`, a `5xx` status, takes longer than 30 seconds, or can't be reached. Paubox retries after 30 seconds, 2 minutes and 5 minutes, for up to 4 attempts in total. Any other non-`2xx` response is treated as final and isn't retried. A `410 Gone` response also disables the endpoint. Respond with a `2xx` status once you've accepted the event. Because a retry can follow a request your endpoint did receive, deduplicate on `payload.data.email_id`.

**Outbound delivery events:** Paubox does not currently retry failed webhook deliveries. If your endpoint is unavailable when an event fires, that notification will not be re-sent. Design your endpoint to be highly available, and use the [Get message receipt](/email-api/message-receipt) endpoint to poll for status if you need guaranteed delivery tracking.

## Verifying webhook signatures

### Inbound mail events

Every inbound mail delivery is signed with your endpoint's signing secret (`whsec_...`), shown once when you create the endpoint. Each request carries two headers:

* `X-Paubox-Timestamp`: when the request was signed, in Unix seconds.
* `X-Paubox-Signature`: a hex-encoded HMAC-SHA256 of `<timestamp>.<raw request body>`, keyed with the whole signing secret, including the `whsec_` prefix.

To verify a request:

1. Read the raw request body before parsing it as JSON.
2. Compute the HMAC-SHA256 of the timestamp, a `.`, and the raw body, using your signing secret as the key.
3. Compare it with `X-Paubox-Signature` using a constant-time comparison.
4. Reject requests whose timestamp is more than a few minutes old. The timestamp is part of the signed content, so a captured request can't be replayed with a fresh one.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_paubox_webhook(raw_body: bytes, headers, secret: str) -> bool:
      timestamp = headers.get("X-Paubox-Timestamp")
      signature = headers.get("X-Paubox-Signature")
      if not timestamp or not signature:
          return False
      if abs(time.time() - int(timestamp)) > 300:
          return False
      signed = timestamp.encode() + b"." + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  function verifyPauboxWebhook(rawBody, headers, secret) {
    const timestamp = headers["x-paubox-timestamp"];
    const signature = headers["x-paubox-signature"];
    if (!timestamp || !signature) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    return (
      expected.length === signature.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
    );
  }
  ```
</CodeGroup>

### Outbound delivery events

If you configured a `signing_key` on your webhook endpoint, Paubox includes it as the `x-webhook-signing-key` header on every delivery. Compare this value in your handler to verify the request came from Paubox.

For additional protection, use network-level controls such as IP allowlisting.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.