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

StatusMeaning
400Validation error — a field is missing, malformed, or has an unknown value.
401Authentication failed — missing or invalid API key.
402Insufficient credits — the tenant balance is exhausted.
403Feature 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.
404Not found — the resource does not exist or is not visible to this tenant.
409Conflict — the resource already exists (for example, a conflicting idempotent create), or it is in a state that forbids the call.
429Rate 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:

CodeTypical statusMeaning
insufficient_credits402The tenant has no credits left to create a session or run a tool.
feature_not_enabled403The tenant's plan/config doesn't include this module or standalone tool.
api_key_tenant_mismatch403/v3 only: the API key belongs to a different tenant than the base URL it was sent to.
unknown_tenant_host403/v3 only: the base URL's host belongs to no tenant.
not_found404The requested resource does not exist for this tenant.
invalid_status400An update-status call passed a value that is not a valid session status.
type is immutable400A Lists API PATCH tried to change a list's type after creation.
import_in_progress / already_deleted409A 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."

FAQ