Skip to main content
Once your endpoint is registered and verified — see Webhooks and Webhook signatures if you have not done that yet — this is the rest of it: what happens after a delivery leaves our queue, what a payload carries, the full event catalogue, and the six operations that let you run your endpoint’s lifecycle yourself without asking an operator to look anything up for you.

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 timeout and retry. Any 2xx acknowledges.
  • event_id is stable across retries — the same event redelivered carries the same id, so it is your idempotency key. Process each id once and answer duplicates 200.
  • occurred_at is 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.
So keep the two habits that do not depend on timing at all:
  1. Be idempotent on event_id. It is stable across retries and across a redrive.
  2. 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 a 2xx 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 — and GET /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 seven booking.* 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:
  1. No guest identity. No name, no initial, no email, no phone, no user id. console_url is 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.
  2. 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.
And one rule runs the other way: you never send guest data back on the calendar direction. A block carries dates and a closed 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/bookings makes a real booking the real way, and one call produces the whole sequence — calendar.held, then calendar.released with reason: converted, then booking.requested or booking.confirmed.
What that leaves genuinely exercisable today is the part that matters most: the 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.requested nor booking.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/bookings sees 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.challenge is 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:
  • until is a deadline, not an authority. Stop selling when calendar.held arrives and wait for the release. If a checkout is given longer, a second calendar.held arrives with the same reference and a later until — take the later one. There is no separate extension event.
  • On converted, the release and the booking.requested / booking.confirmed for that booking are written in one database transaction. They carry the same occurred_at and neither is promised to arrive first. Join them on booking_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 with User-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 under webhooks: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": true at the top level, beside event_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_id and review_id are 00000000-0000-4000-8000-00000000000n; provider_id is test-property; the stay is a week from now, three nights.
The 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 answers 501 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.