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

# Events

> Webhooks with signed payloads and durable retries, or a WebSocket for agents that stay connected.

Agent Mailbox pushes events rather than making you poll. Two transports carry the same payloads: **webhooks** for server-side handlers, **WebSocket** for a long-lived agent process.

## Event types

| Event                      | Fires when                                             |
| -------------------------- | ------------------------------------------------------ |
| `message.received`         | Inbound mail was parsed, threaded and stored           |
| `message.received.blocked` | Inbound mail matched a block list                      |
| `message.sent`             | An outbound message was accepted by the mail transport |
| `message.rejected`         | The mail transport refused an outbound send            |
| `draft.created`            | A draft was created                                    |
| `domain.verified`          | A custom domain passed verification                    |

<Note>
  Three further types — `message.delivered`, `message.bounced` and `message.complained` — are **accepted on subscriptions but never fire** on the current deployment. They need asynchronous feedback from the mail transport that is not available today. They are accepted so that client code stays portable; do not build logic that waits for them.
</Note>

## Webhooks

```bash theme={null}
curl -X POST "$BASE/v0/webhooks" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/hooks/mail",
    "event_types": ["message.received", "message.sent"],
    "inbox_ids": ["ava@agent.waterr.ai"]
  }'
```

```json Response theme={null}
{
  "webhook_id": "whk_9k3mxz7bd2vr8ncq4twf6hps",
  "url": "https://your-app.example.com/hooks/mail",
  "event_types": ["message.received", "message.sent"],
  "inbox_ids": ["ava@agent.waterr.ai"],
  "secret": "whsec_2mw9qd7kv3npx6rb8tzc4hfj5says0geu1ld...",
  "enabled": true,
  "created_at": "2026-09-27T10:31:04.118Z"
}
```

<Warning>
  `secret` is returned **once**, on create. Later reads of the webhook omit it. Store it when you receive it.
</Warning>

`event_types` is required and must be non-empty. Omitting `inbox_ids` subscribes to every inbox the key can see — and a scoped key's webhook is narrowed to that key's scope automatically, whatever the request asked for.

### Delivery headers

| Header                   | Value                                                   |
| ------------------------ | ------------------------------------------------------- |
| `X-AgentInbox-Event`     | The event type                                          |
| `X-AgentInbox-Delivery`  | Unique delivery id, stable across retries               |
| `X-AgentInbox-Signature` | Hex HMAC-SHA256 of the raw body, keyed with your secret |

### Verifying the signature

Compute the HMAC over the **raw request body**, before any JSON parsing. Re-serializing the parsed object will not reproduce the same bytes.

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

function verify(rawBody, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

```javascript Express theme={null}
app.post(
  "/hooks/mail",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-AgentInbox-Signature");
    if (!verify(req.body, signature, process.env.WEBHOOK_SECRET)) {
      return res.sendStatus(401);
    }
    res.sendStatus(200);          // acknowledge first
    handle(JSON.parse(req.body)); // then do the slow work
  },
);
```

Compare with a timing-safe function, not `===`.

### The payload

```json theme={null}
{
  "type": "event",
  "event_type": "message.received",
  "event_id": "evt_4tq8rv2mzx7kd9nbhf3wpc6s",
  "inbox_id": "ava@agent.waterr.ai",
  "created_at": "2026-09-27T10:31:05.226Z",
  "message": { "...": "the full message object" },
  "thread": { "...": "the thread it landed on" }
}
```

`message.received` payloads also carry `matched_by` — which threading layer resolved the message — and `auto_reply`, flagging vacation responders. Check `auto_reply` before letting an agent answer.

### Retries

A delivery is successful on any `2xx`. Anything else is retried:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 2       | 1 minute             |
| 3       | 5 minutes            |
| 4       | 30 minutes           |
| 5       | 2 hours              |
| 6       | 12 hours             |

Six attempts in total, then the delivery is marked `failed`. Each attempt times out after 30 seconds.

```bash theme={null}
GET  /v0/webhooks/{webhook_id}/deliveries        # inspect history
POST /v0/webhooks/deliveries/{delivery_id}/retry # force a retry
```

Because retries are driven by durable alarms rather than an in-memory queue, a delivery scheduled for twelve hours from now survives restarts and deploys.

<Warning>
  Return `2xx` **before** you call a model. A handler that does its work synchronously and takes 40 seconds will hit the 30-second timeout, be recorded as failed, and be retried while the first attempt is still running — so your agent answers the same email twice.
</Warning>

### Managing webhooks

```bash theme={null}
GET    /v0/webhooks
GET    /v0/webhooks/{webhook_id}
PATCH  /v0/webhooks/{webhook_id}     # change url, event_types, or enabled
DELETE /v0/webhooks/{webhook_id}
```

`PATCH { "enabled": false }` pauses a webhook without losing its configuration or secret.

## WebSocket

For an agent process that stays up, a socket avoids needing a public HTTPS endpoint at all.

```
wss://agent.waterr.ai/v0/ws?api_key=YOUR_KEY
```

Connect, then subscribe:

```json theme={null}
{
  "type": "subscribe",
  "inbox_ids": ["ava@agent.waterr.ai"],
  "event_types": ["message.received"]
}
```

The server replies `{"type":"subscribed", ...}` and then streams `{"type":"event", ...}` frames in the same shape webhooks receive. `{"type":"ping"}` gets `{"type":"pong"}` for keepalive.

A per-inbox endpoint exists too, if you want one socket per agent:

```
wss://agent.waterr.ai/v0/inboxes/{inbox_id}/ws?api_key=YOUR_KEY
```

Subscriptions are filtered against the key's scope. Requesting an inbox outside it returns an error frame rather than silently widening the stream.

<Note>
  The socket delivers events while you are connected. It does not replay what you missed while disconnected — for that, read `GET /v0/inboxes/{inbox_id}/events` on reconnect, or use a webhook, which retries.
</Note>

## Choosing between them

|                                  | Webhook                    | WebSocket                            |
| -------------------------------- | -------------------------- | ------------------------------------ |
| Needs a public endpoint          | Yes                        | No                                   |
| Survives your process restarting | Yes, retried               | No, gap while down                   |
| Delivery guarantee               | 6 attempts over \~15 hours | Best-effort while connected          |
| Good for                         | Production handlers        | Local development, long-lived agents |

For anything you would be unhappy to silently miss, use a webhook. The retry queue is the difference.
