Skip to main content
Every change to the contract, dated. What is additive, what is not, and how anything is ever retired is on Versioning and deprecation. The dates below are close together on purpose. This contract was designed and published in one pre-launch window, before any partner was integrated against it, which is why the history reads as days rather than quarters — and why the entries that reshape a payload could be written at all. 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:
  • kind is no longer on the POST /v1/token response, and the PrincipalKind enum is removed. There is nothing left for it to distinguish.
  • The gdpr:ack scope is removed, with the operation it authorized, POST /v1/deletion-requests/{deletion_request_id}/acknowledge, and the user.erased webhook event. No payload on this API carries a guest’s identity, so there is nothing to ask you to delete. Registering an endpoint for user.erased, or sending it a test, is now 400 validation_failed naming the event.
  • developer_access_required is removed from ProblemCode, together with deletion_request_not_found. Every operation is open to every key that carries its scope; a 403 is insufficient_scope or workspace_scope_mismatch, and nothing else.
  • booking.confirmed and booking.cancelled each have one body. They were a oneOf of two shapes; they are now DeveloperBookingConfirmedDelivery and DeveloperBookingCancelledDelivery, the same DeveloperBookingEvent the other five booking events carry — no guest name, no amount_paid, and a console_url. The BookingConfirmedDelivery, BookingCancelledDelivery, BookingConfirmedPayload, BookingCancelledPayload, NotificationGuest, NotificationStay, UserErasedDelivery, UserErasedPayload and ErasureRecordRef schemas are removed.
  • Every read sees your whole workspace. GET /v1/properties and the reads beside it return every property in the workspace, including ones created in the Maat app, with the private address fields, unit_id and console_url on each.
The example identifiers are renamed. The examples’ own ids for a property and a reservation are now 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_until and gap_nights_next_90_days still describe what you have actually sent.
  • It is not visible to anyone else. Like coverage_until and currency, it is returned only to the caller holding the mode.
A 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_on is 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.
A payout row carries no destination — no withdrawal method, no bank or wallet, no IBAN, no tracking number — and an earning line carries no ledger entry, transaction id or gateway fact.Live in a sandbox workspace, 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_id and provider_id moved off the root of GET /v1/bookings, GET /v1/bookings/{booking_reference} and the confirm / reject answers, and into a property object. The seven developer booking.* events and both listing.* 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 200 with result, never 201. PUT /v1/listings/{listing_id}/quick-actions/{template_key} now says result: "created" or result: "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/token was the one endpoint that refused with { "message": … }. Its 401 and 503 are now application/problem+json with code: "unauthorized" and code: "service_unavailable", like everything else, so one error handler covers the first call of a session and every call after it.
A new error code. 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 pending until 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 sends booking.confirmed and no booking.requested. Where the host does decide, booking.requested arrives first and booking.confirmed or booking.rejected follows 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 the previous_stay it 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.guests is carried, as it is there.
Delivered on a sandbox workspace. Against a production one nothing is delivered at all.
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) and booking.modified, which carries the new stay window and the previous_stay it replaced so you can free the nights you were holding without diffing against your own copy. booking.requested had 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.created says a review exists and what it scored, and does not name the reviewer — GET /v1/listings/{listing_id}/reviews carries the same shortened label the public listing page shows.
  • listing.published / listing.unlisted answer “did my push go live?” without polling. Once per transition, and they say whether, never why.
  • pricing.mode_changed and pricing.currency_changed tell 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 a 409 pricing_mode_conflict on 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_changed speaks the pricing-mode read’s vocabulary, not one of its own: partner, rules, external — the same three GET /v1/listings/{listing_id}/pricing-mode answers with, so there is one set of words for one fact. external never 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.confirmed is unchanged: the property, the stay, the guest’s name and amount_paid. A workspace developer endpoint receives a body with no guest identity of any kind and a console_url into 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.guests is null on 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 no amount_paid on 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_failed naming the event rather than accepting a subscription that would never deliver. In particular a workspace developer is not offered user.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/endpoint now REFUSES an empty events array (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 an active endpoint back to pending_verification and 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/endpoint is 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.erased is 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.
Everything above is delivered on a sandbox workspace. Against a production one nothing is delivered at all.
The partner rate calendar
You can now price a listing.
  • PUT /v1/listings/{listing_id}/rates — supply nightly rates for date ranges. first_night and last_night are 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_rate is a Money object whose currency must equal the listing’s — nothing is ever converted. min_stay is 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, partner or external, plus your horizon when you own it.
Push rates BEFORE you take the mode. That is the recommended order and the response says which state you are in: 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) and GET /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_paid as one Money object (null only when there is no figure at all — a stay that really cost nothing is "0.00", because zero is a price), and console_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}/confirm and …/reject act only on a pending request; anything else answers 409 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}/reviews reads the same public projection the listing page shows (“Sarah M.”, never a full name); POST /v1/reviews/{review_id}/reply posts 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_required for 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/endpoint reads 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/endpoint stops 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-secret mints a new secret with the existing dual-secret overlap and no overlap_hours field, because how long we keep honouring the old one is ours to decide, not a caller’s knob; it answers 409 when there is no endpoint registered.
  • The delivery log — GET /v1/webhooks/deliveries lists envelopes (payload_present, never a body); GET /v1/webhooks/deliveries/{delivery_id} adds the exact bytes we sent, and payload_state (held / redacted / purged) says why a body is or is not there — purged names both possible causes, because we cannot tell an aged-out body from one an erasure reached after the delivery had settled. POST …/{delivery_id}/redrive puts a delivered or failed delivery back in the queue with the same event_id — your idempotency key survives the replay — a reset attempt count, 409 when it is in flight, its body is gone, or your endpoint is not active, and 429 with a Retry-After once 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.
