Reference
Errors
HTTP status codes, stable error codes, and the per-surface response body shapes.
Every error carries the appropriate HTTP status. Branch on the HTTP status first, then treat the stable string code as the machine signal. Do not string-match human-readable messages — they can change without notice.
HTTP status codes
| Status | Meaning |
|---|---|
400 | Validation error — a field is missing, malformed, or has an unknown value. |
401 | Authentication failed — missing or invalid API key. |
402 | Insufficient credits — the tenant balance is exhausted. |
403 | Feature not enabled — the tenant is not entitled to the module or tool you called. On /v3, also an API key used on a base URL that isn't its tenant's. |
404 | Not found — the resource does not exist or is not visible to this tenant. |
409 | Conflict — the resource already exists (for example, a conflicting idempotent create), or it is in a state that forbids the call. |
429 | Rate limited — too many requests; back off and retry. See rate limits. |
Note
403 and 404 deliberately look similar from the outside. A resource that
belongs to another tenant returns 404, not 403 — the API never confirms
that a resource exists for a tenant it doesn't belong to. 403 is reserved
for your own tenant hitting a feature it isn't entitled to run (see
standalone tools), and on /v3 for a key sent to the
wrong base URL (api_key_tenant_mismatch, unknown_tenant_host).
Stable error codes
These string codes are stable machine signals. Match on them rather than on message text, since message copy can be reworded at any time:
| Code | Typical status | Meaning |
|---|---|---|
insufficient_credits | 402 | The tenant has no credits left to create a session or run a tool. |
feature_not_enabled | 403 | The tenant's plan/config doesn't include this module or standalone tool. |
api_key_tenant_mismatch | 403 | /v3 only: the API key belongs to a different tenant than the base URL it was sent to. |
unknown_tenant_host | 403 | /v3 only: the base URL's host belongs to no tenant. |
not_found | 404 | The requested resource does not exist for this tenant. |
invalid_status | 400 | An update-status call passed a value that is not a valid session status. |
type is immutable | 400 | A Lists API PATCH tried to change a list's type after creation. |
import_in_progress / already_deleted | 409 | A DELETE on an identity-import batch that is still queued or running, or that has already been deleted. |
Note
A unified { "error": { "code", "message" } } envelope across all surfaces is
planned. Until it ships, the body shape depends on which surface you hit —
covered below. Branching on HTTP status keeps your handling portable across the
transition.
Per-surface body shapes
The JSON body currently differs by surface.
Success is a bare object or array. Errors are a single detail string carrying
the code or message:
{ "detail": "insufficient_credits" }{ "detail": "not_found" }{ "detail": "feature_not_enabled" }A few /v3 endpoints add a second field alongside detail — features on a
403, and a human-readable error on the identity-import validation 400s.
detail is always the machine signal; treat anything beside it as extra
context for a log line, not as the code to branch on.
Handling patterns
Edge cases worth handling explicitly
Example: robust create handling
const res = await fetch(`${BASE}/v3/session/`, {
method: "POST",
headers: { "x-api-key": KEY, "content-type": "application/json",
"idempotency-key": key },
body: JSON.stringify(body),
});
switch (res.status) {
case 201: return res.json(); // ok (or idempotent replay)
case 402: throw new TemporaryOutage("insufficient_credits");
case 403: throw new ConfigError((await res.json()).detail);
case 429: return retryWithBackoff(); // honour the bucket
case 400: throw new BadRequest(await res.json()); // fix the request
default: throw new Error(`obsidian ${res.status}: ${await res.text()}`);
}import requests
resp = requests.post(
f"{BASE}/v3/session/",
headers={"x-api-key": KEY, "Idempotency-Key": key},
json=body,
timeout=15,
)
if resp.status_code == 201:
session = resp.json()
elif resp.status_code == 402:
raise TemporaryOutage("insufficient_credits")
elif resp.status_code == 403:
raise ConfigError(resp.json().get("detail"))
elif resp.status_code == 429:
session = with_backoff(lambda: requests.post(...))
elif resp.status_code == 400:
raise BadRequest(resp.json())
else:
resp.raise_for_status()resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case 201:
return decodeSession(resp.Body)
case 402:
return nil, ErrInsufficientCredits
case 403:
return nil, ErrFeatureNotEnabled
case 429:
return withBackoff(createSession)
case 400:
return nil, decodeValidationError(resp.Body)
default:
return nil, fmt.Errorf("obsidian %d", resp.StatusCode)
}Warning
Never build a code path that treats an unrecognised status as success. New
status codes can be introduced for new failure modes; a default branch that
raises (as above) is safer than one that falls through to "ok."