Harbor
Developer docs menu

Start here

Errors

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

Harbor uses conventional HTTP status codes and returns every error in a single JSON envelope, so you only have to handle one shape.

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.

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