When each of the seven booking webhooks fires, and in what order they can arrive, is on
Booking notifications lifecycle.
And two more for the reputation half of the same inventory:
There is no guest identity on a booking. Anywhere.
No name, no initial, no email, no phone number, no user reference, and no field that could be joined back to a person. The reviews read below is the one place this contract names a guest at all, and it does so in the shortened public form the listing page already shows. A booking, on this contract, is a set of facts about a stay: the dates, the door times, the party size, the status, what was paid, the listing and unit it belongs to, and whether the listing takes instant bookings.status is one of pending, confirmed, checked_in, completed, cancelled or
rejected. property is one object rather than four ids at the root, and it is the same
object the booking webhooks and the publication events carry — so one accessor reads all
three. inventory_kind is unit, service or experience — property.unit_id and
property.property_id are null for the latter two, which have neither. guests is a count — there
is no per-guest anything on this contract, and no amount of nesting would change that: the
shape above is everything a booking read will ever carry.
stay.check_in_time and stay.check_out_time are the unit’s own local times when
inventory_kind is unit. A service or an experience hangs off no unit and has no door, so
those bookings carry the platform defaults — 15:00 and 12:00 — which keeps the field
always present and always a real time. Branch on inventory_kind before you staff anything
from them: for a slot booking they are a placeholder, not the operator’s answer.
console_url is the answer to “who is arriving?”
Every booking, and every review, carries a deep link into the Maat host console — the same
question every property-management integration asks next, answered without moving the answer
onto this wire. Follow it, sign in as a person, and see what a person may see. It is slower
than a field on this response, and that is what it is for: an integration that wants an
arrivals board is built out of the platform’s own access control, not out of a JSON key
nobody can revoke on its own.
amount_paid can be null — that is not zero
amount_paid is one object, a decimal string beside its currency, never two fields you pair
yourself: { "value": "5000.00", "currency": "EGP" }. It reads null when the amount cannot
be stated — a stored figure that is not a number at all — and null means “we do not
know”, never “free”.
The other direction matters just as much: a stay that really cost nothing reads
{ "value": "0.00", "currency": … }, not null. A 100% coupon, a comped night and an
invitation settled at nothing are all prices, and they are the ones you most need to see in a
reconciliation. A list or a read publishes null rather than a guessed 0.00, and 0.00
rather than a cautious null; a webhook, which cannot carry a null in a field meant to be
spent, skips the delivery instead of sending a fabricated amount.
Confirming or rejecting a request
pending. Anything else — already confirmed,
already rejected, already cancelled — answers 409 conflict: nothing about your request was
wrong, the booking is simply not in a state either action can change. Neither call takes a
body; the response is the booking, re-read.
There is no cancellation over this API. Cancelling a confirmed stay creates refunds and
applies a cancellation policy — an irreversible commercial act that belongs to a person in the
app, not to a DELETE a script can retry. If a stay needs to end early, that happens in the
Maat host console, and the resulting booking.cancelled webhook is how you find out.
Listing your bookings
Paging is the publishedpage / page_size / has_more rule — see
Pagination. There is no total and no filter that narrows by anything
about a guest, because there is nothing about a guest on this contract to filter by.
GET /v1/bookings takes four filters, all optional and all about the STAY, never the guest:
Send
check_in_from and check_in_to together to bound a window; either one alone is an
open-ended bound in that direction. Both are inclusive. If you send both and check_in_from
is after check_in_to, the request is refused with 400 validation_failed rather than
answered with an empty page — a window that cannot contain anything is almost always two
parameters the wrong way round, and returning 200 with no items would read as “you have no
bookings in May”.
Reviews: reading and replying
GET /v1/listings/{listing_id}/reviews returns the public projection — the same
shortened form (“Sarah M.”, never a full name, never a user_id or booking_id) that a guest
sees on the listing page. It is public because the content already is; the developer contract
reads it rather than duplicating it.
console_url is the property’s page in the host console — the reputation surface a person
acts on, since there is no per-review page. It reads null for a review on a service or an
experience listing, which hangs off no property at all.
POST /v1/reviews/{review_id}/reply posts one field, { "body": "…" }, and publishes it
exactly as the host app does: authored by the workspace, never by a named person, because
letting a caller attach an author would put somebody’s name on public content through an API
key. The response is the review itself, re-read with the reply attached — the bare object, not
a wrapper.
When we cannot answer
All six operations here — the two reads, the two decisions, the review list and the reply — read and write through Maat’s own booking service. When that does not answer, they say so:503 service_unavailable, with a detail naming what did not happen. Nothing was read,
nothing was disclosed, nothing was changed, and retrying is the right thing to do.
Handle it as its own branch. The alternative would be a fabricated answer to a question you
asked in good faith — an empty page that reads as “you have no bookings”, or a
404 booking_not_found for a stay that is sitting there pending — and either one is worse
than being told we could not answer. It is a different code from 500 internal_error, which
is a fault of ours that a retry will hit again.
Current status
Both surfaces are live in a sandbox workspace — real reads, real409s, a real reply. Against
a production workspace every request is authenticated and validated in full, then answers
501: nothing is read, nothing is written, and no guest is disclosed. A 501 means your
integration is correct — see Sandbox.