Retries, idempotency and ordering
- Answer within ten seconds, and do your work after you acknowledge. Ten seconds is
the whole-request deadline on one delivery attempt; past it we record a
timeoutand retry. Any2xxacknowledges. event_idis stable across retries — the same event redelivered carries the same id, so it is your idempotency key. Process each id once and answer duplicates200.occurred_atis when the thing happened, not when we sent it — so it is safe to order by. See below for the one thing it still cannot do for you.- We never follow redirects. If your endpoint moves, register the new URL.
What occurred_at actually is
occurred_at is when the thing happened. One rule, all fourteen events: it is the
instant the underlying fact was recorded on our side — the booking confirmed, the review
published, the listing taken off sale — and it is stamped in the same database transaction
as the fact itself.
That is what makes it worth sorting on. A backlog on our side, a retry, or a
redrive changes when a delivery arrives and does not touch
occurred_at, so two events replayed out of order still sort into the order they happened.
Until 8 September 2026 that was not true: on eleven of the then-thirteen events occurred_at
was the moment we prepared the delivery, so a backlog reordered it exactly as it reordered
arrival. If your integration was written against the old behaviour and ignores
occurred_at, nothing breaks — it is simply more useful now.
Two things it still does not give you, and neither is a defect:
- It is not a sequence number. Two events can share an instant (a booking confirmed and its calendar effect are one transaction), so ordering on it is a partial order, not a total one.
- It says nothing about delivery. A test event
(
POST /v1/webhooks/endpoint/test) is synthetic and carries the moment we generated it, because there is no underlying fact behind it to date.
- Be idempotent on
event_id. It is stable across retries and across a redrive. - When order actually decides money or a door, read the current state rather than
inferring it from a sequence of deliveries:
GET /v1/bookings/{booking_reference}is the answer to “what is true now”, and no ordering rule beats it.
The retry ladder
Anything that is not a2xx is retried on a fixed ladder. These are today’s defaults
rather than contract: if we tune them, the changelog says so.
Eight attempts, with ±20% jitter on every wait so a batch that failed together does not
retry together. The first three recover inside a quarter of an hour, which covers a deploy or
a restart; the tail spans a little under a day, which covers an overnight outage. After the
eighth the delivery is parked as
failed and surfaced to our operators — and to you, in
the delivery log, where you can redrive it
yourself once your handler is fixed.
One error is terminal on the first occurrence and stops delivery to the endpoint
altogether: a URL that resolves somewhere we will not connect to. A name that answered
publicly at registration and privately later is the signature of a rebinding attempt rather
than a blip, and retrying into it is retrying into the attack.
- We keep a delivery record for 30 days. The list response restates the figure as
retention_days, read from the server, so an integration never hard-codes it. Inside that window your workspace’s Maat console — andGET /v1/webhooks/deliveries— shows every delivery we attempted: the event, when it happened, whether it landed and, on a failure, why. Older deliveries are gone, so if you reconcile against our record rather than your own, reconcile inside the month.
What a booking notification carries — and what it never will
All sevenbooking.* events carry one body: the booking reference, the property (your
provider_id and the Maat listing_id), the booking’s status after the transition, the
stay dates with the property’s own check-in and check-out times, the party size, and a
console_url. booking.modified fills previous_stay with the window it replaced;
booking.cancelled fills cancellation with cancelled_at and initiated_by — guest means the nights are free to re-sell, host
means the property withdrew and somebody has to be re-accommodated.
Two things are never on it:
- No guest identity. No name, no initial, no email, no phone, no user id.
console_urlis the answer to the question this contract will not answer on the wire — who is arriving? Follow it, sign in as a human, and see what a human on your team may see. It is slower than a field, and that is what it is for — see The data boundary. - No money. An amount and its currency can only be paired correctly inside Maat, so no
booking event carries the pair —
GET /v1/bookings/{booking_reference}answers it. Refund and penalty terms are between Maat, the guest and the property.
reason — nothing else, enforced by the
schema. See Calendar blocks.
The seven booking events publish seven schemas, one per event. Each fixes its own
event_type — DeveloperBookingConfirmedDelivery can only say booking.confirmed — so one
handler written over several of them narrows to exactly one shape on event_type alone. The
body inside is the same DeveloperBookingEvent on all seven; see any one’s own reference page
for the exact fields, including why stay.guests reads null on four of the seven.
The event catalogue
Fourteen events over thirteen rows —listing.published and listing.unlisted share one.
Every key can subscribe to every one of them, and each is about your own workspace’s
inventory.
The seven booking.* events are one state machine — which fires when, what never fires, and
how to apply them in order is on Booking notifications lifecycle.
The identifiers themselves are published as
WebhookEventType — the same list you send in
events when you register, and the same one a generated client types event_type as. Each
event below is its own reference page — the exact fields and a live example.
“Producer exists” is not “you will receive one today.” It says the platform emits the
event — there is code behind it, and a subscription to it is not a subscription to nothing.
Two things stand between that and a delivery landing on your server, and both are on our
side:
- Nothing is delivered to a production workspace at all, whatever you subscribe to.
- The delivery job that sends what is queued is enabled by an operator rather than by a
deploy. See
what the sandbox cannot prove yet.
On a sandbox workspace the events themselves now have something to fire from:
POST /v1/sandbox/bookingsmakes a real booking the real way, and one call produces the whole sequence —calendar.held, thencalendar.releasedwithreason: converted, thenbooking.requestedorbooking.confirmed.
endpoint.challenge round-trip is a real signed delivery to your real endpoint, so your
verifier runs against our signature before you write anything else. Pair it with the
test vector and you can be confident of the whole
verification path.
A few things worth knowing that will not fit in a table cell:
- A host-invited stay is a known gap, not a rule. It produces neither
booking.requestednorbooking.confirmed— see that event’s own reference page for why. If you reconcile a workspace’s stays from webhooks alone you will miss those;GET /v1/bookingssees all of them. - Nothing listing-keyed in the table above is delivered for a listing that sells a service or an experience — those hang off no unit, so they have no door times and no owning property, and each event’s own reference page says so in the fields themselves.
endpoint.challengeis not in the table because it is not subscribable: it is sent on every registration whether you asked for it or not.
The unpaid fifteen minutes
calendar.held and calendar.released are the pair that closes a window nothing else on
this API could see. A guest who reaches checkout takes a hold on the nights: they come
off sale on Maat immediately, before any money moves and before there is a booking. Until
this pair, an integration was told nothing until the hold became a booking — and nothing at
all when it lapsed. Sell a night in that window and the same room is sold twice.
Both events carry the same hold_reference, so a calendar.released is always the other
half of a calendar.held you already have. reason says what to do: expired puts the
nights back on sale, converted says a booking took them and booking_reference names it.
Two things worth knowing before you build on them:
untilis a deadline, not an authority. Stop selling whencalendar.heldarrives and wait for the release. If a checkout is given longer, a secondcalendar.heldarrives with the same reference and a lateruntil— take the later one. There is no separate extension event.- On
converted, the release and thebooking.requested/booking.confirmedfor that booking are written in one database transaction. They carry the sameoccurred_atand neither is promised to arrive first. Join them onbooking_reference; do not wait for one before acting on the other.
GET /v1/calendar/holds is the
reconciliation surface for the pair — every hold live right now, across the workspace, with
expired ones already gone. Ask it when your integration starts up or after an outage,
instead of replaying deliveries.
Getting our requests through your edge
Every delivery and every challenge is sent withUser-Agent: MaatStays-Webhooks/1.0 (+https://developer.maatstays.wtf/guides/webhooks).
The version is the scheme’s, not our build’s, so it is safe to allow-list.
We publish no egress IP range, and there is none to allow-list: our sender’s addresses are
not fixed and we will not pretend otherwise. The signature is the allow-list — verify it
and a request that did not come from us cannot get through, whatever address it arrived
from. Your endpoint must present a certificate that chains to a publicly trusted root; we
require one and do not accept a self-signed one, which is why a private or internally-issued
certificate fails registration as endpoint_unreachable.
Managing your endpoint yourself
Six operations, all underwebhooks:manage, let you run the endpoint’s lifecycle and its
delivery log yourself, without asking an operator to look anything up for you: reading,
rotating and deleting your endpoint (below); listing your delivery history and reading one
delivery’s body (The delivery log); and putting a delivery back in the
queue (Redriving a delivery).
Reading it back — GET /v1/webhooks/endpoint returns the URL, the event list, status,
last_challenge_error, the verification timestamps and which secret generation deliveries
are currently signed with. last_challenge_error is why the most recent challenge did not
verify, from a closed set — egress_blocked (we refused the URL or the address behind it),
http_error (it answered, and not 2xx), challenge_mismatch (it answered 2xx without
echoing the value we sent), challenge_unreadable (a 2xx whose body we could not read as
JSON) and endpoint_unreachable (timeout, reset, TLS failure). It is the field to read
first when an endpoint is stuck pending_verification. There is no secret in that response, and there cannot be one: the row stores only the
generation number, never the secret itself, which is derived from it and a pepper that lives
in our environment alone. A signing secret is handed to you exactly twice in an endpoint’s
life — in the response to your first registration, and in the response to a rotation — and
nowhere else, ever. The endpoint IS the response body — there is no wrapper object — and the
body is null rather than a 404 when you have never registered one; that is a fact about your
configuration, not an error.
Rotating — POST /v1/webhooks/endpoint/rotate-secret mints a new secret and returns it
once, in that response only, alongside previous_secret_expires_at — until when the
previous secret is still honoured. There is no overlap_hours field and no way to add one:
how long the previous secret keeps being honoured is a property of our dispatcher rather
than a knob you set, and a caller who could set it to zero would be choosing an outage they
cannot see coming. For the length of that window every delivery
carries both signatures, current and previous, exactly as described in
Webhook signatures: verify against either and you deploy the
new secret on your own clock, never against ours.
This call is not idempotent, and a second one during the overlap is refused. We sign
with exactly two secrets — your current one and the previous one, until its window closes —
so rotating again inside that window would evict the secret you are verifying with today,
including on deliveries already queued, with nothing to tell you it had happened. So a
retry after a lost response answers 409 rotation_in_progress, naming the instant the
window closes, and the secret your first call returned is still the live one. Read that
refusal as proof the earlier call landed, and keep what it gave you.
If your secret has LEAKED, rotating again during the window would not help either — the
leaked generation stays in the signing set as the previous one. Delete the endpoint and
register it again.
It answers 409 conflict when you have no
endpoint registered to rotate a secret for — register one first with
PUT /v1/webhooks/endpoint — and 503 service_unavailable when webhook signing is not
configured on our side; either way, nothing is rotated and your current secret is unchanged.
Deleting — DELETE /v1/webhooks/endpoint answers 204 with no body, whether or not there
was an endpoint to stop. It is idempotent — a repeat call is never a 404 you have to treat
specially, and the answer deliberately does not tell you which case you were in, because acting
on that is the 404 handling idempotency exists to spare you. It stops delivery
and forgets your event subscription. It does not forget everything: your delivery history
stays readable for the rest of its retention window, and your signing secret survives, so
re-registering the same endpoint later keeps your existing verification code working
unchanged. What re-registering never skips is the challenge — even a URL and event list you
have sent before must answer a fresh endpoint.challenge before anything real is sent to it
again.
The delivery log
GET /v1/webhooks/deliveries lists what we have sent or attempted, as envelopes —
delivery_id, event_id, event_type, status, attempts, last_error_code,
last_response_status, next_attempt_at, occurred_at, created_at, payload_present
saying only whether a body is still there to read, and test, which is true on a delivery you
asked for with a test event and false on one something caused.
A body is the most sensitive thing this platform sends anywhere, so the list never carries one. The list response itself also carries retention_days — the 30-day window stated in
Retries, idempotency and ordering above, read from the
server so nothing hard-codes it.
It pages like every other list — page and page_size, defaulting to 25 and capped at
200, with has_more — and narrows on two query parameters:
?status=failed is the reconciliation read: what we tried to send you and could not. Narrow
before you walk — the window is thirty days and a busy endpoint fills it.
GET /v1/webhooks/deliveries/{delivery_id} adds payload — the exact bytes we sent for that
one delivery, byte-identical, so you can re-verify our signature against them the same way
your handler did — plus payload_state, which
says why a body is or is not there:
The same read carries
signature_headers: the X-Signature, X-Signature-Timestamp and —
during a rotation overlap only — X-Signature-Previous that the last attempt actually sent,
recomputed from what that attempt recorded, so you have the complete input to your own
verifier and never have to ask anyone why a check failed. It is null when nothing was ever
attempted or the body is gone, and it never carries the signing secret, which is not stored.
Reading a delivery’s body back is not a new disclosure: it is the same bytes, to the same
recipient, that already crossed the wire and already sit in your own logs. It is scoped to
your own credential — one delivery_not_found covers both “no such delivery” and “somebody
else’s delivery” — and both list and read are retained for the same window stated in
Retries, idempotency and ordering above.
Redriving a delivery
POST /v1/webhooks/deliveries/{delivery_id}/redrive puts one delivery — delivered or
failed — back in the queue. The event_id does not change, so whatever you used as your
idempotency key on the first attempt still matches on the replay; the attempt series resets
to zero, as if this were the delivery’s first try.
It answers 409 conflict, naming which, in three cases: the delivery is already queued,
retrying or being sent (wait for it to settle); its body is gone (redacted or purged —
there is nothing to replay); or your endpoint is not currently active (register it again and
answer the challenge first). It answers 429 rate_limited once your credential’s redrive
allowance for the trailing 24 hours is used up — the allowance is per credential and rolls
forward continuously rather than resetting at a fixed time of day, and it is never bulk: a
single redrive replays one delivery, on purpose, so an endpoint that is still broken cannot
burn its whole backlog’s attempts in one call and learn nothing from it.
Reach for a redrive when you know a specific delivery never reached you, or your handler
failed to process it and you have since fixed the bug. It is not a substitute for making your
own handler idempotent — every delivery, redriven or not, can still arrive more than once, and
event_id is what protects you either way.
Send yourself a test event
POST /v1/webhooks/endpoint/test with { "event_type": "booking.confirmed" } queues one
real delivery of that event to your registered endpoint, built from a fixed, obviously
synthetic fixture. It exists so you can exercise your handler end to end — the signature, the
envelope, the payload shape, your own 2xx — before a real event exists to fire; the sandbox
has no booking flow of its own, and the challenge only proves your endpoint echoes.
It is a real delivery in every way that matters. It goes through the same queue and the same
dispatcher, is signed with your current secret (and your previous one during a rotation
overlap), carries the same User-Agent, climbs the same retry ladder, and appears in
GET /v1/webhooks/deliveries like any other row — badged "test": true, and replayable with a
redrive. Verify it exactly as you verify everything else; if it passes, a real one will.
It is not a real event in two ways you can rely on:
- The body carries
"test": trueat the top level, besideevent_id, on every test delivery and on no real one. If your handler must never act on a fixture — never create a reservation for it, never email anyone — branch on that flag. - The fixture’s ids are visibly not yours.
booking_reference,listing_id,property_idandreview_idare00000000-0000-4000-8000-00000000000n;provider_idistest-property; the stay is a week from now, three nights.
data you receive is produced by the same code as the real event. There is no
second serializer for test bodies to drift from the first, which is the point: what you verify
against is what production will send.
You can send any event in the catalogue, whether or not your endpoint is subscribed to it — a
test is how you find out what you would get. With no endpoint registered, or one that has not answered its challenge, it is 409 conflict. Each
accepted test spends one unit of a per-credential allowance over a rolling 24 hours, separate
from the redrive allowance; over it, 429 test_event_budget_exhausted carries Retry-After.
A refused call spends nothing and queues nothing.
The response is 202, not 200: the delivery is queued, not yet sent. Watch it in
GET /v1/webhooks/deliveries/{delivery_id} or in your own endpoint’s log. You can also fire
one from the Maat console’s Developer → Webhooks page, which calls the same thing.
Current status
Every endpoint operation covered above — the test event included — is live on a sandbox workspace and answers501 against a production one — its reference page says so, and carries it machine-readably as
x-maat-availability. Every event in the catalogue is delivered on a
sandbox workspace and not delivered on a production one; a delivery has no status code to
annotate, so each event’s own reference page says it in its own description.