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.
Registering an endpoint
Settings → Webhooks in the dashboard, or through the API withwebhooks:write:
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 awebhook.test event immediately and shows you what came back,
rather than queueing it and reporting the outcome a minute later in a log:
- 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.
webhook.test by acknowledging it and doing nothing else. It never
means anything happened to your mail.
The envelope
Every delivery is aPOST 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 shapeGET /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
Responding
Answer2xx 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. UsecreatedAt if order matters to you.