> For clean Markdown content of this page, append .md to this URL. For the complete documentation index, see https://docs.postnuvia.com/llms.txt.

# Webhooks Overview

> Learn how to use Webhooks to build responsive, event-driven email agents with PostNuvia.

Webhooks are the best way to get real-time information about what's happening with your emails. Instead of constantly asking the PostNuvia API if there's a new email (a process called polling), you can register a URL, and we will send you a `POST` request with the details as soon as an event happens.

This event-driven approach is more efficient and allows you to build fast, responsive agents that can react instantly to incoming messages.

## Why Use Webhooks?

* **Real-Time Speed:** Build conversational agents that can reply to incoming emails in seconds.
* **Efficiency:** Eliminates the need for constant polling, which saves you computational resources and simplifies your application logic.

> **Prefer a simpler setup?**
>
> If you don't want to expose a public URL or set up ngrok, [WebSockets](/websockets) let you receive the same events over a persistent connection with no external tooling required.

## Available Events

PostNuvia supports sixteen webhook event types. When creating a webhook, you can subscribe to specific events or receive all standard events. See [Webhook Events](/events) for full payload details.

**Message events:**

* **`message.received`** — New email received and processed in one of your inboxes
* **`message.received.spam`** — A message was received and classified as spam (requires `label_spam_read` permission)
* **`message.received.blocked`** — A message was received and matched a block list entry (requires `label_blocked_read` permission)
* **`message.received.unauthenticated`** — A message was received without authentication headers, so PostNuvia could not verify whether it was authenticated
* **`message.sent`** — Message successfully sent from your inbox
* **`message.delivered`** — Delivery confirmed by the recipient's mail server
* **`message.bounced`** — Message failed to deliver and bounced back
* **`message.complained`** — Recipient marked your message as spam
* **`message.rejected`** — Message rejected before send (validation or policy)

**Domain events:**

* **`domain.verified`** — Custom domain successfully verified

**Calendar events** (require `calendar_event_read`; see [Calendar Webhooks](/calendar-webhooks)):

* **`calendar.event.created`** — A calendar event was created, or a date of a recurring event was edited for the first time
* **`calendar.event.updated`** — A calendar event, or one or more dates of a recurring event, changed or were cancelled
* **`calendar.event.deleted`** — A one-off or recurring calendar event was deleted
* **`calendar.event.responded`** — An attendee's response to an invitation changed
* **`calendar.event.starting`** — A calendar event, or one date of a recurring event, started
* **`calendar.event.ending`** — A calendar event, or one date of a recurring event, ended

> **Info**
>
> Spam, blocked, and unauthenticated events are excluded by default. To receive them, explicitly include `message.received.spam`, `message.received.blocked`, or `message.received.unauthenticated` in the `event_types` list when creating a webhook. These messages are no longer sent as `message.received`.

## Updating `event_types` on a webhook

