Harbor
Developer docs menu

Start here

Errors

One error shape, with a stable, machine-readable code you can branch on.

Harbor uses conventional HTTP status codes and returns errors in one JSON envelope, so for almost every call you only have to handle one shape.

The exception is the OAuth endpoints a standard OAuth library calls for you. When a third-party app gets an error from /oauth/token, or from /oauth/register when it registers itself, the error comes in the standard OAuth shape instead, so the library can read it. See token endpoint errors.

The error envelope

{
  "error": {
    "code": "validation_failed",
    "message": "The request was invalid.",
    "details": { "title": "is required" },
    "request_id": "req_01HX…"
  }
}
FieldDescription
codeA stable, machine-readable identifier. Branch on this, not on the message.
messageHuman-readable and safe to show a user. Internal errors never leak specifics.
detailsOptional. Present mainly on validation errors as a field → message map.
request_idIncluded when available, and always in the X-Request-Id response header.

Handling errors

Check the HTTP status first, then the code for anything you want to handle specifically:

import requests

r = requests.post(f"{BASE}/notes", json=payload,
                  headers={"Authorization": f"Bearer {token}"})

if r.status_code == 401:
    raise SystemExit("Token missing, expired, or revoked — re-authenticate.")

if not r.ok:
    err = r.json()["error"]
    if err["code"] == "validation_failed":
        for field, msg in err.get("details", {}).items():
            print(f"  {field}: {msg}")
    raise RuntimeError(f"{err['code']}: {err['message']} ({err.get('request_id')})")

note = r.json()["data"]
const res = await fetch(`${BASE}/notes`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) {
  const { error } = await res.json();
  if (error.code === "insufficient_scope") {
    throw new Error("This token can't do that — check its scopes.");
  }
  throw new Error(`${error.code}: ${error.message}`);
}

Common error codes

CodeHTTPMeaning
bad_request400Malformed request (e.g. unparseable JSON).
unauthorized401Authentication required, or the bearer token is missing.
invalid_token401The token is bad, expired, or revoked.
forbidden403Authenticated, but not allowed to do this.
insufficient_scope403The token lacks a scope this endpoint requires.
not_found404The resource doesn’t exist — or isn’t yours.
conflict409The request conflicts with the current state.
unsupported_media415Wrong Content-Type.
payload_too_large413The request body is too large.
validation_failed422The request failed validation; see details.
rate_limited429Too many requests — slow down and retry.
internal_error500Something went wrong on our side. Retry, then tell us the request_id.
timeout503The request exceeded the server’s time budget.

Scopes and permissions

If you get a 403 with insufficient_scope, the token is valid but wasn’t granted a scope the endpoint needs. Mint a new token (or request a broader OAuth scope) — see Authentication.

Account limits

A 403 with plan_limit_reached means an account limit blocked a write (for example, creating past a plan’s cap, or writing to an account that has gone read-only after a lapse). Reads, exports, and deletes always keep working — you can always get your data out. The details carry machine-readable context (the resource, the limit, and an upgrade URL) so you can show a helpful message.

Metadata errors

A metadata object that breaks a limit is a 422 validation_failed, and details.metadata says which limit:

details.metadataCause
must be a JSON objectThe value is an array, string or number.
is 17010 bytes as compact JSON; the limit is 16384Over 16 KB.
has 65 top-level keys; the limit is 64Over 64 top-level keys.
key "bad.key" is not allowed: keys are 1-64 characters of A-Z, a-z, 0-9, '_' or '-'A top-level key breaks the format.
nests more than 5 levels deep (the top-level object is level 1)Nested too deep.

A bad metadata filter on a list endpoint is keyed by the parameter instead — details["meta.bad.key"], details.meta_has or details.has_metadata — and a bad meta: search operator by details.meta. A metadata body over 1 MiB is 413 payload_too_large.

Retrying

  • 429 and 503 are safe to retry with exponential backoff.
  • 5xx may be retried; if it persists, send us the request_id.
  • 4xx other than 429 won’t succeed on retry without changing the request.

Next steps