external_uid is your identifier — typically your reservation id, “external” meaning
external to Maat — unique to you. Any non-empty string of at most 255 characters; we never
parse it and never show it to anyone else. Three other things in this API are spelled
external and none of them means “yours” — see the
glossary.
That makes every operation idempotent: re-sending a block unchanged is a no-op,
re-sending it with different dates moves it (old nights released, new nights held,
in one step), and releasing a block that is already gone is a 204 with nothing to
retry.
The response
result is created, updated or unchanged, and nights restates the arithmetic so you
can check check_out was read the way you meant it. The reference carries the same sample in
Node and Go.
Dates: check_out is exclusive
check_out is the departure day, not the last night held. A block from 2026-09-01 to
2026-09-05 holds four nights, and the departure day itself stays sellable for same-day
turnover. The response restates nights so you can check the arithmetic. A single block
can hold up to 730 nights — two years, enough for any legitimate long-term closure.
No guest data. Ever. This is a rule you are bound by
Maat has no relationship with somebody who booked through your own system, and therefore no lawful basis to hold anything about them. So the block schema has no field for a guest’s name, a booking reference, an amount, or a note — and no free-text field of any kind, because the obvious thing anyone writes in one is a guest’s name. This is enforced, not requested: an unrecognised field is avalidation_failed naming
the field, never a silent drop. If your integration framework “helpfully” attaches
reservation details to outgoing payloads, you will find out on your first sandbox call
— which is exactly when you want to find out.
The only descriptive field is reason, a closed set — reserved, maintenance,
owner_stay, other — enough to tell “somebody is staying” from “the boiler is
broken” in both our audit trails, and structurally incapable of carrying more.
Conflicts: the whole range or none of it
If any night in your range already carries a Maat booking or a live hold, the push answers409 and nothing is written. We never apply the free part of a range,
because a partial apply you were told succeeded is the one failure you could not
detect — you would stop holding those nights yourself, and the same room would be sold
twice.
The problem document names every colliding date in a machine-readable conflicts[]
array (complete, unlike the human-readable sentence next to it). Act on the array:
re-push a range that excludes those dates, or leave the block off if the stay cannot be
honoured.
Note that the conflict scan runs on every push, including an identical retry. If
your nights were taken by a Maat booking after your original push, a re-push tells you
so with a 409 — which is the fact you most need, not an error to suppress.
A room type with several rooms is refused
A listing that sells a room type over several physical rooms answers409 listing_pooled_unsupported, on this contract and on host blocks alike, and nothing is
written. A block closes one unit, and such a listing’s room is chosen per booking.
Release frees only what the block holds
DELETE removes the block and frees the nights it is still holding. A night that is
unavailable for another reason — the property’s owner closed it, or a Maat booking has
since taken it — stays unavailable. Releasing a block is “I no longer need these nights
held”, never “declare these dates open”. There is no partial release: shorten a stay by
re-pushing the shorter range with PUT.
Reading your blocks back
The response
GET /v1/blocks lists the blocks you pushed into the workspace your key addresses —
after a timeout or a 500, this is how you find out whether a push landed rather than
guessing. Two boundaries are fixed and are not parameters: the blocks are the ones pushed
through this API, and the workspace is the one your key was issued for. Filter by date window with from/to,
and page with page/page_size — see Pagination.
Addressing the property
Identify the property with either your ownprovider_id or Maat’s listing_id — and
send exactly one. Both resolve. A provider_id is looked up against the mapping
your own property push created, inside the workspace your key addresses: two workspaces
using the same reference for different properties never reach each other’s calendars, and a
reference we have never seen from you answers 404, exactly like an unknown listing.
Use listing_id for a property you did not push — we have no reference of your own for
one. It is returned when you push a property, and in every booking notification.
Host blocks: closing nights that nobody bought
The three operations above record a night as sold in your own system. There is a fourth thing a calendar has to be able to say, and it is not the same thing: the owner is closing this property — for maintenance, for their own stay, for a renovation.
The release is a
POST on its own sub-resource because it carries a body: the nights are a
set, not a range, so they cannot ride in the path, and a DELETE with a body has no defined
meaning in HTTP — clients and proxies are free to drop it, which here would turn “free one
night” into a request addressed at the whole collection.
These write the property owner’s own closure — the same rows the Maat app writes when
somebody taps “Block dates”, indistinguishable from one made there.
A host block records no owner. Releasing by date cannot tell your closure from one made in
the Maat app, so a routine “full sync” that releases every date your system does not know about
would reopen a flat somebody closed for rewiring. A block pushed under your own
external_uid
has your reservation id on it, refuses a collision with a Maat booking, and is released only
through that id.PUT /v1/blocks/{external_uid} when
a night is gone because it was sold through your own system.
A set of days, not a range
dates[] is a set. The nights need not be contiguous — closing every Sunday in a quarter
is one request, not thirteen — and up to 366 fit in one call. Each must be a real calendar
date, today or later in UTC.
category is required and closed: maintenance, personal_use, renovation,
cleaning, other. There is no note and no other free-text field, for the same
reason the block contract has none, and an unrecognised property is refused by name rather
than dropped.
The same conflict rule, and one thing that is not a conflict
A night carrying a Maat booking or a live hold refuses the whole request with409
and writes nothing, naming every colliding night in conflicts[].
A night that is already closed by something else — a calendar feed, or another
integration’s block — is not a conflict. It is already unavailable, which is what you
asked for. It comes back under already_held instead: no host block was written there,
and that closure will lift when its own source lifts. blocked lists every night you
named that carries a host block now, including ones that were already blocked, because
the question is “is it closed?” rather than “did this call close it?”.
Release frees only host blocks
DELETE frees the nights a host block is holding and nothing else. A night carrying a
booking, a live hold, a calendar feed’s block or a block you pushed under your own
external_uid is skipped in silence — freeing one would put a sold night back on sale. 204 either way, so releasing
nights that were never closed is not an error to retry.
Live holds
A hold is what a Maat guest takes when they reach checkout: the nights come off sale while their payment completes, before there is a booking. It lasts minutes and it is why a push can meet a409 on nights that looked free when you last read the calendar.
Two ways to see one:
- Subscribe.
calendar.heldfires the moment the nights go off sale, andcalendar.releasedwhen the hold ends — withreason: expired(sell them again) orreason: converted(a booking took them, andbooking_referencenames it). Both carry the samehold_reference. - Ask.
GET /v1/calendar/holdslists every hold live right now across your workspace, soonest to lapse first. Expired holds are never in the answer. There are no filters: the whole point is that you are told about all of them, so ask it when your integration starts up or after an outage rather than replaying deliveries.
held in GET /v1/listings/{listing_id}/calendar.
until on a hold is a deadline, not an authority. Stop selling when the hold appears and
wait for the release — if a checkout is given longer, a second calendar.held arrives with
the same reference and a later until.
Current status
Live in the sandbox — pushes, releases, reads, host blocks, live holds and real409
conflicts.
Against a production workspace every request is authenticated and validated in full, then
answers 501: keep holding those nights in your own system until the production
contract is enabled.