> ## Documentation Index
> Fetch the complete documentation index at: https://developer.maatstays.wtf/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook delivery

> Retries, ordering, what a booking payload carries, the full event catalogue, and the six operations that run your endpoint yourself.

Once your endpoint is registered and verified — see [Webhooks](/guides/webhooks) and
[Webhook signatures](/guides/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](#redriving-a-delivery) 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`](/api-reference/webhooks/send-test-webhook-event))
  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](/guides/changelog) says so.

| After attempt | Next attempt in | Cumulative |
| :- | :- | :- |
| 1 | 30 seconds | 30 seconds |
| 2 | 2 minutes | \~2.5 minutes |
| 3 | 10 minutes | \~13 minutes |
| 4 | 30 minutes | \~43 minutes |
| 5 | 2 hours | \~2.7 hours |
| 6 | 6 hours | \~8.7 hours |
| 7 | 12 hours | \~20.7 hours |
| 8 | — | parked `failed` |

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](#the-delivery-log), where you can [redrive](#redriving-a-delivery) 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](/guides/data-boundary#console_url-and-who-is-arriving).
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](/guides/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](/guides/booking-lifecycle).

The identifiers themselves are published as
[`WebhookEventType`](/api-reference/enums/webhook-event-type) — 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.

| Event | Producer exists |
| :- | :- |
| [`booking.requested`](/api-reference/webhooks/booking.requested) — a guest paid; the host must answer | Yes |
| [`booking.confirmed`](/api-reference/webhooks/booking.confirmed) — the stay is on | Yes |
| [`booking.rejected`](/api-reference/webhooks/booking.rejected) — a pending request was declined | Yes |
| [`booking.checked_in`](/api-reference/webhooks/booking.checked_in) — the guest confirmed arriving | Yes |
| [`booking.checked_out`](/api-reference/webhooks/booking.checked_out) — the guest confirmed leaving | Yes |
| [`booking.modified`](/api-reference/webhooks/booking.modified) — a modification was applied | Yes |
| [`booking.cancelled`](/api-reference/webhooks/booking.cancelled) — a confirmed stay ended early | Yes |
| [`review.created`](/api-reference/webhooks/review.created) — a guest published a review | Yes |
| [`listing.published`](/api-reference/webhooks/listing.published) / [`listing.unlisted`](/api-reference/webhooks/listing.unlisted) — a listing's status | Yes |
| [`pricing.mode_changed`](/api-reference/webhooks/pricing.mode_changed) — who prices a listing changed | Yes |
| [`pricing.currency_changed`](/api-reference/webhooks/pricing.currency_changed) — a priced listing changed currency | Yes |
| [`calendar.held`](/api-reference/webhooks/calendar.held) — a guest reached checkout; the nights are off sale | Yes |
| [`calendar.released`](/api-reference/webhooks/calendar.released) — a hold ended, one way or the other | Yes |

**"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](/guides/sandbox#what-the-sandbox-still-cannot-prove).
  On a sandbox workspace the events themselves now have something to fire from:
  [`POST /v1/sandbox/bookings`](/api-reference/sandbox/create-sandbox-booking) 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](/guides/webhook-signatures#a-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`](/guides/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`](/guides/calendar-blocks#live-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](#the-delivery-log)); and putting a delivery back in the
queue ([Redriving a delivery](#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](/guides/webhook-signatures#verifying-a-delivery): 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](#send-yourself-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](#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:

| Parameter | What it does |
| :- | :- |
| `status` | One [`WebhookDeliveryStatus`](/api-reference/enums/webhook-delivery-status). |
| `event_type` | One [`WebhookEventType`](/api-reference/enums/webhook-event-type), e.g. `booking.confirmed`. |

`?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`](/api-reference/enums/webhook-payload-state), which
says why a body is or is not there:

| `payload_state` | What it means |
| :- | :- |
| `held` | We still have the exact bytes. The only state a redrive can act on. |
| `redacted` | The guest exercised their right to erasure before this delivery went out, so the body was destroyed and will never be sent. Nothing is broken — there is nothing left to send. |
| `purged` | The body aged out of the 30-day window, or was destroyed by an erasure after the delivery had already settled — we cannot tell you which. Either way, nothing is left to send. |

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](#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](#the-event-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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.