Errors

Every failure returns the same envelope, whatever went wrong:

JSON
{
  "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

CodeStatusMeaningWhat to do
VALIDATION_ERROR400A parameter is missing, malformed, unknown, or out of rangeFix the request. Never retry unchanged.
UNAUTHORIZED401Credentials required but not establishedAuthenticate.
MISSING_API_KEY401No Authorization: Bearer header, or a malformed oneSend the key in the header. Not the query string.
INVALID_API_KEY401Unknown, revoked, disabled, expired or wrong-environment keyCheck the key and your subscription status.
FORBIDDEN403Understood, but not allowedDo not retry.
INSUFFICIENT_PERMISSIONS403The key lacks the scope this endpoint requiresRotating the key will not help — check its scopes.
PRO_SUBSCRIPTION_REQUIRED403Key management requires an active Pro subscriptionResubscribe. Only reachable on dashboard routes.
NOT_FOUND404No such resource, or it is not publishedDo not retry.
METHOD_NOT_ALLOWED405Known path, wrong HTTP methodThe API is read-only; use GET.
KEY_LIMIT_REACHED409You already hold the maximum number of active API keysRevoke one. Only reachable on dashboard routes.
PAYLOAD_TOO_LARGE413Request body exceeds the limitSend less.
RATE_LIMITED429Too many requests in the current 60-second windowBack off — see Rate limits.
QUOTA_EXCEEDED429Your subscription's monthly request quota is spentStop until it renews; X-Quota-Reset says when. Retrying will not help.
SERVICE_UNAVAILABLE503A dependency we rely on is unreachableRetry after Retry-After.
INTERNAL_ERROR5xxSomething broke on our sideRetry 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.