Errors
The problem details every error answers with, every error code and what to do about it, and which errors to retry.
Every error answers with an HTTP status and a JSON body in the
RFC 9457 problem details format, with
Content-Type: application/problem+json. Branch on code, which is stable; title and detail
are for people and can change.
{
"type": "https://docs.furniops.com/errors/rate_limited",
"title": "Too many requests",
"status": 429,
"code": "rate_limited",
"detail": "The standard.read budget is spent. Retry after 1 s.",
"request_id": "req_034XqBaL2mRLTZPXyRbaUl",
"doc_url": "https://docs.furniops.com/errors/rate_limited"
}
| Field | Always | Meaning |
|---|---|---|
code | yes | The machine-readable error code. Branch on this. |
status | yes | The HTTP status, repeated for convenience. |
title | yes | A short summary of the code. |
type | yes | A URL identifying the code: its page in these docs. |
doc_url | yes | The same page, under a name that says what it is. |
request_id | yes | This request's ID, also sent as the Request-Id header on every response. Quote it to support. |
detail | no | What went wrong this time, in words. |
errors | no | For a request that failed validation, each offending field: its dotted path, a machine-readable code and a message. |
instance | no | The URI of this occurrence, when there is one. |
A validation failure names each field:
{
"type": "https://docs.furniops.com/errors/invalid_request",
"title": "Invalid request",
"status": 400,
"code": "invalid_request",
"detail": "The cursor is not one this list issued",
"request_id": "req_034XqBaL2mRLTZPXyRbaUm",
"doc_url": "https://docs.furniops.com/errors/invalid_request",
"errors": [
{
"path": "starting_after",
"code": "invalid_cursor",
"message": "Must be a cursor from this list"
}
]
}
Handling errors with the SDK
The SDK turns every error answer into a typed exception, after its own retries:
try {
await furniops.products.get("prod_034XqBaL2mRLTZPXyRbaUc");
} catch (error) {
if (error instanceof NotFoundError) {
// 404: no such product, or not one this key may see.
} else if (error instanceof ValidationError) {
// 400, 413, 415, 422, 428: fix the request; problem.errors names the fields.
for (const field of error.problem?.errors ?? []) console.log(field.path, field.code);
} else if (error instanceof RateLimitError) {
// 429 after the retries, or a Retry-After longer than 60 s.
console.log("retry in", error.retryAfterMs, "ms");
} else if (error instanceof AuthenticationError || error instanceof PermissionError) {
// 401: no key, or a bad one. 403: the key lacks the scope, or test mode is required.
} else if (error instanceof ConflictError) {
// 409 or 412: a state or version conflict.
} else if (error instanceof ConnectionError) {
// No response at all (TimeoutError is one), after the retries.
}
if (error instanceof FurniOpsError) {
// Every SDK error: quote requestId to support.
console.error(error.status, error.code, error.requestId, error.message);
}
}| Class | When |
|---|---|
AuthenticationError | 401 |
PermissionError | 403: a missing scope, or a feature the plan does not include |
NotFoundError | 404 |
ConflictError | 409, 412 |
ValidationError | 400, 413, 415, 422, 428 |
RateLimitError | 429: rate_limited, or quota_exceeded for a spent monthly quota |
ApiError | any other error status, such as 500 or 503 |
ConnectionError | no response at all; TimeoutError is one |
All of them extend FurniOpsError, which carries status, code, problem (the parsed body) and
requestId. See Errors and retries for the retry rules.
What to retry
| Answer | Retry? |
|---|---|
429 rate_limited | Yes, after Retry-After. See Rate limits. |
429 quota_exceeded | Not before Retry-After, which is the end of the month (UTC). See Usage and quotas. |
503 overloaded | Yes, after Retry-After (2 to 10 s). |
503 unavailable, 500 internal | Yes, with backoff. A POST is safe to retry with the same idempotency key. |
409 idempotency_request_in_progress | Yes, after Retry-After: you then get the first request's answer. |
412 precondition_failed | Read the object again, then retry with the new ETag. |
403 feature_unavailable | No: the plan does not include the feature. See Plans and features. |
| A network error or timeout | Yes, with the same idempotency key. |
Any other 4xx | No. Fix the request: retrying it unchanged gets the same answer. |
The SDK retries 429, every 5xx, timeouts and network errors for you (twice by default, with
exponential backoff and jitter), always with the same idempotency key, but never waits more than
60 seconds for a Retry-After: a quota_exceeded throws at once. It does not retry a 409 or a
412: those need a decision from your code.
Error codes
Each code links to its own page with what it means, what to do, an example and the operations that can answer it.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Invalid request. The request is malformed: a parameter or body field is missing, of the wrong type or out of range, or a cursor is not one the list issued. errors[] names each offending field by its dotted path, with a machine-readable code (for example invalid_cursor). |
| 400 | idempotency_key_invalid | Idempotency-Key header missing or malformed. A POST that requires an Idempotency-Key header arrived without one, or with a value that is not 1 to 255 visible ASCII characters (a plain token or a quoted structured-field string). |
| 400 | unsupported_version | Unsupported FurniOps-Version. The FurniOps-Version header names a version the API does not serve, or the organisation is pinned to one it no longer serves. |
| 401 | authentication_required | Authentication required. The request carries no credential, or one that cannot be accepted: malformed, unknown, revoked, expired, or used from an address outside its key's access policy. Every refused credential gets this same answer, so a probe learns nothing. |
| 401 | invalid_token | Invalid token. The credential is not valid. It is listed on every authenticated operation, but the API currently answers authentication_required for every refused credential, so handle the two alike. |
| 402 | payment_declined | The payment was declined. Stripe declined the card charged for a subscription or plan change, or the card needs its holder (3-D Secure) and no browser was there to confirm it. Stripe's own decline code is in errors[]. Nothing was charged and the plan did not change. |
| 403 | permission_denied | Permission denied. The credential is valid, but it is not allowed to do this: the key lacks the scope the operation needs (the API reference names it under each operation), or a member's role does not allow it. A cross-site browser request to a write is refused this way too. |
| 403 | insufficient_scope | Insufficient scope. The credential lacks a scope the operation needs beyond its own. Starting an export (POST /v1/exports) answers it when the key holds exports:write but not products:read, since an export reads products for its caller. A missing operation scope is answered with permission_denied, so handle the two alike. |
| 403 | support_token_invalid | The support token does not fit. A FurniOps support conversation was sent a support token that does not fit: it does not verify, it was opened by another staff member, it names another organisation or member than the conversation, or it was not opened for FurniOps AI. Answered only to FurniOps' own staff tools, never to an API key or a member's sign-in. |
| 401 | support_renewal_required | Renew the support token. Less time is left on the support token than one FurniOps AI turn needs. Answered only to FurniOps' own staff tools, never to an API key or a member's sign-in. |
| 403 | feature_unavailable | Not included in the plan. The organisation's plan does not include the feature the request asks for: bulk exports (POST /v1/exports), or fat webhooks (creating a webhook endpoint with payload: "fat", or switching one to it). Nothing was changed. |
| 403 | plan_limit_reached | Plan limit reached. The organisation is at a limit of its plan, such as its seats or its API keys. |
| 403 | plan_required | A paid plan is needed for live mode. The request was made in live mode, and the organisation's plan does not include live mode. Test mode is free on every plan; organisation, billing, audit, usage and request-log routes answer in live mode on any plan. |
| 422 | preview_unsupported | This operation cannot be previewed. The write was sent with FurniOps-Dry-Run, and this operation cannot be previewed: it reaches a payment provider, a model or another service whose answer cannot be known without asking it. detail says why. Nothing was changed. |
| 422 | undo_unavailable | This change cannot be undone. The audit entry has nothing that can be put back: its write cannot be undone, it is older than the undo window, or it was made in FurniOps sign-in (members, invitations, API keys), whose own audit route undoes it. Nothing was changed. |
| 403 | test_mode_required | Test mode only. A test-mode-only operation, such as POST /v1/test/events, was called with a live key. |
| 404 | not_found | Not found. No such resource. A resource that belongs to another organisation, and an ID with the wrong prefix for the route, answer the same way. |
| 405 | method_not_allowed | Method not allowed. The path exists, but not with this HTTP method. |
| 406 | not_acceptable | Not acceptable. The Accept header asks for a media type the operation cannot send. |
| 409 | conflict | Conflict. The request conflicts with the resource's current state, for example acting on a run that is still active, or starting a fourth export while three are still pending or running in the same mode. |
| 409 | agreement_required | Accept the data agreement first. The organisation publishes its catalogue to the FurniOps network, and has not accepted the current data agreement, so it cannot publish or write records to a published catalogue. Nothing was changed. |
| 409 | brand_unverified | Verify the brand first. Publishing a catalogue to the FurniOps network needs a verified brand, and the organisation's brand is not verified yet. Nothing was published. |
| 409 | memory_limit | Too many memories. FurniOps AI already keeps the most memories allowed for this scope (25 for a person, 25 for the organisation). |
| 409 | idempotency_request_in_progress | A request with this Idempotency-Key is still being processed. A request with the same Idempotency-Key is still being processed. |
| 412 | precondition_failed | Precondition failed. The If-Match ETag no longer matches: the resource changed after you read it. |
| 413 | payload_too_large | Request body too large. The request body is larger than the operation accepts. |
| 415 | unsupported_media_type | Unsupported media type. The body is not sent as application/json. |
| 422 | unprocessable | Request violates a domain rule. The request is well formed but breaks a domain rule, for example an invalid combination of fields. |
| 422 | idempotency_key_reused | Idempotency-Key reused with a different request. The Idempotency-Key was already used, within 24 hours, for a different request (method, URL or body). |
| 422 | currency_unsupported | Credit is not sold in this currency. FurniOps AI credit is not sold in the organisation's billing currency yet. Nothing was charged. |
| 422 | model_unpriced | The model has no price. An AI model without a price for today cannot serve organisations, so it cannot be routed to an organisation's purpose or lose its price while it serves one. Answered only to FurniOps' own staff tools, never to an API key or a member's sign-in. |
| 422 | price_notice_too_short | A price rise needs 30 days' notice. A change would raise the list price organisations pay for a model they use sooner than 30 days ahead. Answered only to FurniOps' own staff tools, never to an API key or a member's sign-in. |
| 422 | support_token_required | The support token is required. A FurniOps support conversation was called without the support token in the FurniOps-Support-Token header. Answered only to FurniOps' own staff tools, never to an API key or a member's sign-in. |
| 428 | precondition_required | Precondition required. The operation requires If-Match (the ETag of your last read) and none was sent. |
| 429 | rate_limited | Too many requests. The budget for this kind of request is spent. The RateLimit and RateLimit-Policy headers describe the budget, and Retry-After says when to try again. |
| 429 | quota_exceeded | Monthly quota used up. A monthly quota of the organisation's plan is used up in live mode, and this metered action is refused until the month ends (UTC): API calls, the source runs you start, or exported rows. Retry-After gives the seconds until the new month. Reading the usage itself is never refused, and test mode is never capped. |
| 429 | ai_budget_reached | Today's AI budget is spent. Today's FurniOps AI spend is used up: the person's daily budget, the organisation's daily budget, or the day's included allowance with no credit left to spend. detail says which. It resets at midnight UTC. |
| 500 | internal | Internal error. Something failed on our side. No internals are exposed. |
| 503 | unavailable | Service unavailable. The service, or a dependency it needs for this request, is unavailable for the moment. |
| 503 | overloaded | Server busy, retry later. The server is shedding load: under pressure it refuses the lowest-priority requests first (starting bulk exports, then expensive work and new streams, then reads on the standard tier) so that the rest keep being served. Other writes are never shed. |
Sign-in codes
These come from signing in to FurniOps (the dashboard and the CLI's furniops login), not from
API calls with a key:
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_credentials | Invalid credentials. An email and password (or another sign-in factor) did not match. |
| 403 | reauthentication_required | Re-authentication required. The action is sensitive and the session's sign-in is too old for it. |
| 403 | sso_required | Single sign-on required. The organisation requires single sign-on, and this sign-in did not use it. |
| 409 | interaction_superseded | A newer authorization request replaced this one. A newer sign-in request replaced this one, in another tab or window. |
| 409 | account_mismatch | Signed in as a different account. The browser is signed in as a different account from the one this flow is for. |
New codes can appear
New error codes are additive changes: they can be added without a new API version. Handle an
unknown code by its status, as the SDK's classes do.