# Overview

> Register an endpoint and Pidgeon POSTs a signed event the moment mail arrives. The payload carries ids and metadata, never a body.

Register an endpoint and we `POST` every event you subscribe to. The response
to this call carries the signing secret, once.

```bash
curl https://pidgeon.ai/api/v1/webhooks -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{
  "url": "https://yourapp.com/webhooks/pidgeon",
  "event_types": ["message.received", "message.delivered"]
}'
```

```ts
const { data } = await webhooksCreate({
body: {
  url: 'https://yourapp.com/webhooks/pidgeon',
  event_types: ['message.received', 'message.delivered'],
},
throwOnError: true,
});

// data.data.secret is shown once. Store it now.
```

```python
response = httpx.post(
  "https://pidgeon.ai/api/v1/webhooks",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "url": "https://yourapp.com/webhooks/pidgeon",
      "event_types": ["message.received", "message.delivered"],
  },
)
secret = response.json()["data"]["secret"]
```

## The payload we send

Ids and metadata, never a body. Two reasons: an event is delivered to a server
we do not control, and it is kept in a log nothing deletes from. Exchange the
id for the content when you need it.

```json
{
"id": "0044ed22-cbcb-4455-a945-06f9699dfca0",
"type": "message.received",
"created_at": "2026-09-14T09:41:02.118Z",
"data": {
  "message_id": "1a70b25d-bc53-4a94-a51a-10fd88ebd19a",
  "thread_id": "463ec73a-699f-4ea8-a8fa-bb736ae1601e",
  "identity": "hello@yourdomain.com",
  "from": "billing@supplier.com",
  "to": ["hello@yourdomain.com"],
  "subject": "Invoice 4417",
  "delivered_to": "hello@yourdomain.com",
  "matched_by": "address",
  "spam": false,
  "from_self": false,
  "firewall": { "verdict": "clean", "signals": [] },
  "attachments": [
    {
      "id": "0b1d4e8c-6a4f-4a1e-9b3e-2d0f1c7a5b94",
      "filename": "invoice-4417.pdf",
      "content_type": "application/pdf",
      "size": 184203
    }
  ]
}
}
```

`identity` is the address it arrived at and `delivered_to` is the address it
was sent to — the same string unless a routing rule moved it, in which case
`matched_by` says which rule. `to` is every recipient on the message, you
included. Ids are bare UUIDs everywhere in this API; nothing is prefixed.

**`from_self` is the one to check before you reply.** A workspace with
several addresses sees its own outbound mail arrive at the others, and it is
genuinely inbound there — `direction` will not tell you apart from a
stranger. A handler written the obvious way, *answer every
`message.received`*, answers its own reply, which arrives at the first
address, which it answers. Skip anything where `from_self` is true unless you
mean it:

```ts
if (event.type === 'message.received' && !event.data.from_self) {
await answer(event.data.message_id);
}
```

