Skip to main content
Every error in this API is an RFC 7807 problem document, served as application/problem+json:
Branch on code, never on type or the wording of detail. code is the contract; title and detail are for people and we reserve the right to rewrite them. instance identifies this one occurrence — it is an identifier, not a URL — and quoting it is the fastest way to find this request in our logs. code is snake_case. type is derived from it by replacing underscores with hyphens — not_enabled becomes …/errors/not-enabled — so the two can never be different vocabularies, and neither can be parsed out of the other by accident. Switch on code. The same list is published in the contract itself as ProblemCode, which is what a generated client types code as.

The codes

Every problem document’s type URI redirects here — both spellings of it, the hyphenated one the URI carries and the snake_case one code carries. Two codes carry a typed extension member: validation_failed adds errors[] ({ field, code } per rejected field — never the value you sent), and conflict adds conflicts[] (every colliding date, untruncated) on the calendar-block collision only — its other producers carry no conflicts[] at all. Parse those, not the prose. errors[].code names the rule a field broke, from ValidationErrorCode. Show it beside field; do not switch on it. Most of that vocabulary is the validator’s own rule names; a few name a check that needs something your request does not carry, such as the currency a listing is priced in. Either way it can gain members — and an unrecognised value means what a recognised one means, that the field was not accepted. field is the part to act on.

One refusal, several causes — on purpose

Some errors do not say which of several checks failed. An unknown key, a revoked key and a wrong secret all answer the same 401; a sandbox/production mismatch and a workspace that does not resolve answer the same 403 workspace_scope_mismatch; a listing that does not exist and one that belongs to another workspace answer the same 404. Distinguishing them would let anyone enumerate credentials, tenants or another customer’s inventory. If you are stuck on one of these, more requests will not tell you more; see Get access for who to ask. insufficient_scope is the exception: it names the scope you lack, because a scope is a fact about your own credential and not about anybody else’s data. photo_not_found follows the same rule as the other three: one answer covers a photo that does not exist and a photo that belongs to a different property, and the lookup behind it is a single statement narrowed by both ids, so the two are indistinguishable in timing as well as in wording.

The five 409s that are not conflicts

conflict covers several different situations across this API, but they share one shape: the request was fine, and the resource’s own state is what cannot answer it right now — a collision over the nights, a booking not currently pending, and so on. Some of those clear on their own (wait, then retry) and some do not (a review already replied to never will). The five codes beside it are a different kind of refusal, and re-sending will never help any of them: they say the resource is the wrong kind of thing to address that way, or that it is not yours to set. property_not_simple means the property has more than one unit, or more than one listing (or the unit is pooled inventory under a parent unit). The targeted edits — PUT /v1/properties/{property_id}/address, …/amenities, PATCH /v1/units/{unit_id}, PUT /v1/units/{unit_id}/house-rules, PATCH /v1/listings/{listing_id} and PUT /v1/listings/{listing_id}/pricing — manage a property that is one unit with one listing, which is every property this API can create today. It is a temporary refusal: multi-unit routes will be additions later, not renames of these, so nothing you build now moves. listing_pooled_unsupported means the listing sells a room type over several physical rooms — ten identical doubles, say — and Maat picks the room per booking. A calendar block closes ONE unit, so PUT /v1/blocks/{external_uid} and POST /v1/listings/{listing_id}/calendar/blocks have nothing single to close and write nothing. Like property_not_simple, it is about the listing’s shape, not its state. inventory_not_supported means the listing is an experience or a service — something sold by the seat rather than by the night, for which “bedrooms”, “check-in time” and “a nightly rate” have no answer. The reads return such a listing with its inventory_kind rather than pretending it does not exist, because telling you your own listing is missing would send you hunting through your own catalogue. The writes are where the refusal belongs. pricing_mode_conflict means this listing’s price is set by another source, so a rule you attached would never be evaluated. Retrying cannot succeed while that is true — and it is not something this API can change: the source is released by the workspace, in the Maat app. Until then a quick action on that listing is refused rather than stored somewhere it would silently do nothing. sandbox_only means you named the listing in your own live workspace and the operation you called acts on sandbox inventory only — almost always an id copied from your production dashboard. Today one operation produces it: POST /v1/sandbox/bookings, the booking fixture. It is a 409 rather than a 404 for the same reason property_not_simple is: the listing is yours and you can see it on every read, so telling you it does not exist would send you hunting through your own catalogue. It has its own code rather than sharing conflict because that operation answers both, and only one of them is worth retrying: conflict there means the nights are taken and a different window will work. It is scoped to your own live/sandbox pair. A listing that belongs to neither half of it answers 404 listing_not_found, the same as an id that names nothing at all — so this code never tells you anything about inventory that is not yours. Nothing is written in any of the four cases.

Four 429s, and they are not the same wait

rate_limited is the per-key ceiling on requests. It clears within the hour, it carries Retry-After, and backing off is the whole answer. redrive_budget_exhausted is the replay allowance on POST /v1/webhooks/deliveries/{delivery_id}/redrive, over a rolling 24 hours rather than a calendar day: it frees up as the window moves past an earlier replay, and Retry-After is the wait until it does. Only that one operation produces it, which is what tells it apart from the hourly ceiling that can fire on any call. test_event_budget_exhausted is the same shape of allowance on POST /v1/webhooks/endpoint/test — rolling 24 hours, Retry-After until the oldest test falls out of the window — and its own counter: recovering a backlog with redrives and exercising a handler with test events never compete for one figure. photo_quota_exceeded is the per-key ceiling on photos presigned per UTC day. It does not clear within the hour and it carries no Retry-After, because there is no short wait that would help. The answer is to stop the import and resume tomorrow. The presign draws on the hourly ceiling too, so a 429 from it is one of two codes and only one of them names an interval — read code before you read Retry-After. They are separate codes precisely so you do not poll a day-long refusal on an hourly timer. Nothing is reserved, no upload is authorized and nothing is replayed when any of them fires. See Rate limits.

There is no exception

Every response in this API is a problem document, the token exchange included. POST /v1/token used to answer { "message": … }; it now answers 401 unauthorized and 503 service_unavailable in the same RFC 7807 shape as everything else, so one error handler covers the first call of a session and every call after it. See the changelog for the date.
The type URIs are stable identifiers, and code is what you should branch on. Do not fetch a type URI at runtime — it redirects to the code table above for a person to read, and it is not an API. All twenty-four land at the top of that one table rather than on a block of their own, so following …/errors/conflict and following …/errors/not_enabled puts you in the same place: find your row in the code column.