---
title: "Webhooks"
description: "Tell your endpoint when mail arrives, instead of making you poll."
url: "https://openemail.uk/docs/knowledge/developers/webhooks"
area: "Knowledge base"
category: "Developers"
status: "live"
---

# Webhooks

Tell your endpoint when mail arrives, instead of making you poll.

## Details

- Usable today from Settings → Webhooks and over the API: register an https endpoint, pick which of the twenty events it wants, and copy the whsec_ signing secret, which is shown on creation and on rotation and never again. Deliveries are real signed POSTs raised by the mailbox itself rather than by any API call, so they fire on inbound mail and on opens and clicks whatever sent the message. Sending fires from every surface, and it used to fire from only some: a send through the API, MCP, a template or a rule raised email.sent while a message sent from the app’s own composer did not, because the composer writes to the mailbox directly rather than through the send service that emitted the event. The event is now raised at the mailbox itself, which is where all of them meet, so composing in the app, scheduling for Tuesday and posting to the API are three ways of causing the same webhook. A deferred send says so twice: email.scheduled or email.queued when it is accepted, email.sent when it actually goes, and email.cancelled if you take it back in between. Ten endpoints per mailbox, enforced wherever one is registered rather than only on this screen.
- The events come in three families. Fifteen are about one message: email.received, email.replied, email.sent, email.delivered, email.failed, email.cancelled, email.scheduled, email.queued (the undo-send sibling of scheduled), email.delivery_delayed, email.bounced, email.complained, email.suppressed, email.opened, email.clicked and email.downloaded. email.sent means the sending service accepted the message, email.delivered means the receiving server did, and email.delivery_delayed means it has not arrived yet and is still being retried. email.replied fires alongside email.received when the arriving message answers one already in the mailbox, so a consumer that wants both gets both. email.downloaded fires when a person fetches a file that went out as a download link, with the same classifier keeping scanners and link previewers out of the count, and it names no recipient, because the link is the same for everyone the message went to. Three are about a domain: domain.verified when it starts receiving, domain.sending_changed when its sending verdict moves, and domain.deleted when it is removed, whether you asked or the seven-day reaper dropped it unverified. Two are about the suppression list itself, which is a different thing from email.suppressed: suppression.added when an address goes on, suppression.removed when one is allowed again. Subscribing to none of them means every message event except email.replied, fourteen today, never a family added later, and the API reads that back as ["*"]. Name the events you want if you would rather be explicit. Each delivery carries X-OpenEmail-Signature as t=<unix>,v1=<hex>, an HMAC-SHA-256 over the timestamp, a dot, and the raw body, plus X-OpenEmail-Event and X-OpenEmail-Delivery. Verify against the bytes as they arrived: parsing and re-serialising reorders keys and breaks the signature. The 300-second replay window is the receiver’s to enforce, and the SDK’s verifier defaults to it.
- Registration is refused for anything that is not https or not publicly routable (loopback, RFC1918, link-local, CGNAT and the IPv6 equivalents), and redirects are not followed, so a 3xx is recorded as a failed delivery rather than chased somewhere else. The receiver gets 5 seconds, endpoints are delivered in parallel so ten of them still cost 5 seconds rather than 50, and every attempt is listed, a page at a time, on the page for that endpoint with the response code and how long it took.
- A delivery is attempted up to 8 times. The first goes out as the event happens; a failure that could plausibly clear on its own is retried after 1 minute, then 5, then 30, then 2 hours, 5 hours, 10 hours and a further 10 hours, which spreads one event over about 27 and a half hours. Each wait varies by up to a tenth, so a thousand events that failed together do not all come back in the same second, and a Retry-After from the endpoint that asks for longer is honoured, up to 6 hours. Retries are held as durable work rather than in memory, so a deploy in the middle of that window does not lose them. Only failures worth repeating are repeated: a timeout, a refused connection, 408, 425, 429 or any 5xx. Any other 4xx is the endpoint rejecting the payload deliberately, and asking seven more times would be seven times the load for the same answer. The event id and its createdAt are fixed once and every attempt carries them, the id in X-OpenEmail-Delivery as well, so a receiver that sees the same id twice can drop the second rather than act on it twice. Once the endpoint is fixed, a delivery that failed can be replayed from the delivery log in the app or over the API, one event at a time, and a replay carries the same id. A replay pauses the automatic retries of that event while it is sent, and refuses while one of them is already being sent, so the receiver never gets two copies at once. After 100 events in a row fail every attempt, the endpoint is disabled, the workspace is emailed, and the reason is readable on the endpoint itself. An endpoint answering 410 Gone is disabled on the spot.
- An endpoint that fails 100 times in a row is switched off rather than dialled forever, and everybody with webhooks access is emailed to say so: which one, what the last attempt reported, and that nothing was queued while it was failing. The count is CONSECUTIVE and any delivered attempt resets it, so a bad afternoon last March cannot add up to a disabled endpoint today. Switching it back on clears the count with it. The console distinguishes the two states rather than showing one toggle: an endpoint you turned off looks different from one we turned off.
- Managing endpoints is one job with two front doors. Over the API it is POST /webhooks, the patch, the delete, rotate-secret, test, the delivery log and replay, with a method for each in the SDK; in the app it is Settings → Webhooks, against the same registry rather than a second one. Reading is gated on webhooks:read, so anybody building an integration can see the endpoints and their delivery history (which one fired, what the receiver answered, how long it took) without being the owner. Registering, editing, testing, rotating, replaying and deleting need webhooks:write AND ownership of the mailbox, on both surfaces, and that second half is deliberate: an endpoint hears about every address the workspace holds unless its own allowlists narrow it, with subjects and recipients on it, and no permission means “may be sent all of that”. A role that builds integrations and does not read the mail drives it with a workspace key instead. Opening a delivery to read the body that was sent and the whole answer needs ownership too, because that body carries the same subjects and recipients.
- The logs read the same way everywhere. GET /webhooks/deliveries reads every endpoint’s delivery log at once and GET /webhooks/{id}/deliveries one endpoint’s, both narrowed by status, the “only failed” switch, and by a window, and GET /webhooks/activity and GET /webhooks/{id}/activity read who created, changed, switched, rotated, tested, replayed or removed what, as @username or as the API key that did it. The SDK has a method for each, and the MCP server has listWebhookDeliveries, getWebhookDelivery, listWebhookActivity and replayWebhookDelivery, which always asks before it sends. Reading a delivery body stays the owner’s alone on every surface, and a key limited to one address on a domain cannot read the deliveries of an endpoint that covers the whole domain.
