FurniOpsDocs

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"
}
FieldAlwaysMeaning
codeyesThe machine-readable error code. Branch on this.
statusyesThe HTTP status, repeated for convenience.
titleyesA short summary of the code.
typeyesA URL identifying the code: its page in these docs.
doc_urlyesThe same page, under a name that says what it is.
request_idyesThis request's ID, also sent as the Request-Id header on every response. Quote it to support.
detailnoWhat went wrong this time, in words.
errorsnoFor a request that failed validation, each offending field: its dotted path, a machine-readable code and a message.
instancenoThe 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:

errors.ts
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);
  }
}
ClassWhen
AuthenticationError401
PermissionError403: a missing scope, or a feature the plan does not include
NotFoundError404
ConflictError409, 412
ValidationError400, 413, 415, 422, 428
RateLimitError429: rate_limited, or quota_exceeded for a spent monthly quota
ApiErrorany other error status, such as 500 or 503
ConnectionErrorno 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

AnswerRetry?
429 rate_limitedYes, after Retry-After. See Rate limits.
429 quota_exceededNot before Retry-After, which is the end of the month (UTC). See Usage and quotas.
503 overloadedYes, after Retry-After (2 to 10 s).
503 unavailable, 500 internalYes, with backoff. A POST is safe to retry with the same idempotency key.
409 idempotency_request_in_progressYes, after Retry-After: you then get the first request's answer.
412 precondition_failedRead the object again, then retry with the new ETag.
403 feature_unavailableNo: the plan does not include the feature. See Plans and features.
A network error or timeoutYes, with the same idempotency key.
Any other 4xxNo. 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.

StatusCodeMeaning
400invalid_requestInvalid 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).
400idempotency_key_invalidIdempotency-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).
400unsupported_versionUnsupported 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.
401authentication_requiredAuthentication 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.
401invalid_tokenInvalid 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.
402payment_declinedThe 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.
403permission_deniedPermission 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.
403insufficient_scopeInsufficient 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.
403support_token_invalidThe 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.
401support_renewal_requiredRenew 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.
403feature_unavailableNot 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.
403plan_limit_reachedPlan limit reached. The organisation is at a limit of its plan, such as its seats or its API keys.
403plan_requiredA 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.
422preview_unsupportedThis 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.
422undo_unavailableThis 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.
403test_mode_requiredTest mode only. A test-mode-only operation, such as POST /v1/test/events, was called with a live key.
404not_foundNot 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.
405method_not_allowedMethod not allowed. The path exists, but not with this HTTP method.
406not_acceptableNot acceptable. The Accept header asks for a media type the operation cannot send.
409conflictConflict. 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.
409agreement_requiredAccept 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.
409brand_unverifiedVerify 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.
409memory_limitToo many memories. FurniOps AI already keeps the most memories allowed for this scope (25 for a person, 25 for the organisation).
409idempotency_request_in_progressA request with this Idempotency-Key is still being processed. A request with the same Idempotency-Key is still being processed.
412precondition_failedPrecondition failed. The If-Match ETag no longer matches: the resource changed after you read it.
413payload_too_largeRequest body too large. The request body is larger than the operation accepts.
415unsupported_media_typeUnsupported media type. The body is not sent as application/json.
422unprocessableRequest violates a domain rule. The request is well formed but breaks a domain rule, for example an invalid combination of fields.
422idempotency_key_reusedIdempotency-Key reused with a different request. The Idempotency-Key was already used, within 24 hours, for a different request (method, URL or body).
422currency_unsupportedCredit is not sold in this currency. FurniOps AI credit is not sold in the organisation's billing currency yet. Nothing was charged.
422model_unpricedThe 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.
422price_notice_too_shortA 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.
422support_token_requiredThe 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.
428precondition_requiredPrecondition required. The operation requires If-Match (the ETag of your last read) and none was sent.
429rate_limitedToo 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.
429quota_exceededMonthly 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.
429ai_budget_reachedToday'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.
500internalInternal error. Something failed on our side. No internals are exposed.
503unavailableService unavailable. The service, or a dependency it needs for this request, is unavailable for the moment.
503overloadedServer 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:

StatusCodeMeaning
401invalid_credentialsInvalid credentials. An email and password (or another sign-in factor) did not match.
403reauthentication_requiredRe-authentication required. The action is sensitive and the session's sign-in is too old for it.
403sso_requiredSingle sign-on required. The organisation requires single sign-on, and this sign-in did not use it.
409interaction_supersededA newer authorization request replaced this one. A newer sign-in request replaced this one, in another tab or window.
409account_mismatchSigned 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.

On this page