info.version in the OpenAPI document is the date that document was last
published, not a release you can pin.
Do I need to do anything?
No. No partner key has ever been issued against this contract outside our own sandbox, so nothing described below is running in anybody’s integration and there is nothing in the field to migrate. That is a statement about today, and it stops being true the day the first credential is issued — from then on, Versioning and deprecation governs. If you have built against a pre-release draft: the 2026-09-30 release makes this API serve one caller — your own workspace — and removes everything that existed only for a second kind of credential. The 2026-09-07 release reshaped five things —page_size, the name of two
collections, three single-resource bodies, the delete of a webhook endpoint, and the path and
verb of the host-block release. Each is described in its entry, with what to change. Three
response shapes moved before that — a booking’s property reference, the quick-action apply
answer and the token exchange’s refusals — and are described where they happened.
Everything else is one shape — a new operation needed a scope that no existing key was
given — and the fix is always a new key, because
scopes are fixed when a key is issued.
A key issued before 2026-09-06 carries exactly partner:token.issue, catalog:write,
calendar:read, calendar:write and webhooks:manage — what its tokens could already do (it
also carried gdpr:ack, which no longer exists). Everything since then needed something else:
One entry below asks for something that is not a re-issue: the Scopes release
(2026-09-06) refuses a token minted before it. Exchange your key again, which the
authentication guide already asks for on any
401.
A new 409: listing_pooled_unsupported
One change, additive. Nothing you have written stops working.
ProblemCode gains listing_pooled_unsupported. A listing can now sell a room type over
several physical rooms, with the room chosen per booking. A calendar block closes one unit,
so PUT /v1/blocks/{external_uid} and POST /v1/listings/{listing_id}/calendar/blocks answer
409 listing_pooled_unsupported for such a listing and write nothing. Like every calendar
write, this is live in a sandbox workspace and still 501 against a production one. See
Errors.One caller: your own workspace
Breaking, before any production key exists. No production credential has ever been issued
against this contract, so nothing in the field changes behaviour. If you have written code
against a pre-release draft, this is the list.This API serves one kind of caller: a host workspace — a host, or a property company with its
own system, website or app — managing its own Maat listings, calendars, rates, bookings and
reviews. You issue your keys from your own Maat account, and a key reaches your workspace and
nothing else. There is one contract, and everybody uses the same one. Everything that existed
only for a second kind of credential is gone:
kindis no longer on thePOST /v1/tokenresponse, and thePrincipalKindenum is removed. There is nothing left for it to distinguish.- The
gdpr:ackscope is removed, with the operation it authorized,POST /v1/deletion-requests/{deletion_request_id}/acknowledge, and theuser.erasedwebhook event. No payload on this API carries a guest’s identity, so there is nothing to ask you to delete. Registering an endpoint foruser.erased, or sending it a test, is now400 validation_failednaming the event. developer_access_requiredis removed fromProblemCode, together withdeletion_request_not_found. Every operation is open to every key that carries its scope; a403isinsufficient_scopeorworkspace_scope_mismatch, and nothing else.booking.confirmedandbooking.cancelledeach have one body. They were aoneOfof two shapes; they are nowDeveloperBookingConfirmedDeliveryandDeveloperBookingCancelledDelivery, the sameDeveloperBookingEventthe other five booking events carry — no guest name, noamount_paid, and aconsole_url. TheBookingConfirmedDelivery,BookingCancelledDelivery,BookingConfirmedPayload,BookingCancelledPayload,NotificationGuest,NotificationStay,UserErasedDelivery,UserErasedPayloadandErasureRecordRefschemas are removed.- Every read sees your whole workspace.
GET /v1/propertiesand the reads beside it return every property in the workspace, including ones created in the Maat app, with the private address fields,unit_idandconsole_urlon each.
my-prop-9001, my-prop-9044 and my-res-55123 — your own system’s ids, which is what
provider_id and a block’s external_uid always were. Only the examples changed; nothing about
what you may send did.Tenancy is unchanged. Every operation does real work on a sandbox workspace, and answers
501 not_enabled against a production one after full authentication, rate limiting and
validation.Pricing: choose what a night you have not priced is worth
Additive. One optional request field, one new response field on two operations. The default
is exactly the behaviour you have today.
PUT /v1/listings/{listing_id}/pricing-mode accepts fallback_to_base_price, and both it
and GET …/pricing-mode now report it back.Until now a night your calendar did not cover was unconditionally unquotable once you held the
mode. That is right for a feed covering every night you are contracted for, and a cliff for one
covering a rolling horizon: the night after your last pushed one falls off sale, and it
moves a day closer every day.Set fallback_to_base_price: true and an unpriced night is quoted at the host’s own base
price instead of refusing. Your rates still win on every night you have sent one for; the
base price fills gaps and nothing else, and carries no minimum stay.Three things it deliberately does not do:- It never quotes zero. A listing whose host has no usable base price is refused either way — a fallback reaching zero would put a stay on sale free.
- It does not quiet the warnings.
no_rates_next_30_nights,coverage_untilandgap_nights_next_90_daysstill describe what you have actually sent. - It is not visible to anyone else. Like
coverage_untilandcurrency, it is returned only to the caller holding the mode.
PUT states the whole mode, so send the field on every take — omitting it means false.
Changing it on a listing you already price is allowed and takes effect on the next quote; you
do not need to release first.Unchanged: the scopes (rates:write / rates:read), and the tenancy split — sandbox does the
real work, production is still 501 after full authentication and validation.Contract: an earning line and a summary bucket now carry Maat's commission
Additive. Two new fields on two objects, both required. Nothing existing changed.
Earning and EarningsBucket gain platform_fee and refunded_platform_fee, both
Money in the row’s own settlement currency.
Regenerate your client to pick them up.platform_fee is Maat’s commission on that payment — the cut taken out of your rate, which is
why gross is smaller than what the stay was priced at. It is the commission and nothing else:
the guest’s service fee is charged on top of your rate rather than deducted from it, and card
processing is ours to carry.They are a pair, and they pair with gross and refunded. platform_fee is before any
reversal of it; refunded_platform_fee is how much came back when the payment was reversed;
what Maat kept is the difference. There is deliberately no single net-of-reversal fee: it would
be the one figure on the line measured on a different basis from the ones beside it, so
gross + platform_fee on a partly-refunded payment would add a pre-reversal amount to a
post-reversal one.The Finance guide previously said
this figure was not published, because at the time it existed nowhere we could read it without
reconstructing it. It is now recorded when the payment is booked, in the same database
transaction as the money itself, and read back as a stored fact.The commission rate behind it is still not published, on this or any other operation.Finance: read your balance, earnings, payouts and penalties — per currency, read-only
Additive. Six new operations, one new tag, five new enums. Nothing existing changed.A workspace developer can now read its own books through the API:
All six take the
finance:read scope, which has been in the vocabulary since the scopes
release and reached nothing until now — so a key issued with it already works, and a key
without it needs re-issuing. All six are workspace-developer only: a commercial
channel-partner key is refused with 403 developer_access_required whatever scopes it holds.Three rules are worth reading before you build on them, and they are in the
Finance guide:- Currencies are never added together. A balance is one partition per settlement currency and a summary is one bucket per period and currency. Nothing converts, and there is no total across them anywhere.
earned_onis the stay’s check-in date, not the day the money moved — the same bucketing the Maat host app uses, so the two agree about the same month.- Everything is read-only. There is no payout request, no penalty settlement and no finance webhook. Asking to be paid commits banking details; a balance is a position you poll.
501 against a production one. A sandbox never charges
anybody, so all six answer the real shape with nothing in it.Contract: every reachable status documented, format: uuid on every id, and per-event booking delivery schemas
Additive, and documentation-only on the wire. No response body, status code or field value
changed — what changed is what the published document says about them, which is what a
generated client is built from. Regenerate your client to pick the typing up.Six operations now document the
503 service_unavailable they could always return.
GET /v1/bookings, GET /v1/bookings/{booking_reference},
POST /v1/bookings/{booking_reference}/confirm, …/reject,
GET /v1/listings/{listing_id}/reviews and POST /v1/reviews/{review_id}/reply read and
write through Maat’s booking service, and when that does not answer they say so rather than
returning an empty page or a booking_not_found for a stay that is really there. That was
always the behaviour; it was missing from the document, so a generated client had no branch
for it. See Bookings and reviews. The shared 503
description now names both situations it covers — an unconfigured capability, and a part of
Maat an operation depends on not answering — and carries an example of each.The photo presign documents both of its 429s.
POST /v1/properties/{property_id}/photos/presign answers photo_quota_exceeded when the
day’s photo allowance is spent, and the shared hourly rate_limited like every other call.
Only the second carries Retry-After. The document listed the first alone, which left a
caller reading a Retry-After that the description said was absent. Both codes are now named,
with an example each, and the response says which one carries the header. Nothing about either
ceiling changed.format: uuid on seven id fields that were bare strings. They always carried UUIDs; the
document did not say so, so a generated client typed them more loosely than the ones beside
them — booking_reference is format: uuid on the path and on the booking read, and was a
plain string on every webhook payload that hands it to you.The seven developer
booking.* deliveries are seven schemas now, one per event.
DeveloperBookingEventDelivery — one schema whose event_type could hold any of seven values
— is replaced by DeveloperBookingRequestedDelivery, DeveloperBookingConfirmedDelivery,
DeveloperBookingRejectedDelivery, DeveloperBookingCheckedInDelivery,
DeveloperBookingCheckedOutDelivery, DeveloperBookingCancelledDelivery and
DeveloperBookingModifiedDelivery. Each fixes its own event_type, so a handler written over
several of them narrows to exactly one shape on event_type alone; before, all five
developer-only events published the identical type. The body inside is unchanged — the
same DeveloperBookingEvent on all seven — and every one of them still points at
BookingEventType, so the vocabulary is still one
declaration. If your code names DeveloperBookingEventDelivery, name the event’s own schema
instead; the JSON is byte-for-byte what it was.On booking.confirmed and booking.cancelled, event_type still cannot tell the two arms
apart, and no change to the document could make it: both arms carry the same event name, and
which one you receive is a property of your credential. The document now says so and names the
runtime predicate — data.guest on booking.confirmed, data.console_url on both — under
The same event, two payloads.No key re-issue, no scope change, no subscription change.The fifteen unpaid minutes at checkout: calendar.held, calendar.released, and a read that lists what is held right now
Additive. Nothing you have written stops working, and nothing arrives unless you subscribe
to it.A guest at checkout takes the nights off sale before there is a booking, and you can now
see it. Two new events —
calendar.held and
calendar.released — cover the minutes between
somebody reaching checkout and paying. Until now nothing told you about that window: the
nights were unavailable on Maat and still sellable on your side, and a push over them met a
409 you had no way to anticipate.calendar.held carries the listing, the physical unit, first_night / last_night and an
until. calendar.released carries the same hold_reference plus a reason —
expired (sell them again) or converted, with the booking_reference of the stay that
took them. There is nothing about the guest on either: nobody has bought anything yet.Subscribe to receive them. Add them to events on
PUT /v1/webhooks/endpoint; an endpoint that does not name them is unaffected. They need
webhooks:manage like any other subscription, and no key re-issue. Workspace-developer
credentials only.GET /v1/calendar/holds lists every hold live right now across your workspace, soonest
to lapse first, in the same shape calendar.held carries. Expired holds are never in the
answer. It takes calendar:read — the scope the availability read already uses — so no
re-issue there either. There are no filters, deliberately: being told about a subset while
believing it was the set is the failure this whole surface exists to remove. Workspace
developers only; a channel-partner key is refused with 403 developer_access_required and
still sees a held night as held on the availability read.One call to
POST /v1/sandbox/bookings now produces the
whole sequence on a sandbox workspace: calendar.held, then calendar.released with
reason: converted, then booking.requested or booking.confirmed. See
the unpaid fifteen minutes.Live on a sandbox workspace; 501 against a production one, where nothing is read and
nothing is delivered.Five rule names added to ValidationErrorCode
One change, additive. Nothing you have written stops working.
ValidationErrorCode gains five members.
errors[].code on a validation_failed now
also publishes currency_mismatch, duplicate_value, unsupported_param_kind, unavailable
and unknown_template. These are not new refusals — the endpoints that use them have always
answered this way — but the published enum did not list them, so a generated client typed
against it would have seen a value its union did not hold. They are the checks that need
something your request does not carry: the currency a listing is priced in, or whether a
quick-action template is still published.The guidance is unchanged: show code beside field, and do not switch on it. field is the
part to act on, and an unrecognised value means what a recognised one means — that field was
not accepted.A booking you can make in the sandbox, occurred_at as the event's real time, and one rotation at a time
Three changes, all additive. Nothing you have written stops working.A sandbox booking you can actually make.
POST /v1/sandbox/bookings creates a booking
on one of your sandbox listings the way a real one is made — the same availability check, the
same price quote, the same booking record, the same events — so the booking.* webhooks
arrive at your endpoint in the same envelope with the same signature. Until now no sandbox
booking could exist at all, because a sandbox workspace cannot reach a payment provider, and
the outbound half of the contract could not be exercised before launch.Pick "status": "pending" to watch booking.requested and then drive the booking on with
confirm or reject, or "status": "confirmed" to get booking.confirmed straight away.
There is no guest field: the guest is synthetic and minted by us. It needs bookings:manage
and a workspace-developer key. See Sandbox.Its refusals each carry their own code, so branch on code. Nights that are already
taken answer 409 conflict with every closed night in conflicts — the same member, in the
same shape, PUT /v1/blocks/{external_uid} answers a collision with, so a handler you
already wrote for one works for the other. The listing in your own LIVE workspace answers a
new 409 sandbox_only: the fixture only ever books sandbox
inventory, and unlike the first refusal that one can never be retried into a success. An
experience or a service answers 409 inventory_not_supported. Everything else — an id that
names nothing, and an id outside your own live/sandbox pair — is one 404 listing_not_found.occurred_at is now the time the thing happened, on every event. It was the time we
prepared the delivery on eleven of the thirteen, which meant a backlog or a redrive reordered
it exactly as it reordered arrival — so the advice on this site was “do not order by it”. It
is now stamped in the same transaction as the underlying fact, so a retry does not move it and
it is safe to sort on. The value is still an ISO 8601 string in the same field: if your
handler ignores it, nothing changes. See
What occurred_at actually is.One signing-secret rotation at a time.
POST /v1/webhooks/endpoint/rotate-secret now answers a new 409,
rotation_in_progress, while an overlap window is
still open, naming the instant it closes. This makes a retry safe: we only ever sign with
two secrets, so a second rotation inside the window used to push the secret you were
verifying with out of the signing set — silently, and including for deliveries already
queued. Rotate once, store what it returns, and read a rotation_in_progress as proof that
the call you are retrying already landed. If a secret has leaked, delete the endpoint and
register it again rather than rotating twice.One page size, bare single resources, and a release that takes a body
Five breaking shape changes, in one release, before any partner key exists. No credential
has ever been issued against this contract outside our own sandbox, so nothing in the field
changes behaviour and there is nothing for you to migrate. If you have written code against a
pre-release draft, this is the list.One
page_size, everywhere. It defaulted to 50 on GET /v1/blocks and 25 on the other four
lists, and capped at 100 on the reviews read and 200 on the rest. It is 25 by default and 200
at most on every list. Above the ceiling is still a refusal, never a silent clamp. See
Pagination.A collection is items. The availability read and the rate calendar called theirs nights,
which is also the name of a count on a block (Block.nights is how many nights it holds) —
so the same word meant “how many” in one response and “which ones” in another. Both are items
now, like every other collection here. Neither is paged and neither gained a has_more; the
window is still the bound.A single resource is the bare object. GET /v1/webhooks/endpoint,
GET /v1/webhooks/deliveries/{delivery_id} and POST /v1/reviews/{review_id}/reply wrapped
their answer in a one-key object ({ "endpoint": … }, { "delivery": … }, { "review": … })
while GET /v1/properties/by-id/{property_id}, GET /v1/units/{unit_id} and
GET /v1/bookings/{booking_reference} returned theirs bare. They are all bare now. The endpoint
read still answers null rather than a 404 when you have registered nothing — the body is
null, where it used to be { "endpoint": null }. The redrive and the test event keep their
delivery key, because each carries a second field beside it.DELETE /v1/webhooks/endpoint answers 204. It answered 200 { "deleted": boolean }. Five
other deletes on this API already answered 204, and the flag was the only thing separating
“there was one” from “there was not” — a distinction an idempotent delete exists to make
unnecessary. It is still idempotent and still never a 404.Releasing host blocks is POST /v1/listings/{listing_id}/calendar/blocks/release. It was a
DELETE on /calendar/blocks carrying { "dates": [...] } in the body. A DELETE body has no
defined meaning in HTTP, so a client, a proxy or a runtime may drop it — which here would turn
“free one night” into a request addressed at the whole collection. Same body, same 204, same
idempotency, same operationId. The partner-block release,
DELETE /v1/blocks/{external_uid}, is unchanged: it takes no body at all.Two error documents now name every code they can carry. The 403 on the workspace-developer
operations named only developer_access_required, and the 403/404/409 on the photo writes
pointed at documents whose prose described a different operation. Branch on code, as
Errors has always said. No code was added, removed or renamed.Your rate limit can be raised per key. The rate-limit guide has always said an operator can
lift a ceiling for you; until this release the figure was the same for everybody. It is now a
property of your key, and RateLimit-Limit reports the figure that applies to yours. The
default is unchanged at 600 an hour — talk to your Maat contact if it is the wrong number for
your integration.The reference sidebar is in integration order. Authentication is first now, rather than
fifth. Nothing moved in the contract.Docs: webhook pages restored, and one guide split into three
No contract change — reference and navigation only. The fourteen webhook events —
endpoint.challenge and the thirteen in
the event catalogue — are back under
API Reference, one page per event, grouped with the operations that share their tag:
Booking notifications, Reviews, Listings, Pricing and GDPR. They had dropped out of
the generated navigation when it moved from Mintlify’s autogeneration to an explicit page
list; the spec’s webhooks: section itself never changed.The webhooks guide is now three pages, split by concern rather than trimmed:
Webhooks is the short version — register an endpoint, answer the
challenge, acknowledge in time. Webhook signatures has the
header scheme, the exact bytes to sign, and the two offline test vectors.
Webhook delivery has retries, ordering, what a booking payload
carries, the full event catalogue, erasure requests, and the six operations that run your
endpoint’s own lifecycle. Every fact moved to one of the three; the per-event payload detail
that used to live in prose is now on each event’s own restored reference page instead. A link
into the old page’s signature or delivery sections now resolves on
/guides/webhook-signatures or /guides/webhook-delivery — the anchor is unchanged, only
which page it is on.Every enum is named, and has a page
No field changed and no value changed. Thirty-four enums got names. Twenty-two of them —
Problem.code among them — were written inline against the property that used them, which
renders in this reference as a nameless enum<string>: you could see that a field took one of
a fixed set of strings, and neither which set nor what any member meant. They are named
schemas now, and every property that takes one points at the same definition.A new section, API Reference → Enums. One page per
enum: every value, what each one means, and which fields, parameters, operations and webhooks
reach it. Every property that takes an enum links straight to its page.Three fixed values became const. token_type on the token exchange and mode on both
pricing-mode take shapes were one-member enums; they are const now, which is what a value
that can only ever be one thing means in OpenAPI 3.1. A generated client types them the same
way. Nothing on the wire changed.Two fields gained the vocabulary they always had. event_type on a delivery envelope was
typed as a bare string beside a WebhookEventType that already published the list — it is
that enum now. errors[].code on a validation_failed is
ValidationErrorCode, which also corrects the
guide: invalid_string was named as an example and cannot occur.Four request schemas were removed from the reference. TokenRequest,
RotateWebhookSecretRequest, RedriveWebhookDeliveryRequest and BookingActionRequest
existed only to declare that there is nothing to send. Those four operations take no body and
now say so by declaring none — which is also what they always did. If you were sending {},
keep doing it; it is ignored exactly as before.Send yourself a test event
One new operation.
POST /v1/webhooks/endpoint/test queues one real delivery of the event
you name, built from a fixed synthetic fixture and produced by the same code as the real event
for your kind of credential — so you can exercise your handler end to end before a real event
exists to fire. It needs webhooks:manage, which every key that can register an endpoint
already holds, so no re-issue. Live on a sandbox workspace; 501 against a production one.
The webhook delivery guide has the details.One new field, additive. Every delivery envelope in GET /v1/webhooks/deliveries and the
single read now carries test (boolean). A delivery you asked for with the operation above
also carries "test": true at the top level of the body we POST you, beside event_id;
real deliveries do not carry the key at all. Every event schema declares it as optional.One new error code. test_event_budget_exhausted (429, with Retry-After) — the
operation’s own rolling 24-hour allowance, separate from the redrive allowance and from the
hourly rate_limited. Twenty-four codes now; the errors guide has the row.The OpenAPI document and the Postman collection have new URLs
Two static files moved. The OpenAPI document is now at
/openapi/openapi.yaml and the Postman
collection at
/openapi/postman/maat-partner-api.postman_collection.json.
The previous paths under /docs/openapi/… were published yesterday and are gone — a tool
pointed at them fetches nothing, so repoint it. Nothing in the contract changed: same document,
same collection, byte for byte. The quickstart links the new paths.The booking lifecycle, on one page
A new guide, Booking notifications lifecycle. The seven
booking.* events were documented one payload at a time; nothing said which fires when, what
never fires, or how to apply two that arrive out of order. The new page draws them as one state
machine, names the actor behind each transition (the guest’s payment, the host’s answer, the
guest’s own check-in taps, an applied modification), lists what never fires (a request does not
expire; a host-invited stay produces no event; a session has no check-in), and gives the three
rules for applying deliveries in the order they happened. Documentation only — no payload,
event or operation changed. Available on both workspace kinds like every guide.One property object, one problem shape, a code for the redrive budget
Three response shapes changed. All three are pre-go-live corrections, made while no
partner is integrated against a production workspace; each replaces something a caller would
otherwise have had to special-case forever.
- A booking’s property reference is one object.
listing_id,unit_id,property_idandprovider_idmoved off the root ofGET /v1/bookings,GET /v1/bookings/{booking_reference}and theconfirm/rejectanswers, and into apropertyobject. The seven developerbooking.*events and bothlisting.*events carry the same object with the two members a recipient can act on — so one accessor now reads a booking, a booking event and a publication event, where before each had its own shape. - Applying a quick action answers
200withresult, never201.PUT /v1/listings/{listing_id}/quick-actions/{template_key}now saysresult: "created"orresult: "updated"in the body, which is what the property and block pushes already do. A caller branching on the status code was taking the wrong path silently. - The token exchange answers a problem document.
POST /v1/tokenwas the one endpoint that refused with{ "message": … }. Its401and503are nowapplication/problem+jsonwithcode: "unauthorized"andcode: "service_unavailable", like everything else, so one error handler covers the first call of a session and every call after it.
redrive_budget_exhausted (429) replaces rate_limited on
POST /v1/webhooks/deliveries/{delivery_id}/redrive when your rolling 24-hour replay allowance
is spent. rate_limited there now means only the shared hourly ceiling, so the two waits are
finally distinguishable.One new member on a response. GET /v1/webhooks/deliveries/{delivery_id} 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 on read
and returned beside the byte-exact payload. Together they are the whole input to your own
verifier, so a failed check is diagnosable without asking us. null before any attempt, and
never the signing secret, which is not stored anywhere. Additive: nothing to change.One path parameter renamed. POST /v1/deletion-requests/{event_id}/acknowledge is now
POST /v1/deletion-requests/{deletion_request_id}/acknowledge. The value is unchanged —
it is still the event_id from the user.erased delivery — and the response body still calls
it event_id. Only the path segment’s name moved, so a client that builds the URL by
interpolation needs nothing; a generated client’s argument name changes.Nothing else in your integration changes. No scope moved, no operation was removed, and
every one of these is recorded here rather than discovered in a diff.booking.requested starts firing
booking.requested now fires. It was published in the previous release as a subscribable
event with nothing behind it; the producer has shipped, so a subscription made then starts
delivering with no change at your end.- It means one thing: a guest has PAID and the host owes them an answer. You receive it the
moment the payment lands on a listing that approves each booking, and the booking is
pendinguntil the host answers. - You get it, or you get
booking.confirmed— not both. For a booking a guest makes on your listing: instant-book confirms the stay the moment they pay, so it sendsbooking.confirmedand nobooking.requested. Where the host does decide,booking.requestedarrives first andbooking.confirmedorbooking.rejectedfollows when they answer: one approval, two ends. A host-invited stay is today’s one exception — it sends neither; see Webhook delivery. - Not for a change to a stay that already exists. A modification is
booking.modified, which carries the new window and theprevious_stayit replaced. - The payload is
booking.confirmed’s, so an integration already rendering an arrivals board renders a pending queue from this one without a second vocabulary.stay.guestsis carried, as it is there.
Ten more events, and the payload your credential decides
The webhook catalogue goes from three events to thirteen — and
booking.confirmed now has
two shapes.-
The booking lifecycle —
booking.requested(a guest paid and the host has to answer),booking.rejected,booking.checked_in,booking.checked_out(both fired by the guest’s own confirmation, never by a clock) andbooking.modified, which carries the new stay window and theprevious_stayit replaced so you can free the nights you were holding without diffing against your own copy.booking.requestedhad no producer in THIS release: subscribing was accepted and nothing arrived, so that the day it started firing you were already listening. It fires from the release above — “booking.requested starts firing”. -
review.createdsays a review exists and what it scored, and does not name the reviewer —GET /v1/listings/{listing_id}/reviewscarries the same shortened label the public listing page shows. -
listing.published/listing.unlistedanswer “did my push go live?” without polling. Once per transition, and they say whether, never why. -
pricing.mode_changedandpricing.currency_changedtell you that a listing you were pricing is no longer yours to price, or that its currency moved and its future rates were purged — from us, rather than from a409 pricing_mode_conflicton your next push. Both go to channel partners and workspace developers alike, and both fire today. This is the release in which those two webhooks begin to exist at all — until now neither was in the contract, whatever an earlier draft of the rate-calendar guide implied. -
pricing.mode_changedspeaks the pricing-mode read’s vocabulary, not one of its own:partner,rules,external— the same threeGET /v1/listings/{listing_id}/pricing-modeanswers with, so there is one set of words for one fact.externalnever names the pricer. Whether a host has engaged another integration, and which, is a commercial fact about their business; this event is pushed to every subscriber for every listing they can see, so naming it would hand each integration a live census of the others. -
Two payloads for the same event. A channel-partner endpoint’s
booking.confirmedis unchanged: the property, the stay, the guest’s name andamount_paid. A workspace developer endpoint receives a body with no guest identity of any kind and aconsole_urlinto the Maat host console instead. Which one you get is a property of your credential, so it never varies between deliveries. On the developer body,stay.guestsisnullon the events that do not state a party size — that means “not stated here”, never “nobody is coming”, and the unit’s capacity is not a substitute — and there is noamount_paidon any booking event at all, because an amount and its currency can only be paired correctly inside Maat. -
Not every event is available to every credential, and registering for one you cannot
receive now answers
400 validation_failednaming the event rather than accepting a subscription that would never deliver. In particular a workspace developer is not offereduser.erased: they receive no guest data, so there is nothing to ask them to delete — and asking about somebody they never received would itself disclose something about that person. See Webhook delivery for the full table. -
One narrowing, and it is the only one.
PUT /v1/webhooks/endpointnow REFUSES an emptyeventsarray (400 validation_failed). It was previously documented as accepting one to “pause” delivery, and it never did that: every registration re-arms the challenge, so an empty one took anactiveendpoint back topending_verificationand unsubscribed it, needing a fresh challenge round-trip to undo. That is a shutdown, not a pause. If you were relying on it,DELETE /v1/webhooks/endpointis the operation that means stop, and it keeps your signing secret and your delivery history. No endpoint that exists today is affected: the refusal is on the request, not on stored state. -
Nothing listing-keyed fires for a service or an experience. Every event above except the
two pricing ones and
user.erasedis delivered for a listing that sells a stay. A service or experience listing hangs off no unit, so it has no door times and no owning property, and the payloads say so in the fields rather than inventing them.
The partner rate calendar
You can now price a listing.
PUT /v1/listings/{listing_id}/rates— supply nightly rates for date ranges.first_nightandlast_nightare both inclusive nights; ranges in one request may not overlap, every night is today or later and no more than 730 nights ahead, and one request covers at most 730 nights.nightly_rateis a Money object whose currency must equal the listing’s — nothing is ever converted.min_stayis optional, 1–365.PUT /v1/listings/{listing_id}/pricing-mode{"mode":"partner"}— make those rates the listing’s price. This switches the listing’s own pricing rules off; releasing does not switch them back on.DELETE /v1/listings/{listing_id}/pricing-mode— hand it back.204, idempotent, and your rate rows are kept.GET /v1/listings/{listing_id}/rates?from&to— your own nights, at most 366 per read. A receipt: the rates you send are yours. Another integration on the same listing writes its own nights and reads its own nights; neither of you can see or overwrite the other’s, and only the calendar of whoever holds the pricing mode sets what guests are quoted.GET /v1/listings/{listing_id}/pricing-mode—rules,partnerorexternal, plus your horizon when you own it.
binding: false means the rates are stored and not yet in force.
Once you hold the mode, a night with no rate cannot be quoted at all — it is not sold at
the host’s base rate and not sold cheaply, it is refused. coverage_until is the last night
of the contiguous run that starts today, and gap_nights_next_90_days counts what is
missing; watch both.Unlike the quick actions, these five are open to channel-partner keys. Every rate row
carries the identity of the integration that wrote it, and the pricing mode has one named
owner — so a write here can be attributed and a release undoes exactly one integration’s
work. That is the property the rule routes lack, and it is why they stay
workspace-developer only.Scopes. rates:write for the push and both mode writes, rates:read for the two reads.
Neither was among the scopes existing keys were given — see
Do I need to do anything? at the top.No new error codes. pricing_mode_conflict (409) now also means “somebody else prices
this listing” — without saying who, because whether a host has engaged another integration
is a fact about their business. Nothing is half-applied on a conflict.The host sees all of it in their own app: who prices each listing, how far the horizon
reaches, when you last sent a rate — and a Take pricing back button that needs no
agreement from you. A revoked credential does not release the mode, so a quiet feed runs the
horizon down night by night rather than flipping a listing’s prices silently.Everything above is additive. Live on a sandbox workspace. Against a production one every request is authenticated,
rate-limited and validated in full, then answers 501 — no rate is stored and no mode is taken. See
Pricing rules and rates.Bookings, reviews, and running your webhook endpoint yourself
The API can now read what is happening on your inventory, and answer a request, without ever
naming who is staying.
- Bookings —
GET /v1/bookings(paged) andGET /v1/bookings/{booking_reference}read a booking as a set of facts about a stay: dates, the property’s own door times, party size, status,amount_paidas one Money object (nullonly when there is no figure at all — a stay that really cost nothing is"0.00", because zero is a price), andconsole_url, a deep link into the Maat host console for the one question this contract will not answer on the wire: who is arriving. There is no guest object and there never will be one — no name, no initial, no email, no reference of any kind. POST /v1/bookings/{booking_reference}/confirmand…/rejectact only on apendingrequest; anything else answers409 conflict, naming why. There is no cancellation over this API — a cancellation creates refunds and penalties and stays a human decision in the app.- Reviews —
GET /v1/listings/{listing_id}/reviewsreads the same public projection the listing page shows (“Sarah M.”, never a full name);POST /v1/reviews/{review_id}/replyposts a reply authored by the workspace, exactly as the host app does. - Workspace developer keys only, for now — the four booking operations and the review reply
answer
403 developer_access_requiredfor a commercial channel-partner key, whatever scopes it holds. A booking carries no record of which integration read or answered it, and a reply is published as the workspace itself, so neither can be attributed or selectively revoked. Reading a listing’s reviews is open to every key. - Running your own webhook endpoint — three new operations close the loop the registration
guide always described from our side:
GET /v1/webhooks/endpointreads it back (there is no secret to return — the row stores a generation, and the secret is handed over exactly twice, at first registration and at a rotation);DELETE /v1/webhooks/endpointstops delivery and forgets your event subscription, keeping your delivery history and your signing secret, so re-registering picks up where you left off — behind a fresh challenge either way. It answers{"deleted": false}when there was nothing active to delete, so a retry after a timeout tells you which of the two happened, and anything still queued for that endpoint is closed off rather than left waiting for a delivery that can no longer be attempted.POST …/rotate-secretmints a new secret with the existing dual-secret overlap and nooverlap_hoursfield, because how long we keep honouring the old one is ours to decide, not a caller’s knob; it answers409when there is no endpoint registered. - The delivery log —
GET /v1/webhooks/deliverieslists envelopes (payload_present, never a body);GET /v1/webhooks/deliveries/{delivery_id}adds the exact bytes we sent, andpayload_state(held/redacted/purged) says why a body is or is not there —purgednames both possible causes, because we cannot tell an aged-out body from one an erasure reached after the delivery had settled.POST …/{delivery_id}/redriveputs adeliveredorfaileddelivery back in the queue with the sameevent_id— your idempotency key survives the replay — a reset attempt count,409when it is in flight, its body is gone, or your endpoint is not active, and429with aRetry-Afteronce your credential’s rolling 24-hour redrive allowance is spent. It is never bulk: one call replays one delivery. - Three new error codes, all
404:booking_not_found,review_not_found,delivery_not_found— each one answer for “no such resource” and “not yours”. See Errors.
501 — nothing is read, nothing is written, and no guest is ever disclosed.Photos
A workspace can now upload the bytes of a photo, not just point us at a URL.
- Four operations —
GET /v1/properties/{property_id}/photos(state per photo) ·POST /v1/properties/{property_id}/photos/presign(signed upload forms) ·PUT /v1/properties/{property_id}/photos/order(index 0 is the cover) ·DELETE /v1/properties/{property_id}/photos/{photo_id}(204, safe to retry). See Photos. - The upload is two steps and the second does not come back to us: declare
{ content_type, size }, then POST the bytes to the returned form — every returned field first,filelast. The form pins the destination, the exactContent-Typeand a length range around the size you declared. You never hold a credential for a bucket of your choosing, and there is no object key orcategoryfield in either direction. - A successful upload is not a published image. Each photo appears immediately as
pendingand becomesreadyonce processed and reviewed, orrejectedwith a reason. PollGET …/photos. - The three write verbs require the
media:writescope and are workspace-developer keys only; a channel-partner key is refused with403 insufficient_scope(no key issued so far carriesmedia:write, and the scope gate runs first) or403 developer_access_requiredif it does, and keeps sending photo URLs on the property push, which is unchanged. The read iscatalog:readand open to both. - A property a channel partner pushed is refused on all three writes, whoever asks, with
409 conflictnaming the integration. Their sync replaces the whole photo set and re-scores the property from its own list, so a photo written here would be undone and the property taken off sale on the way past. Reading is unaffected. - Photos never publish or unpublish a property. Upload → wait for
ready→ thenPATCH /v1/listings/{listing_id}withstatus: "listed", which is the one operation that runs the validity gate. - A new daily ceiling, per API key: photos presigned per UTC day.
quota.remaining_todaycomes back on every successful presign. Crossing it is429 photo_quota_exceeded— a new code, notrate_limited, because it does not clear within the hour and carries noRetry-After. See Rate limits. - A new
404 photo_not_found, on the order operation only, for aphoto_idthat names no photo on that property. rejection_reasongainsupload_missing— you presigned and the bytes never arrived. Additive: the existing five values are unchanged and the new one is reachable only from a presigned upload, never from a URL push.- Nothing is reserved on any refusal. Both ceilings are checked before a photo exists, so a refused presign leaves the property exactly as it was.
501 — no upload is authorized.Needs a key carrying media:write — the first operation to require it. See
Do I need to do anything? at the top.Host calendar blocks and quick actions
The API can now change a listing’s availability and its rules.
- Host calendar blocks —
POSTandDELETE /v1/listings/{listing_id}/calendar/blocks, bydates[]. These write the property owner’s own closure, with acategory, and are a different thing fromPUT /v1/blocks/{external_uid}, which records a night as sold on your platform. There is nonoteand no other free-text field; an unrecognised property is refused by name. A booked or held night refuses the whole range with409and names every collision, exactly as the blocks contract does. See Calendar blocks. - Quick actions —
GET /v1/listings/{listing_id}/quick-actions,PUTandDELETE …/{template_key}. Eight one-tap pricing and booking rules, withtemplate_keyas a closed enum whose members are permanent. Values are typed by the action: a number on its own step, the closed day names, or a Money object in the listing’s currency. See Pricing rules. GET /v1/listings/{listing_id}/rules— every rule attached to a listing, quick-action and hand-drawn alike, read-only. Rule authoring is not on this API and will not be.- One new error code:
pricing_mode_conflict(409, the listing’s price is set by another source, so a rule on it would never be evaluated).inventory_not_supported, introduced below, now also answers the calendar and rule routes. See Errors. - The quick-action reads carry a
warningsarray. The two rule families are served independently, so one being unreachable degrades that family and says so rather than returning a short list that looks like a listing with nothing switched on.
501 — nothing is blocked, nothing is released and no rule
changes.Scopes. Calendar blocks ride calendar:write, which every earlier integration key
already holds. The quick actions and the rules read need rules:write, and anything
touching a pricing.* template needs pricing:write as well — the only place in this
API where the requirement depends on the path, because changing what a guest pays is not
the same capability as changing who may book. On the two reads the second scope is not an
error: without it the pricing half is withheld (unavailable_reason: "insufficient_scope"
on the quick actions, pricing_withheld in warnings on the rules) and the booking half
comes back whole.All six operations are workspace-developer keys only. A channel-partner key is refused
with 403 developer_access_required, whatever scopes it carries. Neither a host calendar
block nor a rule row records which integration created it, so a write through either could
not afterwards be attributed or reversed selectively. A partner’s own calendar direction is
unchanged and unaffected: PUT/DELETE /v1/blocks/{external_uid} carries your reservation
id, refuses a collision with a Maat booking, and can be released only by you.Request history and delivery retention
Nothing about the API changed. This entry is about what your workspace can now SEE,
and for how long.
- Your API request history is visible in the Maat host console — every request your
keys made that reached the API, live and sandbox in one list: the URL, when, how long
it took, the status and error code, what you sent and what we answered, and the request
id to quote to support. It is the same id our error responses return in
instance(urn:maat:request:…). - A few things are not stored, on purpose. Credentials never are: the token exchange
keeps neither side of the call, the webhook-registration response is not kept (it
carries your signing secret), and any parameter or field that looks like a key or a
token is replaced with
[redacted]— including in the query string. Bodies over 64 KB are not stored either; the console shows the size instead of the payload. A request that never reached the API at all — a malformed JSON body, say, which we reject before we read your credentials — leaves no entry, because we cannot tell whose it was. - API history is retained for 30 days. Older requests are removed.
- Webhook delivery history is also retained for 30 days, and the deliveries table says so. This is a reduction: the delivery envelope was previously kept much longer. If you reconcile against our delivery record rather than your own, reconcile inside the month.
- Bodies we store are redacted before they are written and capped at 64 KB. Two responses are never stored at all, because the response itself is a credential: the token exchange, and webhook endpoint registration.
Targeted edits
The API can now change one thing about a property without re-pushing all of it.
- Six write operations, addressed by Maat’s own ids rather than your
provider_id:PUT /v1/properties/{property_id}/address·PUT /v1/properties/{property_id}/amenities·PATCH /v1/units/{unit_id}·PUT /v1/units/{unit_id}/house-rules·PATCH /v1/listings/{listing_id}·PUT /v1/listings/{listing_id}/pricing. APUTreplaces a set — an omitted member is removed — and aPATCHtouches only what you name. See Edits, not upserts. PUT /v1/listings/{listing_id}/pricingis the first operation to requirepricing:writerather thancatalog:write: what a stay costs is a different capability from what it is. The two unit routes are workspace-developer keys only, like the unit read.status: "listed"runs the validity gate, and a failure is a200— withpublishable: falseandmissing[]on the returned listing, and nothing about publication changed. See Publication is a gate.- Two new error codes, both
409and both writing nothing:property_not_simple(the property is not one unit with one listing) andinventory_not_supported(the listing is an experience or a service). See Errors. PUT /v1/properties/{provider_id}returns two more fields:property_idandunit_id, both always present, unlikelisting_id. They are the handles the six edits take, so a property you created by your own reference is editable without a lookup. Additive — an existing integration reading the response is unaffected.- A calendar block addressed by
provider_idnow resolves. It previously answered501telling you to sendlisting_idinstead; both work now, against the mapping your own property push created.listing_idis still the way to block a property you did not push.
501 — nothing is written.Needs a key carrying pricing:write — the first operation to require it. catalog:write,
which every integration key already holds, covers the other five. See
Do I need to do anything? at the top.Reads and catalogues
The API can now read what it has been writing to.
- Properties, units and listings —
GET /v1/properties(paged),GET /v1/properties/by-id/{property_id},GET /v1/units/{unit_id}andGET /v1/listings/{listing_id}. A property comes back with its units and their listings nested, so a catalogue sync is one walk. See Properties, units and listings. - Availability —
GET /v1/listings/{listing_id}/calendar?from&to, one entry per night withstatusandblocked_by. Your ownexternal_uidcomes back on blocks you pushed; another integration’s never does. - Three reference catalogues —
GET /v1/catalog/property-types·/amenities·/house-rules. Slugs and structure, no labels. This closes the gap a property push has always pointed at when it refused an unknownproperty_type. POST /v1/tokenis a documented operation, with its full response shape and the two{ "message": … }error bodies it can return — the one place in this API that does not answer with a problem document. Nothing about the exchange changed.- Three new error codes:
listing_not_found,unit_not_foundanddeveloper_access_required. See Errors. - What a key holds decides what a read returns. A workspace-developer key sees its whole
workspace plus the private address fields,
unit_idandconsole_url; a channel-partner key sees only the properties it pushed, and none of those three.
501.Needs a key carrying catalog:read, which no earlier key carries. The availability read
(GET /v1/listings/{listing_id}/calendar) rides calendar:read, which every earlier
integration key already holds, so it works today for the properties you pushed. See
Do I need to do anything? at the top.Scopes
Every operation now requires a scope, in both tenancies.
- Keys carry a fixed list of scopes, and each operation names the one it requires as
x-maat-scopein the reference. A token from a key without it answers403 insufficient_scope, which names the missing scope — a new error code, and the one refusal that says what is wrong. See Scopes. - The token exchange response gains two fields:
scope(the key’s scopes at exchange time, for your own reading) andkind(channel_partner, orworkspace_developerfor a host workspace holding keys to its own data). Both are additive. - Every key issued before this change was given exactly the scopes that equal what its
tokens could already do —
catalog:write,calendar:read,calendar:write,webhooks:manage,gdpr:ack— so no integration that worked before is refused now. - A token minted before this change is refused on its next request; exchange your key
again, as the authentication guide already asks on any
401. This is the one entry that asks for something that is not a re-issue — see Do I need to do anything? at the top.
Initial publication
The Partner API is published, spec version
2026-08-26, and the sandbox is open.Live in a sandbox workspace:- Authentication — the key-for-token exchange at
POST /v1/token, with per-request revocation. - Properties —
PUT /v1/properties/{provider_id}creates and replaces real properties, with asynchronous photo ingestion and thepublishable/missing/photos_rejected/unknown_amenitiesresponse contract. - Calendar blocks —
PUT/DELETE /v1/blocks/{external_uid}andGET /v1/blocks, with whole-range409conflicts and machine-readableconflicts[]. - Webhook registration —
PUT /v1/webhooks/endpointwith the signedendpoint.challengeround-trip, per-partner signing secrets and dual-secret rotation. - Deletion acknowledgment —
POST /v1/deletion-requests/{event_id}/acknowledge, recording your confirmation against theuser.eraseddelivery it answers.
501 after full authentication and validation):- every contract endpoint against a production workspace;
- addressing a calendar block by
provider_id(uselisting_id— the reference names the working route) — each operation’s reference page states its current status.