When you [update a webhook](https://docs.postnuvia.com/api-reference/webhooks/update-webhook), you can change which events it receives by sending `event_types` on `PATCH`:

* **Non-empty array:** Replaces the webhook's subscribed event types **in full** (same idea as create: you set the whole list, not a diff). If you send only one type, the webhook will only receive that type afterward.
* **Omitted or empty array:** Leaves the current event types unchanged. You cannot clear all subscriptions by sending an empty list.

Subscribing to `message.received.spam`, `message.received.blocked`, or `message.received.unauthenticated` on update still requires the same [label permissions](/core-concepts/permissions) as on create.

## Custom delivery headers

Add custom HTTP headers when you create a webhook to authenticate PostNuvia deliveries with your endpoint or pass routing metadata. The values are write-only: PostNuvia sends them with each delivery but never returns them in a webhook response.

Use `get_headers` to list the configured header names without exposing their values. Use the separate `update_headers` method to rotate values, add keys, and remove keys atomically. An update must set at least one header or name at least one header to remove, and the same header cannot appear in both operations regardless of casing.

**`Python`**

```python title="Python"
webhook = client.webhooks.create(
    url="https://your-server.com/webhooks",
    event_types=["message.received"],
    headers={"Authorization": "Bearer hook-secret", "X-Environment": "production"},
)

configured = client.webhooks.get_headers(webhook.webhook_id)
client.webhooks.update_headers(
    webhook.webhook_id,
    headers={"Authorization": "Bearer rotated-secret"},
    remove_headers=["X-Environment"],
)
```

**`TypeScript`**

```typescript title="TypeScript"
const webhook = await client.webhooks.create({
  url: "https://your-server.com/webhooks",
  eventTypes: ["message.received"],
  headers: { Authorization: "Bearer hook-secret", "X-Environment": "production" },
});

const configured = await client.webhooks.getHeaders(webhook.webhookId);
await client.webhooks.updateHeaders(webhook.webhookId, {
  headers: { Authorization: "Bearer rotated-secret" },
  removeHeaders: ["X-Environment"],
});
```

The same `headers` create field and `get_headers` / `update_headers` methods are available under `pods.webhooks` and `inboxes.webhooks`. Store header values in a secret manager and rotate them with `update_headers`; retrieving the configured names cannot recover a lost value.

## Scoping a webhook to a pod or inbox

By default a webhook is registered at the organization level. You can limit it to a [pod](/core-concepts/pods) or inbox in two ways. Both receive the same events; they differ in who owns the webhook:

* **From the organization endpoint:** pass `pod_ids` or `inbox_ids` when you `webhooks.create` (and add or remove them later with `add_pod_ids` / `add_inbox_ids` on update). The webhook still belongs to the organization, so pod- and inbox-scoped API keys cannot see or change it. Use this when one webhook should cover several pods, or when the pod's own users should not manage it.
* **From the scoped endpoints:** call `pods.webhooks.create(pod_id, ...)` or `inboxes.webhooks.create(inbox_id, ...)`. The pod or inbox comes from the path, so you don't pass `pod_ids` / `inbox_ids` in the body. A pod-scoped webhook receives events for the whole pod (optionally narrowed to specific inboxes with `inbox_ids`); an inbox-scoped webhook is fixed to that inbox and can only change its `event_types`. The webhook belongs to that pod or inbox.

The scoped endpoints are the natural fit for [pod- or inbox-scoped API keys](/core-concepts/permissions): such a key can manage only its own webhooks, and a scoped webhook must always keep at least one pod or inbox subscription.

A webhook receives an event that matches any of its pods or inboxes. A pod covers every inbox in it, so adding one of that pod's inboxes does not narrow the webhook. To receive only specific inboxes, create the webhook with `inbox_ids` and no pod.

**`Python`**

```python title="Python"
# webhook scoped to a single pod
client.pods.webhooks.create(
    pod_id="pod_abc123",
    url="https://your-server.com/webhooks",
    event_types=["message.received"],
)

# webhook scoped to a single inbox
client.inboxes.webhooks.create(
    inbox_id="agent@domain.com",
    url="https://your-server.com/webhooks",
    event_types=["message.received"],
)
```

**`TypeScript`**

```typescript title="TypeScript"
// webhook scoped to a single pod
await client.pods.webhooks.create("pod_abc123", {
  url: "https://your-server.com/webhooks",
  eventTypes: ["message.received"],
});

// webhook scoped to a single inbox
await client.inboxes.webhooks.create("agent@domain.com", {
  url: "https://your-server.com/webhooks",
  eventTypes: ["message.received"],
});
```

**`CLI`**

```bash title="CLI"
# webhook scoped to a single pod
postnuvia pods webhooks create \
  --pod-id pod_abc123 \
  --url https://your-server.com/webhooks \
  --event-types message.received

# webhook scoped to a single inbox
postnuvia inboxes webhooks create \
  --inbox-id agent@domain.com \
  --url https://your-server.com/webhooks \
  --event-types message.received
```

## The Webhook Workflow

The process is straightforward:

#### 1. Create a Webhook Endpoint

This is a public URL on your server that can accept `POST` requests. For local development, a tool like `ngrok` is perfect for creating a secure, public URL that tunnels to your local machine. Your endpoint should immediately return a `200 OK` response to acknowledge receipt and process the payload in the background to avoid timeouts.

#### 2. Register the Endpoint with PostNuvia

You can register your URL using the PostNuvia API. When you create a webhook, you'll specify your endpoint's URL as well as event types you want to receive.

```python
client.webhooks.create(
    url="https://<your-ngrok-url>.ngrok-free.app/webhooks",
    event_types=["message.received", "message.sent"],
)
```

```typescript
await client.webhooks.create({
    url: "https://<your-ngrok-url>.ngrok-free.app/webhooks",
    eventTypes: ["message.received", "message.sent"],
});
```

**`CLI`**

```bash title="CLI"
# register a webhook endpoint
postnuvia webhooks create \
  --url https://<your-ngrok-url>.ngrok-free.app/webhooks \
  --event-types message.received \
  --event-types message.sent
```

Specify which events to receive; omit `event_types` to subscribe to all standard event types. Spam, blocked, and unauthenticated events must always be explicitly included.

#### 3. PostNuvia Sends Events

When an event occurs (e.g. a new message is received, a message is delivered, or a domain is verified), PostNuvia sends a `POST` request with a JSON payload to your registered URL.

## Payload Structure

When PostNuvia sends a webhook, the payload includes `event_type` and `event_id`, plus event-specific data. The example below shows a `message.received` payload; other events use different top-level objects (`send`, `delivery`, `bounce`, etc.). See [Webhook Events](/events) for each event's payload shape.

### Payload size limit

Webhook payloads are capped at **1 MB**. When a message exceeds this limit, PostNuvia omits the `text` and `html` fields from the webhook payload to reduce size. All other metadata is still included. Inline images embedded in the HTML (such as base64-encoded data URIs) count toward the payload size and are a common reason for the limit being reached.

Omitted content is always available through the API. After receiving a webhook, fetch the full message to access the complete body and attachment data:

```python
# fetch the full message after receiving a webhook
message = client.inboxes.messages.get(
    inbox_id=payload["message"]["inbox_id"],
    message_id=payload["message"]["message_id"],
)
text_body = message.text
html_body = message.html
```

```typescript
// fetch the full message after receiving a webhook
const message = await client.inboxes.messages.get(
  payload.message.inbox_id,
  payload.message.message_id,
);
const textBody = message.text;
const htmlBody = message.html;
```

**`CLI`**

```bash title="CLI"
# fetch the full message after receiving a webhook
postnuvia inboxes messages get \
  --inbox-id <inbox_id> \
  --message-id <message_id>
```

**`Webhook Payload`**

```json Webhook Payload
{
  "event_type": "message.received",
  "event_id": "evt_123abc...",
  "message": {
    "from_": ["sender@example.com"],
    "organization_id": "org_abc123...",
    "inbox_id": "inbox_def456...",
    "thread_id": "thd_ghi789...",
    "message_id": "<jkl012@postnuvia.com>",
    "labels": ["received"],
    "timestamp": "2023-10-27T10:00:00Z",
    "reply_to": ["reply-to@example.com"],
    "to": ["recipient@example.com"],
    "cc": ["cc-recipient@example.com"],
    "bcc": ["bcc-recipient@example.com"],
    "subject": "Email Subject",
    "preview": "A short preview of the email text...",
    "text": "The full text body of the email.",
    "html": "<html>...</html>",
    "attachments": [
      {
        "attachment_id": "att_pqr678...",
        "filename": "document.pdf",
        "content_type": "application/pdf",
        "size": 123456,
        "inline": false
      }
    ],
    "in_reply_to": "<parent456@postnuvia.com>",
    "references": ["<ref001@postnuvia.com>", "<ref002@postnuvia.com>"],
    "sort_key": "some-sort-key",
    "updated_at": "2023-10-27T10:00:05Z",
    "created_at": "2023-10-27T10:00:00Z"
  }
}
```

### Field Descriptions

* **`event_type`** (`string`): The event type (e.g. `message.received`, `message.sent`, `message.delivered`). Payload structure varies by event—see [Webhook Events](/events) for each event's shape.
* **`event_id`** (`string`): A unique identifier for this specific event delivery.
* **`message`** (`object`): A dictionary containing the full details of the received email message.
  * **`from_`** (`array<string>`): The sender's email address. Note the trailing underscore to avoid conflict with the Python keyword.
  * **`organization_id`** (`string`): The ID of your organization.
  * **`inbox_id`** (`string`): The ID of the inbox that received the message.
  * **`thread_id`** (`string`): The ID of the conversation thread.
  * **`message_id`** (`string`): The unique ID of this specific message.
  * **`labels`** (`array<string>`): Labels associated with the message (e.g., `received`, `sent`).
  * **`subject`** (`string`): The subject line of the email.
  * **`preview`** (`string`): A short plain-text preview of the email body.
  * **`text`** (`string`): The plain-text body of the email. May be omitted when the webhook payload exceeds the 1 MB size limit. Fetch the full message via the API if this field is missing.
  * **`html`** (`string`): The HTML body of the email, if present. May be omitted when the webhook payload exceeds the 1 MB size limit. Fetch the full message via the API if this field is missing.
  * **`attachments`** (`array<object>`): A list of attachment metadata, each with its own `attachment_id`, `filename`, `content_type`, `size`, and `inline` status. Attachment content is not included in the webhook payload; download attachments through the API.
  * **`in_reply_to`** (`string`): The `message_id` of the email this message is a reply to, if applicable.

## Copy for Cursor / Claude

Copy one of the blocks below into Cursor or Claude for complete Webhooks API knowledge in one shot.

**`Python`**

```python title="Python"
"""
PostNuvia Webhooks — copy into Cursor/Claude.

Setup: pip install postnuvia python-dotenv. Set POSTNUVIA_API_KEY in .env.
Return 200 immediately; process payload in background.

API reference:
- webhooks.create(url, event_types?, inbox_ids?, pod_ids?, client_id?, headers?)
- webhooks.get(webhook_id), webhooks.get_headers(webhook_id), webhooks.list(limit?, page_token?)
- webhooks.update(webhook_id, add_inbox_ids?, remove_inbox_ids?, add_pod_ids?, remove_pod_ids?, event_types?)
- webhooks.update_headers(webhook_id, headers?, remove_headers?)
- webhooks.delete(webhook_id)
- scoped variants: pods.webhooks.* (pod_id) and inboxes.webhooks.* (inbox_id) — same CRUD, scope from the path

Events: message.received, message.received.spam, message.received.blocked, message.received.unauthenticated, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified, calendar.event.created, calendar.event.updated, calendar.event.deleted, calendar.event.responded, calendar.event.starting, calendar.event.ending (calendar events need calendar_event_read)
Payload: event_type, event_id, plus message/send/delivery/bounce/complaint/reject/domain; calendar.event.* carry inbox_id and calendar_event. Verify with Svix (webhook.secret).
Custom delivery header values are write-only. get_headers returns names only; update_headers rotates or removes them.
Note: message.received.spam, message.received.blocked, and message.received.unauthenticated are excluded by default. To receive them, explicitly include them in event_types and ensure the API key has label_spam_read / label_blocked_read permissions for spam and blocked events.
On update, non-empty event_types replaces the subscribed list in full (omit or [] to leave types unchanged).
"""
import os
from dotenv import load_dotenv
from postnuvia import PostNuvia

load_dotenv()
client = PostNuvia(api_key=os.getenv("POSTNUVIA_API_KEY"))

wh = client.webhooks.create(url="https://your-server.com/webhooks", event_types=["message.received"], client_id="my-webhook-v1")
all_wh = client.webhooks.list()
secret = client.webhooks.get(wh.webhook_id).secret
```

**`TypeScript`**

```typescript title="TypeScript"
/**
 * PostNuvia Webhooks — copy into Cursor/Claude.
 *
 * Setup: npm install postnuvia dotenv. Set POSTNUVIA_API_KEY in .env.
 * Return 200 immediately; process payload in background.
 *
 * API reference:
 * - webhooks.create({ url, eventTypes?, inboxIds?, podIds?, clientId?, headers? })
 * - webhooks.get(webhookId), webhooks.getHeaders(webhookId), webhooks.list({ limit?, pageToken? })
 * - webhooks.update(webhookId, { addInboxIds?, removeInboxIds?, addPodIds?, removePodIds?, eventTypes? })
 * - webhooks.updateHeaders(webhookId, { headers?, removeHeaders? })
 * - webhooks.delete(webhookId)
 * - scoped variants: pods.webhooks.* (podId) and inboxes.webhooks.* (inboxId) — same CRUD, scope from the path
 *
 * Events: message.received, message.received.spam, message.received.blocked, message.received.unauthenticated, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified, calendar.event.created, calendar.event.updated, calendar.event.deleted, calendar.event.responded, calendar.event.starting, calendar.event.ending (calendar events need calendar_event_read)
 * Custom delivery header values are write-only. getHeaders returns names only; updateHeaders rotates or removes them.
 * Note: message.received.spam, message.received.blocked, and message.received.unauthenticated are excluded by default. To receive them, explicitly include them in eventTypes and ensure the API key has label_spam_read / label_blocked_read permissions for spam and blocked events.
 * On update, non-empty eventTypes replaces the subscribed list in full (omit or [] to leave types unchanged).
 * Verify with Svix using webhook.secret. Use express.raw() for body—signature needs raw payload.
 */
import { PostNuviaClient } from "postnuvia";
import "dotenv/config";

const client = new PostNuviaClient({ apiKey: process.env.POSTNUVIA_API_KEY! });

async function main() {
  const wh = await client.webhooks.create({
    url: "https://your-server.com/webhooks",
    eventTypes: ["message.received"],
    clientId: "my-webhook-v1",
  });
  const allWh = await client.webhooks.list();
  const secret = (await client.webhooks.get(wh.webhookId)).secret;
}
main();
```

## Next Steps

#### [Webhook Events](/events)

Explore the full list of available event types and their data payloads.

#### [Verifying Webhooks](/webhook-verification)

Learn how to verify webhook signatures to secure your endpoints.

#### [WebSockets](/websockets)

Receive events over a persistent connection with no public URL required.

#### [Example: Event-Driven Agent](/github-star-agent)

Build a fully deployable, event-driven agent that can respond to emails in
real time.