# Wake on mail

Subscribe a webhook to an address's events, prove the endpoint, verify each signature, and fetch what arrived.

A webhook wakes your agent when mail arrives or is held, so it need not poll. Events carry ids only, never message text: your agent then reads with its key, as it would anyway.

## Subscribe

```bash curl
curl -X POST https://address.seleya.ai/api/v1/subscriptions \
  -H "Authorization: Bearer $SELEYA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"address": "desk@acme.seleya.email", "url": "https://agent.acme.com/hooks/mail"}'
```

Before it saves the subscription, the service sends your URL a challenge:

```json
{"type": "subscription.challenge", "timestamp": "2026-10-16T08:00:00Z", "data": {"subscription_id": "…", "challenge": "ch_…"}}
```

Answer it with any 2xx and the same challenge, as JSON: `{"challenge": "ch_…"}`. Then the call answers `201` with the subscription and its `secret` (`whsec_…`). The secret is shown once: keep it to verify every event.

## Events

| Event | When |
| --- | --- |
| `message.received` | Mail was delivered to the address |
| `message.held` | Mail was held for a person |
| `message.sent` | A send went out |
| `message.bounced` | A send bounced |
| `approval.requested` | The agent asked for something only a person may do |
| `approval.approved`, `approval.rejected`, `approval.failed` | The person decided, or carrying it out failed |
| `binding.paused`, `tenant.paused` | A key, or the owner's sending, was paused |

Each body is `{"type", "timestamp", "data": {"event_id", "address_id", "object_id"}}`. For a message, `object_id` is the delivery id: fetch it with `GET /v1/deliveries/{id}`, or read the stream from your cursor.

## Verify the signature

Events are signed with Standard Webhooks. The headers are `webhook-id`, `webhook-timestamp` and `webhook-signature`, which holds `v1,` and a base64 HMAC-SHA256. The key is your secret after `whsec_`, base64-decoded; the signed text is `{webhook-id}.{webhook-timestamp}.{body}`, with the body exactly as received.

```python
import base64, hashlib, hmac, time

def verify(secret: str, headers, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    want = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(want, sig.split(",", 1)[1]) for sig in headers["webhook-signature"].split() if sig.startswith("v1,"))
```

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Headers, body: string): boolean {
  const id = headers.get("webhook-id")!, ts = headers.get("webhook-timestamp")!;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const want = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
  return headers.get("webhook-signature")!.split(" ").some((s) => {
    const [v, sig] = s.split(",");
    const got = Buffer.from(sig ?? "", "base64");
    return v === "v1" && got.length === want.length && timingSafeEqual(got, want);
  });
}
```

## Delivery

- At least once: the same event may come twice, so drop repeats by `webhook-id`.
- A failed delivery is retried with backoff. After five failures in a row the subscription pauses.
- Answer quickly with any 2xx, then do the work.

## MCP Events

Apps that support MCP Events, such as ChatGPT, subscribe through the MCP server instead, to `message.received` and `message.held`. Deliveries use the same signatures, plus an `X-MCP-Subscription-Id` header, and carry `{"eventId", "name", "timestamp", "data": {"address", "delivery_id"}}`.

Claim or select an address before subscribing. An empty address selection is an error. A subscription covers the current selected addresses; repeat subscribe after claiming another address or changing access. Repeating it reconciles the scope and callback secret. Disconnecting or losing authority removes the affected callbacks. Delivery depends on the host supporting events and running the agent; authorization alone does not establish background execution.
