application/problem+json:
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’stype 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 same401; 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.