Skip to main content
A calendar block is one message: these nights are gone. When a stay is sold through your own system — your website, your booking engine — you push a block so Maat stops selling those nights; when that reservation ends early, you release it. Three operations cover the whole lifecycle: 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 a validation_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 answers 409 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 answers 409 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 own provider_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.
Use these when the closure is the owner’s decision, and 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 with 409 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 a 409 on nights that looked free when you last read the calendar. Two ways to see one:
  • Subscribe. calendar.held fires the moment the nights go off sale, and calendar.released when the hold ends — with reason: expired (sell them again) or reason: converted (a booking took them, and booking_reference names it). Both carry the same hold_reference.
  • Ask. GET /v1/calendar/holds lists 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.
A held night also reads 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 real 409 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.