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…"
}
}
| Field | Description |
|---|---|
code | A stable, machine-readable identifier. Branch on this, not on the message. |
message | Human-readable and safe to show a user. Internal errors never leak specifics. |
details | Optional. Present mainly on validation errors as a field → message map. |
request_id | Included 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
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Malformed request (e.g. unparseable JSON). |
unauthorized | 401 | Authentication required, or the bearer token is missing. |
invalid_token | 401 | The token is bad, expired, or revoked. |
forbidden | 403 | Authenticated, but not allowed to do this. |
insufficient_scope | 403 | The token lacks a scope this endpoint requires. |
not_found | 404 | The resource doesn’t exist — or isn’t yours. |
conflict | 409 | The request conflicts with the current state. |
unsupported_media | 415 | Wrong Content-Type. |
payload_too_large | 413 | The request body is too large. |
validation_failed | 422 | The request failed validation; see details. |
rate_limited | 429 | Too many requests — slow down and retry. |
internal_error | 500 | Something went wrong on our side. Retry, then tell us the request_id. |
timeout | 503 | The 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.metadata | Cause |
|---|---|
must be a JSON object | The value is an array, string or number. |
is 17010 bytes as compact JSON; the limit is 16384 | Over 16 KB. |
has 65 top-level keys; the limit is 64 | Over 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
429and503are safe to retry with exponential backoff.5xxmay be retried; if it persists, send us therequest_id.4xxother than429won’t succeed on retry without changing the request.
Next steps
- API reference — every endpoint, with examples
- Authentication — tokens and scopes