Skip to main content
The API so far is something you call. Webhooks are the other direction: Ruber calls you, as things happen, so an integration does not have to poll a mailbox to find out a message arrived.

Events

storage.limit_warning is in the catalogue so you can subscribe to it now without a breaking change later; nothing raises it yet, and this page will say so until something does.

Raised by the data, not by the caller

Almost everything above is raised when the row changes, not from whichever handler happened to make the change. A task is created by the API, by the app and by turning a mail thread into one; an event by the composer, by a booking and by the API. Raising from the row is why every path is covered, including ones added later. Two consequences worth knowing: Transitions, not saves. task.completed fires when a task closes, not while somebody types in it — a task’s notes save on an 800ms debounce, and an event per keystroke would be indistinguishable from an outage at your end. event.updated fires only when something a guest would notice changes. *.updated events are not deduplicated. A second edit is a second event. Everything else carries a dedupe key on the fact — task.created:<id> can only be true once — so a retry is a no-op rather than a second notification. task.assigned keys on the task and the person, so re-assigning to the same person is quiet and handing it to somebody else is not.
Private mailboxes never raise message.received.Not a limitation to lift later. The event payload carries a subject and a sender, and a payload is a row in our database — so emitting one for a Private mailbox would put exactly the plaintext we promise never to hold at rest into storage we can read.A stripped payload with no headers was the obvious compromise and is also wrong: it would leak who is corresponding and how often, and the event would be useless anyway, since every API endpoint refuses to read a Private mailbox.
Events cover the organization’s rows, never a member’s personal ones.Tasks, calendars, contacts and booking links made in the web app belong to the person who made them, and an API key cannot read them. An organization-level webhook must not be the one door that leaks them — a guest’s address, somebody’s private task, a personal contact would otherwise reach anybody who could open Settings → Webhooks.So a personal task, event, contact or booking raises nothing at all. Create them through the API and they belong to the organization, are visible to your team in the dashboard, and raise events.Domains, mailboxes, API keys and memberships have no personal kind, so they always raise.Encrypted calendar events raise nothing either. A Private event’s title and location are ciphertext, so a payload would announce a nameless meeting at a time anybody can read — which leaks when somebody is busy while claiming to protect what they are doing.

Registering an endpoint

Settings → Webhooks in the dashboard, or through the API with webhooks:write:
The secret is returned by POST and by nothing else.Unlike an API key it is stored intact — we need it to sign — so this endpoint could hand it back on every read. It does not: a signing secret in a list response ends up in logs, in a support screenshot and in whatever cached the response, and none of those are places it can be taken back from.The response says "secret_shown_once": true to make that explicit. Lost it? Delete the endpoint and make another. There is no rotation, because rotating safely means accepting two secrets for a window while a receiver switches over, and one that swapped instantly would break every delivery in flight.
PATCH replaces events wholesale — it is one fact, “call me about these”, and a merging update would have no way to say “stop sending me bounces” that was not also the way to say “leave the list alone”. To stop the calls without losing the subscription, set enabled: false. Deleting an endpoint discards its queued deliveries, which have nowhere left to go. webhook.test is refused as a subscription: the Test button sends it regardless, so subscribing would be a setting that changes nothing. Or, in the dashboard: give it an HTTPS URL and tick the events you want. You get a signing secret beginning whsec_, shown once. Unlike an API key it is stored intact — we need it to sign — but it is still only displayed at creation. Your URL must be HTTPS and must resolve to a public address. Endpoints resolving to loopback, private ranges, or cloud metadata addresses are refused on every attempt, checked at delivery time rather than only at registration, so a name repointed later is caught too.

Test it before you rely on it

Every endpoint has a Test button in Settings → Webhooks, and one appears next to the signing secret the moment you create it — the one point where the URL and the secret are both in front of you and a typo is still cheap to fix. It sends a webhook.test event immediately and shows you what came back, rather than queueing it and reporting the outcome a minute later in a log:
It goes through exactly the same code as a real delivery: same signature, same headers, same ten-second timeout, same refusal to follow redirects. A test that proved its own separate path would be worse than no test at all. Two deliberate differences from a real event:
  • It ignores your event subscriptions. You do not have to subscribe to webhook.test — and it is not offered as a checkbox, because the point is to prove the endpoint before anything real depends on it.
  • It ignores whether the endpoint is paused, so you can set one up fully before turning it on.
Handle webhook.test by acknowledging it and doing nothing else. It never means anything happened to your mail.

The envelope

Every delivery is a POST with this body:
id is the delivery id. It is the same across every retry of that delivery, which is what makes it usable for deduplication — see below.
from and envelope_from are different things and both are given. from is the header a person sees; envelope_from is the address that actually delivered and is where a bounce would go. They differ for mailing lists and forwarders, and code that checks only one will eventually be surprised.There is no IMAP uid in message.received. The event fires the moment the mail server accepts the message, which is before the mailbox has assigned one. Use message_id, which is the sender’s and never changes.

Bounce payloads

permanent: true means stop sending to that address. It is surfaced as its own boolean so you do not have to learn AWS’s vocabulary to act on the one distinction that matters. Continuing to send to addresses that hard-bounce is the fastest way to lose a domain’s sending reputation.

Booking payloads

The same shape GET /v1/bookings returns, so code written against one reads the other without a second mapping.
booking.cancelled is the identical shape with "status": "cancelled" and a cancelled_at instant. The slot is free again at that moment, so a calendar you mirror into should release it.
Both routes raise the event. A guest booking on the public page and a call to POST /v1/bookings produce the same event, because it is raised when the booking row appears rather than by either caller. Nearly every real booking comes from the page.event_id is the calendar event the booking created. On a cancellation that event is marked cancelled rather than deleted, so the id stays resolvable through GET /v1/events/{id}.

Verifying the signature

Two headers arrive with every delivery: The signed string is ${timestamp}.${rawBody}.
Node
Verify against the raw request body, never a re-serialised object.This is the single most common way a webhook integration is got wrong. Parsing the JSON and re-stringifying it can change insignificant whitespace, how a number is written, or how a non-ASCII character is escaped — and any of those produce a different string and a failed verification for a message nobody tampered with.In Express, that means express.raw({ type: "application/json" }) on this route, not express.json(). In Next.js, await request.text() before any parsing.
The timestamp is inside the signed string rather than merely sent beside it. That is what makes the freshness check meaningful: a captured delivery cannot be given a new timestamp without invalidating the signature.

Responding

Answer 2xx quickly. Anything else — including a 3xx — counts as a failure and is retried. We do not follow redirects, so a redirecting endpoint never receives anything. We wait 10 seconds. If your handler needs longer, acknowledge immediately and do the work afterwards; that is what the retry schedule assumes.

Retries

Six attempts, then we stop: About half a day in total — long enough that an endpoint broken over lunch still receives its events once fixed, short enough that a permanently dead one stops costing anything within a day. Disabling an endpoint stops its queued deliveries; they are not retried at you later.

Deduplicate on id

Deliveries are at-least-once. A network failure after your server processed the request but before we saw the response is indistinguishable, from our side, from one that never arrived — so we retry, and you may see the same id twice. Store the id and ignore one you have already handled. We also deduplicate on our side, so a message the mail server retried does not become two separate events, but that protects against a different failure than this one.

Ordering

Events are not ordered. Deliveries run in parallel so one slow endpoint does not delay everybody else’s events, and a retried event arrives after events raised later. Use createdAt if order matters to you.

Errors

Delivery attempts, their response codes and their failure reasons are recorded against each endpoint. An endpoint that never receives anything is usually one of: