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…"
}
}
| 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.
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