Errors
Branch on the HTTP status first; it tells you what kind of problem you have and whether a retry can help. The body then says why, in one of four shapes. The shapes differ because different layers of the API produce them, and making them one would change the API, so this page documents them as they are.
Status codes
Section titled “Status codes”| Status | Meaning | Retry? |
|---|---|---|
200 |
A read, an update, or the repeat of an earlier write: an Idempotency-Key replay, a booking whose external_reference already exists, a till sale whose external_ref is known |
|
201 |
A booking, a webhook endpoint or a till sale was created | |
202 |
A webhook replay was queued | |
204 |
A webhook endpoint was removed | |
400 |
The request is wrong: a missing or invalid field, an unknown site, a booking that cannot be cancelled, a date in the query that is not a real date (which), a body that is not JSON | No. Fix the request. |
401 |
The key is missing, unknown, revoked or expired (Credentials) | No |
403 |
The key lacks the scope this endpoint needs | No |
404 |
No such record at this campground, or a page past the end of a list | No |
409 |
The request clashes with the campground’s data: the pitch is taken, the external_reference belongs to another key, the Idempotency-Key was used for another request, the webhook endpoint is paused |
Not unchanged |
422 |
The campground’s date rules refuse the stay: a minimum stay, no arrivals or departures on that day, or a night not on sale | No. Offer other dates. |
429 |
Your key’s rate limit is used up | After Retry-After seconds |
503 |
A webhook replay could not be queued | Yes, later |
Other 5xx |
A fault on our side | Yes, with a growing pause and, for a write, the same Idempotency-Key |
The four shapes
Section titled “The four shapes”1. A code or a sentence in detail
Section titled “1. A code or a sentence in detail”{ "detail": "not_found" }Most errors look like this. detail holds either a code in snake_case or an
English sentence. The codes:
detail |
Status | When |
|---|---|---|
invalid_key |
401 |
The key is unknown or mistyped |
key_inactive |
401 |
The key was revoked or has expired |
not_found |
404 |
No such booking, webhook endpoint, delivery or till sale at this campground |
site_not_found |
400 |
POST bookings/: site_id is not one of the campground’s public sites |
site_or_site_type_required |
400 |
POST bookings/: neither a site_id nor a site type the campground knows |
create_failed |
400 |
POST bookings/: the booking could not be made for a reason not listed here |
no_pitch_available |
409 |
POST bookings/ by site type: no pitch of that type is free |
external_reference_conflict |
409 |
POST bookings/: a booking with this external_reference exists, and your key did not make it |
webhook_inactive |
409 |
Webhook replay: the endpoint was removed or paused |
enqueue_failed |
503 |
Webhook replay: it could not be queued |
The sentences come from the framework the API is built on, and from the checks on date parameters:
401"Authentication credentials were not provided.": noAuthorizationheader, or one that is notBearer ck_....403"You do not have permission to perform this action.": the scope is missing.404"Invalid page.": a page past the end.429"Request was throttled. Expected available in 37 seconds."400for a date in the query that is not a real date (see Bad dates in a query), or a body that is not valid JSON.
Match on the status and on the codes, never on the wording of a sentence.
2. detail with more keys
Section titled “2. detail with more keys”{ "detail": "site_unavailable", "reason": "overlap_or_block" }A few errors add a key that says more:
| Body | Status | Sent by |
|---|---|---|
{"detail": "site_unavailable", "reason": "overlap_or_block"} |
409 |
POST bookings/: the pitch is booked, blocked or held for those dates |
{"detail": "not_cancellable", "status": "completed"} |
400 |
POST bookings/{id}/cancel/: only reservation_request, pending and confirmed bookings can be cancelled; status is the booking’s |
{"detail": "Idempotency-Key was reused with a different request.", "code": "idempotency_key_reuse"} |
409 |
Any POST or PUT that carries an Idempotency-Key (Retrying safely) |
{"detail": "Departures are not permitted on 2026-07-17 for this pitch category.", "code": "closed_to_departure", "date": "2026-07-17"} |
422 |
POST bookings/: a date rule of the pitch type refuses the stay, and nothing is created. code is the first rule the stay breaks, checked in this order: stop_sell, closed_to_arrival, min_stay, closed_to_departure. date is the closed night for stop_sell, the departure date for closed_to_departure and the arrival date otherwise. detail is an English sentence for a person |
3. Field errors with codes
Section titled “3. Field errors with codes”{ "check_out_date": [ { "code": "invalid", "detail": "must be after check_in_date", "params": {} } ]}When the body you sent does not pass the checks, each key is the name of a
field (non_field_errors when the problem is not one field), and each item
carries a code, a sentence in detail and params. Codes in lower case come
from the field checks (required for a field you left out, invalid), codes
in capitals from the campground’s rules, such as MAX_OCCUPANCY for more
guests than the pitch takes. Sent with 400 by
POST bookings/,
POST webhooks/
and POST pos/transactions/.
POST bookings/ also answers 409 in this shape when the booking is refused
at the moment it is saved, for example with SITE_UNAVAILABLE when the pitch
is taken, or ADMIN_BLOCK when the campground has blocked it. So a taken pitch
can come back in shape 2 or in shape 3; handle both.
4. Field errors as plain text
Section titled “4. Field errors as plain text”{ "channel": ["This channel label is reserved."] }The same idea without codes: each item is a sentence. Sent with 400 by
POST bookings/ when the channel (or source) label is one Grondia reserves
for itself, and by
PUT ari/restrictions/
when a row does not validate. That one answers with a list, one object per row
you sent, empty for the rows that were fine:
[{}, { "date": ["Date has wrong format. Use one of these formats instead: YYYY-MM-DD."] }]The API reference describes every error with one model,
PublicError (detail, error and
reason, all optional). Read it as a rough summary: no endpoint sends error
today, and shapes 3 and 4 are not in it.
Bad dates in a query
Section titled “Bad dates in a query”Every date in a query is checked before the API uses it. A value that is not a
real date answers 400, with a sentence in detail that names the parameter:
| Parameter | Value | Answer |
|---|---|---|
check_in, check_out on sites/; start, end on ari/ and ari/restrictions/ |
Not a valid YYYY-MM-DD date |
400, a sentence in detail |
check_in_from, check_in_to on bookings/ |
Not a real date, such as tomorrow, 14.07.2026 or 2026-02-30 |
400, for example {"detail": "check_in_from must be an ISO date (YYYY-MM-DD)."} |
updated_since on bookings/ |
Not a real date or date and time, such as yesterday, 2026-13-45 or 2026-07-14T25:00:00 |
400, {"detail": "updated_since must be an ISO date or datetime."} |
The same value is refused every time, so do not retry it: build dates with a
date library, send them as YYYY-MM-DD (for updated_since, send the
updated_at values the API gave you), and fix the value.
The rules a stay’s dates have to meet, such as the longest stay and how far from today a booking may start, are in Dates, times and money.
One function for all four
Section titled “One function for all four”For your logs, a line from any error body:
def describe_error(status: int, body) -> str: """One log line from an error body of any of the four shapes.""" if not isinstance(body, (dict, list)): # a 5xx body is HTML, not JSON return f"{status} {body!r}"[:300] if isinstance(body, dict) and isinstance(body.get("detail"), str): # shapes 1 and 2 more = ", ".join(f"{key}={value}" for key, value in body.items() if key != "detail") return f"{status} {body['detail']}" + (f" ({more})" if more else "") rows = body if isinstance(body, list) else [body] # shapes 3 and 4 problems = [] for number, row in enumerate(rows): for field, items in (row or {}).items(): for item in items if isinstance(items, list) else [items]: coded = isinstance(item, dict) and "code" in item text = f"{item['code']}: {item.get('detail')}" if coded else str(item) where = f"row {number}, {field}" if isinstance(body, list) else field problems.append(f"{where}: {text}") return f"{status} " + "; ".join(problems)In the sandbox
Section titled “In the sandbox”The sandbox key is real and read-only, so you can see shape 1 for yourself: a
wrong key answers 401 with invalid_key, POST bookings/ answers 403
(the key has no bookings:write), GET bookings/999999999/ answers 404 with
not_found, and ?page=99 answers 404 with "Invalid page.". The errors of
writes (400, 409, 422) need a key that may write; ask Felix,
felix@grondia.com, or Siro,
siro@grondia.com.