Guides
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
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:
{"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.
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,"))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.