Skip to Content
Your subscription data now goes where you need it: the Curobi Developer API feeds your warehouse, ERP or CRM, and webhooks tell you the moment something changes — read the API docs →

API errors

Every failure from the Curobi API comes back in one envelope, whatever went wrong.

{ "error": { "code": "insufficient_scope", "message": "This API key is missing the `read:plans` scope.", "doc_url": "https://docs.curobi.com/api/errors#insufficient_scope", "request_id": "req_01K3XQ7N2M8P4R6T9V1Y3B5D7F" } }
Field
codeBranch on this. It is part of the versioned contract
messageHuman copy. It may be reworded at any time — never parse it
doc_urlA deep link to the section of this page describing the code
request_idAlso returned as X-Curobi-Request-Id, on every response — success or failure

request_id is logged on our side and is time-sortable, so quoting one in a support request narrows our search to a range scan rather than a hunt. Include it whenever you report an API problem.

The list is add-only

A new code may appear within /v1; an existing one never changes meaning and is never renamed.

Treat a code you don’t recognise as its HTTP status. That’s the whole contract — a client that throws on an unknown code will break on a release that broke nothing.

Authentication errors

missing_credential

401 — No Authorization: Bearer header was sent.

Check that your HTTP client is actually attaching the header. Some proxies and serverless runtimes strip Authorization unless it’s explicitly forwarded.

invalid_credential

401 — The token resolves to nothing.

Either it isn’t a Curobi key, it was truncated in transit or in an environment variable, or it’s a publishable key (curobi_pk_…), which is never accepted on the Admin API. The message doesn’t say which — naming the kind we expected would confirm to a probe that the key is real.

Mint a fresh key on the API keys page and try again with it directly, no indirection.

credential_revoked

401 — The key exists but was revoked.

Someone revoked it in the admin, or the shop uninstalled Curobi — an uninstall revokes every key for the shop immediately. Mint a new key.

credential_expired

401 — The key exists but is past its expiresAt.

Keys can be minted with an expiry. Mint a replacement; expiries can’t be extended in place, which is the point of setting one.

shop_unavailable

401 — Curobi is no longer installed on the shop.

This is the one check that fails closed: “we can’t reach the shop” and “the merchant still wants this key working” are not the same statement. Reinstalling Curobi does not resurrect old keys — mint new ones.

Authorization errors

insufficient_scope

403 — The key is valid, but doesn’t hold the scope this endpoint requires.

The message names the missing scope. Scopes are matched exactly: no wildcards, no hierarchy, and write: does not imply read:. Scopes can’t be added to an existing key — mint a new one with the right set and retire the old one. See Authentication & scopes.

plan_upgrade_required

403 — The shop’s plan doesn’t include the Developer API.

The API and outbound webhooks are a Pro feature, and the plan is re-read on every request (at most 60 seconds stale) — so a downgrade takes API access offline within the minute rather than at the next key rotation. See Pricing & Plans.

This check sits above the scope check, so a downgraded shop holding a fully-scoped key sees this code, not insufficient_scope.

Request errors

not_found

404 — No such resource, or a GET at a path that isn’t routed.

One generic code covers every resource; the message names which one was being looked for. A bare /v1 with no resource named also lands here.

Worth ruling out before you go looking for a bug: Curobi can only see contracts Curobi created. A subscription another app created is a not_found here by design, not a missing record.

method_not_allowed

405 — A non-GET at a path that exists but takes no write.

v1 is read-only apart from /webhooks/endpoints and POST /exports — and the second of those queues a read. Every other write in the API design maps onto a later phase.

invalid_request

400 — A malformed parameter or body.

The common causes:

  • an updated_after that isn’t a parseable ISO 8601 timestamp — dropped filters return more data than was asked for, so we refuse rather than ignore
  • a body that isn’t valid JSON
  • an events array containing a name that isn’t in the webhook catalogue, including a reserved name that nothing emits yet
  • a webhook endpoint URL that fails validation — not https, credentials in the URL, or a private, loopback, link-local or cloud-metadata address
  • the per-shop cap of ten active webhook endpoints

The message says which. It’s the one code where reading the message is worth it.

export_in_progress

409 — The shop already has a queued or running export job.

A shop may have one export in flight at a time. The message names the job you’re waiting on, so a client can poll that id rather than guessing.

Its own code rather than an invalid_request because the request was fine — the timing was wrong. Branch on it by waiting and retrying, not by fixing your payload.

invalid_cursor

400 — An opaque pagination cursor that doesn’t decode.

Cursors are opaque and belong to the crawl that produced them: don’t construct one, don’t edit one, don’t persist one across a schema change. A bad cursor is an error rather than a silent restart from page one, because a silent restart is a duplicate-import bug that surfaces days later.

Drop the cursor and start the crawl again.

unsupported_api_version

404Reserved. Declared in the contract for the day /v2 exists. Nothing emits it today.

Throttling and server errors

rate_limited

429 — The per-shop token bucket is empty.

The limit is 60 requests/minute sustained, burst 120, per shop — shared across every key the shop has. The response carries Retry-After; wait that long, then retry with jitter.

Successful responses carry X-Curobi-RateLimit-Remaining, so a client that watches its budget and slows down never sees this code. See rate limits.

internal_error

500 — An unrecognised failure on our side.

Degraded deliberately to the envelope, the request id, and nothing else — never a stack trace, never a database message. Retry with backoff; if it persists, send us the request_id and we can find the exact call.

Quick reference

CodeStatus
missing_credential401
invalid_credential401
credential_revoked401
credential_expired401
shop_unavailable401
insufficient_scope403
plan_upgrade_required403
not_found404
method_not_allowed405
invalid_request400
export_in_progress409
invalid_cursor400
unsupported_api_version404 · reserved
rate_limited429
internal_error500

Handling errors well

const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); if (!res.ok) { const { error } = await res.json(); switch (error.code) { case "rate_limited": // Honour Retry-After, then retry with jitter. return retryAfter(res.headers.get("Retry-After")); case "credential_revoked": case "credential_expired": case "plan_upgrade_required": // Not retryable. Page a human — the integration is off until someone acts. return alertOperator(error); case "internal_error": return retryWithBackoff(); default: // Unknown or client-side: log the request id and stop. throw new Error(`${error.code} (${error.request_id})`); } }

Three habits worth building in from the start:

  1. Log request_id on every failure. It is the only thing that lets us find your call.
  2. Retry 429 and 5xx only. Retrying a 403 in a loop just burns your rate budget.
  3. Distinguish “broken” from “off”. credential_revoked and plan_upgrade_required mean a human has to act; retrying them forever hides an outage that someone could fix in a minute.
Last updated on