Inbound webhooks, signed
An agent should hear about a message the moment it lands, not the next time it happens to look. Webhooks carry that moment, and the signature is what makes the payload worth acting on.
Subscribe once
create_webhook {
url: "https://your-service.dev/hooks/sending",
events: ["email.received", "email.bounced"]
}
→ { id, secret } # the secret is shown onceThe secret is returned at creation and never again, like an API key. Deliveries are recorded, so when your endpoint was down for an hour you can see what we tried to send rather than guessing.
Verify before you trust
Every delivery carries Sending-Signature: t=,v1=. The signed string is the timestamp, a dot, and the raw body, exactly as received: re-serializing the JSON before hashing is the mistake that makes a correct implementation fail, because key order and spacing change the bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
// header: Sending-Signature: t=<unix>,v1=<hmac>
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Compare with a timing-safe function, and reject a timestamp older than your tolerance. An endpoint that skips both is a public inbox for anyone who learns the URL.
What to do with email.received
Treat it as a signal, not as the message. The payload tells you which inbox and which thread moved; read the thread when you are ready to act on it. That way a burst of mail does not turn into a burst of half-finished work, and a redelivery does not make your agent answer twice.
Deliveries are at-least-once by design. Make the handler idempotent on the message id, because the alternative is a duplicate reply to a customer who only wrote once.
Questions
Which events exist?
email.received, email.sent, email.delivered, email.bounced, email.complained. The first fires on inbound mail, the others follow the life of what you send: accepted by the provider, delivered, bounced, or reported as spam by the recipient.
Why verify the signature at all?
Because the URL is the only thing an attacker needs to post you a message that looks like a customer request. The signature is what separates a delivery we made from a request anyone can make. Verify it before you parse the body, and compare in a timing-safe way.
What is the timestamp for?
Replay. The signed string is the timestamp plus the raw body, so an old delivery captured on the wire cannot be resent as if it were new: reject anything older than a few minutes and the window closes.
Should I use webhooks or poll list_threads?
Webhooks. Polling costs you latency you cannot recover and calls you do not need, and it gets worse exactly when the inbox is busy. Keep a poll as a slow safety net if you like, not as the main path.
Keep reading
One inbox per agent
Why an agent needs an address of its own, where it should live, and what a shared domain costs you in practice.
Threading that survives the reply
How a reply stays in the same conversation on the recipient's side, and why folders are views rather than labels to maintain.
Inbound webhooks, signed
Being told a mail arrived instead of polling for it, and verifying the signature before you trust the payload.
Allow and block rules
Deciding who may write to an agent, and what happens to mail that fails SPF, DKIM or DMARC.