**`firewall` is the other.** Pidgeon Firewall reads every inbound message
for attempts to steer the agent reading it — hidden text, instructions
addressed to an AI, a sender pretending to be someone you trust — and says
`suspicious` with the kinds of signal that fired. The full signals, with what
each one found, are on the message. [Pidgeon Firewall](https://pidgeon.ai/docs/firewall.md)
explains each.

The same applies to [the event stream](https://pidgeon.ai/docs/reference/events.md), which spans
every mailbox the key reaches unless you name one with `identity_id`.

Mail arriving at `hello@yourdomain.com` produces one of these within seconds. Fetch the
message when you want the text — `GET /v1/messages/{id}` — which keeps the
body out of your logs unless you put it there.

## Read a message

The other half of the contract. The event gave you an id; this gives you the
content, authorised and scoped to your account.

```bash
curl https://pidgeon.ai/api/v1/messages/af08b0a1-284f-464a-a36e-8e15bb7c2eb8 -H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await messagesGet({ path: { id: event.data.message_id } });
```

```python
httpx.get(
  f"https://pidgeon.ai/api/v1/messages/{event['data']['message_id']}",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
)
```

## No endpoint to host

If your code cannot receive a request — a script on a laptop, a notebook, an
agent runtime with no public address — hold the log open instead. The TS SDK
ships it, because a generated client has nowhere to put a connection that
stays open and the alternative was everybody writing the same forty lines:

```ts
import { streamEvents } from '@pidgeon-ai/sdk';

for await (const event of streamEvents({ apiKey: process.env.PIDGEON_API_KEY! })) {
if (event.type === 'message.received' && !event.data.from_self) {
  await answer(event.data.message_id as string);
}
}
```

**Reconnection is handled and lossless.** The connection closes itself after
about 280 seconds so the last thing you receive is a cursor rather than a
socket error; the helper resends it as `Last-Event-ID`, which the server
prefers over `after`, so there is no gap and no repeat. A revoked key stops
the loop rather than retrying for ever. Pass `after:
'1970-01-01T00:00:00Z'` to start from the beginning of the log, `signal` to
stop, and `identityId` or `type` to narrow it.

Over plain HTTP it is `GET /v1/events/stream` with
`Accept: text/event-stream`; each frame carries the event as `data`, its type
as `event:` and **the cursor — not the event's id — as `id:`**.

### Or a WebSocket

The same read over a socket, for a server that already speaks WebSocket.
Some runtimes cannot set headers on a socket, so the key goes in the first
message — never in the URL. **Like every key it stays on your server:** a
browser tab holding one hands it to anybody with devtools
([keys](https://pidgeon.ai/docs/authentication.md)). To show events in a browser, relay them from
your server.

```ts
const ws = new WebSocket('wss://pidgeon.ai/api/v1/events/socket?type=message.received');

ws.onopen = () =>
ws.send(JSON.stringify({ type: 'auth', api_key: process.env.PIDGEON_API_KEY }));
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.type === 'event') handle(message.event);
if (message.type === 'reconnect') reconnectAfter(message.cursor);
};
```

You get `ready`, then one `event` per event, oldest first. Shortly before
280 seconds the server sends `reconnect` with a cursor and closes with
4000; open a new socket with `after` set to that cursor and nothing is
missed. A close with 4401 means the key was missing or wrong.

## Before you act on any of it

Verify the signature. An unverified webhook endpoint is an unauthenticated
`POST` that says mail arrived, and acting on that is how you get software any
stranger can drive. That is the [next page](https://pidgeon.ai/docs/webhooks/verifying.md), and it
is the one page here worth reading twice.

## When an endpoint goes down

We retry with a backoff, and after enough consecutive failures we disable the
endpoint rather than keep calling into the dark. Turn it back on once you have
fixed it — do not delete and recreate, which mints a new signing secret and
breaks every receiver verifying against the old one.

```
PATCH /v1/webhooks/{id}   {"status": "active"}
```

Re-enabling forgives the failure count. An endpoint that was fixed would
otherwise be disabled again by its own history the first time it was slow.

To see what happened, and to send something again:

```bash
curl "https://pidgeon.ai/api/v1/deliveries?webhook_id=$WEBHOOK_ID" \
-H "Authorization: Bearer $PIDGEON_API_KEY"

curl -X POST https://pidgeon.ai/api/v1/deliveries/$DELIVERY_ID/replay \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data: attempts } = await webhooksDeliveries({
query: { webhook_id: webhookId },
throwOnError: true,
});

const failed = attempts.data.filter((attempt) => attempt.status === 'dead');
for (const attempt of failed) {
await webhooksReplay({ path: { id: attempt.id } });
}
```

```python
attempts = httpx.get(
  "https://pidgeon.ai/api/v1/deliveries",
  params={"webhook_id": webhook_id},
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
).json()["data"]

for attempt in attempts:
  if attempt["status"] == "dead":
      httpx.post(
          f"https://pidgeon.ai/api/v1/deliveries/{attempt['id']}/replay",
          headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
      )
```

A replay is not idempotent, and that is the point of it: your endpoint will be
called again. `status` is `pending` when it is queued, `sending` in flight,
`delivered` when your server answered, `failed` while we are still retrying,
and `dead` once we have stopped.
