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 | |
|---|---|
code | Branch on this. It is part of the versioned contract |
message | Human copy. It may be reworded at any time — never parse it |
doc_url | A deep link to the section of this page describing the code |
request_id | Also 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_afterthat 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
eventsarray 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
404 — Reserved. 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
| Code | Status |
|---|---|
missing_credential | 401 |
invalid_credential | 401 |
credential_revoked | 401 |
credential_expired | 401 |
shop_unavailable | 401 |
insufficient_scope | 403 |
plan_upgrade_required | 403 |
not_found | 404 |
method_not_allowed | 405 |
invalid_request | 400 |
export_in_progress | 409 |
invalid_cursor | 400 |
unsupported_api_version | 404 · reserved |
rate_limited | 429 |
internal_error | 500 |
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:
- Log
request_idon every failure. It is the only thing that lets us find your call. - Retry
429and5xxonly. Retrying a403in a loop just burns your rate budget. - Distinguish “broken” from “off”.
credential_revokedandplan_upgrade_requiredmean a human has to act; retrying them forever hides an outage that someone could fix in a minute.