Errors
Every failure returns the same envelope, whatever went wrong:
{
"error": {
"code": "INSUFFICIENT_PERMISSIONS",
"message": "This key does not have the 'boats.read' permission",
"requestId": "01JD8K2M4Q7X9V"
}
}Branch on code, never on message. The codes are a closed, versioned set and adding one is a
contract change; messages are written for humans and may be reworded at any time.
details appears on validation failures and names the offending field. It is absent otherwise.
Codes
| Code | Status | Meaning | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | A parameter is missing, malformed, unknown, or out of range | Fix the request. Never retry unchanged. |
UNAUTHORIZED | 401 | Credentials required but not established | Authenticate. |
MISSING_API_KEY | 401 | No Authorization: Bearer header, or a malformed one | Send the key in the header. Not the query string. |
INVALID_API_KEY | 401 | Unknown, revoked, disabled, expired or wrong-environment key | Check the key and your subscription status. |
FORBIDDEN | 403 | Understood, but not allowed | Do not retry. |
INSUFFICIENT_PERMISSIONS | 403 | The key lacks the scope this endpoint requires | Rotating the key will not help — check its scopes. |
PRO_SUBSCRIPTION_REQUIRED | 403 | Key management requires an active Pro subscription | Resubscribe. Only reachable on dashboard routes. |
NOT_FOUND | 404 | No such resource, or it is not published | Do not retry. |
METHOD_NOT_ALLOWED | 405 | Known path, wrong HTTP method | The API is read-only; use GET. |
KEY_LIMIT_REACHED | 409 | You already hold the maximum number of active API keys | Revoke one. Only reachable on dashboard routes. |
PAYLOAD_TOO_LARGE | 413 | Request body exceeds the limit | Send less. |
RATE_LIMITED | 429 | Too many requests in the current 60-second window | Back off — see Rate limits. |
QUOTA_EXCEEDED | 429 | Your subscription's monthly request quota is spent | Stop until it renews; X-Quota-Reset says when. Retrying will not help. |
SERVICE_UNAVAILABLE | 503 | A dependency we rely on is unreachable | Retry after Retry-After. |
INTERNAL_ERROR | 5xx | Something broke on our side | Retry with backoff; report it with the requestId. |
INVALID_SIGNATURE (400) also exists, but is only reachable on our Stripe webhook endpoint and will
never appear on a /v1 route.
Which errors are worth retrying
Retry, with exponential backoff: 429, 503, and 5xx. These are transient by definition —
a rate limit window elapses, a dependency recovers, a bad deploy is rolled back. Honour
Retry-After when it is present rather than picking your own interval.
With one exception: QUOTA_EXCEEDED. It shares the 429 status with RATE_LIMITED but is not
transient in any useful sense — only the monthly renewal clears it, which may be weeks away, and
Retry-After will say so. This is why you should branch on error.code rather than on the status
alone: a retry loop that treats every 429 the same will sit and spin for a fortnight.
Never retry unchanged: 400, 403, 404, 405, 413. Nothing about the passage of time makes
a malformed parameter valid or a missing scope present. Retrying these burns your rate limit and
delays you noticing the real bug.
401 is a special case. Retrying the same key is pointless, but a 401 is not always your
mistake: if your subscription lapsed, the fix is to resubscribe, and your existing keys start
working again on their own. Alert on 401s rather than silently retrying them.
Do not treat a 401 as a signal to rotate credentials automatically. During a subscription lapse the key is valid and merely disabled — rotating produces a new key that is also disabled, and now you have two.
404 versus empty
These mean different things and are worth handling separately:
- 404
NOT_FOUND— you asked for one specific boat or manufacturer and it does not exist, or is not published. Unpublished and non-existent are deliberately indistinguishable from outside; we do not confirm the existence of drafts. - 200 with
data: []— your filters matched nothing. The resource type exists, the query was valid, there is simply no match.
A request for one manufacturer's boats returns 404 if the manufacturer is unknown, and data: []
if the manufacturer exists but has no published boats.
Errors never leak internals
A failure never returns a stack trace, an upstream error body, or the name of a system we depend on.
An unexpected error is logged on our side with its request ID and answered with a generic
INTERNAL_ERROR. If you need to know what happened, quote the requestId to
[email protected] — we can see the detail that your response
deliberately omits.