Live on a sandbox workspace. Against a production one every request is authenticated, rate-limited and validated in full, then answers 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, file last. The form pins the destination, the exact Content-Type and 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 or category field in either direction.
  • A successful upload is not a published image. Each photo appears immediately as pending and becomes ready once processed and reviewed, or rejected with a reason. Poll GET …/photos.
  • The three write verbs require the media:write scope and are workspace-developer keys only; a channel-partner key is refused with 403 insufficient_scope (no key issued so far carries media:write, and the scope gate runs first) or 403 developer_access_required if it does, and keeps sending photo URLs on the property push, which is unchanged. The read is catalog:read and open to both.
  • A property a channel partner pushed is refused on all three writes, whoever asks, with 409 conflict naming 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 → then PATCH /v1/listings/{listing_id} with status: "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_today comes back on every successful presign. Crossing it is 429 photo_quota_exceeded — a new code, not rate_limited, because it does not clear within the hour and carries no Retry-After. See Rate limits.
  • A new 404 photo_not_found, on the order operation only, for a photo_id that names no photo on that property.
  • rejection_reason gains upload_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.
Live on a sandbox workspace. Against a production one every request is authenticated, rate-limited and validated in full, then answers 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 — POST and DELETE /v1/listings/{listing_id}/calendar/blocks, by dates[]. These write the property owner’s own closure, with a category, and are a different thing from PUT /v1/blocks/{external_uid}, which records a night as sold on your platform. There is no note and no other free-text field; an unrecognised property is refused by name. A booked or held night refuses the whole range with 409 and names every collision, exactly as the blocks contract does. See Calendar blocks.
  • Quick actions — GET /v1/listings/{listing_id}/quick-actions, PUT and DELETE …/{template_key}. Eight one-tap pricing and booking rules, with template_key as 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 warnings array. 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.
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 — 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. A PUT replaces a set — an omitted member is removed — and a PATCH touches only what you name. See Edits, not upserts.
  • PUT /v1/listings/{listing_id}/pricing is the first operation to require pricing:write rather than catalog: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 a 200 — with publishable: false and missing[] on the returned listing, and nothing about publication changed. See Publication is a gate.
  • Two new error codes, both 409 and both writing nothing: property_not_simple (the property is not one unit with one listing) and inventory_not_supported (the listing is an experience or a service). See Errors.
  • PUT /v1/properties/{provider_id} returns two more fields: property_id and unit_id, both always present, unlike listing_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_id now resolves. It previously answered 501 telling you to send listing_id instead; both work now, against the mapping your own property push created. listing_id is still the way to block a property you did not push.
Live on a sandbox workspace. Against a production one every request is authenticated, rate-limited and validated in full, then answers 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} and GET /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 with status and blocked_by. Your own external_uid comes 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 unknown property_type.
  • POST /v1/token is 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_found and developer_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_id and console_url; a channel-partner key sees only the properties it pushed, and none of those three.
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.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-scope in the reference. A token from a key without it answers 403 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) and kind (channel_partner, or workspace_developer for 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 the publishable / missing / photos_rejected / unknown_amenities response contract.
  • Calendar blocks — PUT / DELETE /v1/blocks/{external_uid} and GET /v1/blocks, with whole-range 409 conflicts and machine-readable conflicts[].
  • Webhook registration — PUT /v1/webhooks/endpoint with the signed endpoint.challenge round-trip, per-partner signing secrets and dual-secret rotation.
  • Deletion acknowledgment — POST /v1/deletion-requests/{event_id}/acknowledge, recording your confirmation against the user.erased delivery it answers.
Published and not yet enabled (501 after full authentication and validation):
  • every contract endpoint against a production workspace;
  • addressing a calendar block by provider_id (use listing_id — the reference names the working route) — each operation’s reference page states its current status.