# API · Webhooks

{/* // POST /v1/webhooks */}

Postern can push inbound mail to your endpoint as **HMAC-signed, timestamped** webhooks, so you don't
have to poll. The signature lets you verify every delivery with Web Crypto — in Node or at the edge,
with no dependency.

## `POST /v1/webhooks` — register

### Request

```json
{
  "url": "https://my-agent.example.com/inbound",
  "events": ["message.received"],
  "address": "agent7@x4p.mszazu.com"   // optional — scope to one mailbox; omit for all
}
```

```bash frame="terminal"
curl -sS -X POST "$POSTERN_API_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $POSTERN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://my-agent.example.com/inbound", "events": ["message.received"] }'
```

### Response `201`

```json
{
  "id": "wh_2Lp",
  "url": "https://my-agent.example.com/inbound",
  "events": ["message.received"],
  "secret": "whsec_…"   // shown once — store it now
}
```
**secret is shown once:** Store `secret` immediately — it's needed to verify deliveries and is not retrievable afterwards.

## The delivery

Each delivery `POST`s a JSON body and carries the signature headers:

```http
POST /inbound HTTP/1.1
Content-Type: application/json
X-Postern-Signature: t=1749751542,v1=3b2a…   // timestamp + HMAC-SHA256 over `t.body`
X-Postern-Event: message.received
```

```json title="message.received"
{
  "event": "message.received",
  "inbox": "agent7@x4p.mszazu.com",
  "message": {
    "id": "msg_8Tz",
    "thread_id": "thread_9aB",
    "from": "no-reply@acme.test",
    "subject": "Verify your email",
    "received_at": "2026-06-12T18:05:42Z"
  }
}
```

## Verifying a delivery

The signature is `HMAC-SHA256(secret, "{t}.{raw_body}")`, hex-encoded, with a timestamp to defeat
replay. Verify the **raw** body — don't re-serialize the JSON first.

```ts title="inbound-handler.ts"
import { verifyWebhookSignature } from "@postern/sdk";

export async function POST(req: Request) {
  const payload = await req.text();           // raw body — do not re-serialize
  const ok = await verifyWebhookSignature({
    payload,
    signature: req.headers.get("x-postern-signature")!,
    secret: process.env.POSTERN_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("bad signature", { status: 400 });
  // ... handle the verified message.received event ...
  return new Response("ok");
}
```

Or `parseWebhook({ ... })` to verify and JSON-parse in one step (returns `null` on a bad signature).

  ```ts title="verify.ts"
async function verify(raw: string, header: string, secret: string) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const data = `${parts.t}.${raw}`;
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return hex === parts.v1; // also reject if `t` is too old (replay window)
}
```
**Reject stale timestamps:** Treat a delivery whose `t` is outside a small window (e.g. ±5 minutes) as invalid, even if the HMAC
  matches. The timestamp is signed precisely so you can bound replay.

## Next

- [wait_for_email](https://docs.agents.mszazu.com/api/wait/) — pull the next message instead of receiving a push.
- [Inboxes](https://docs.agents.mszazu.com/api/inboxes/) — `inbound_webhook_url` registers a webhook at create time.
- [Errors](https://docs.agents.mszazu.com/api/errors/) — status codes.