API errors
Every failure from the Curobi API comes back in one envelope, whatever went wrong. One envelope serves all three surfaces, so this page is also the error reference for the Storefront API and the Customer API; the two codes only those surfaces raise are marked below.
{
"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.
forbidden
403 — (Customer API only.) The session is valid and the resource exists, but it belongs to another customer.
Distinct from insufficient_scope, which is about what a key may touch. No re-scoping of any
credential changes this answer: a shopper’s session reaches that shopper’s own subscriptions and
nothing else. See the Customer API.
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 takes a write at exactly three places: the
subscription writes under write:subscriptions,
/webhooks/endpoints under manage:webhooks, and POST /exports — which queues a read.
Everywhere else, a POST, PUT, PATCH or DELETE lands here.
The plan, bundle, Mix & Match, gift and win-back writes named in the scope vocabulary are not
built. Their scopes are not offered when you mint a key, and a POST or PATCH aimed at one of
those resources is refused.
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
- a missing
Idempotency-Keyon a subscription write, where the header is required rather than optional - on a subscription write: a
next_billing_atthat isn’t a future ISO 8601 timestamp within five years, avariant_idthat isn’t a variant GID, aPATCHon a line with nothing to change, or a Shopify refusal that names a field you sent - 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.
state_conflict
409 — Shopify refused a subscription write because of the contract’s current state.
Pausing one that is already cancelled, resuming one that was never paused, skipping one with no upcoming charge, editing a line on a cancelled contract.
Distinct from invalid_request because the remedy differs in kind: you do not fix your body, you
re-read the contract and decide whether you still want to. The split is mechanical on our side — a
Shopify error that names a field becomes invalid_request, one that does not becomes this.
idempotency_conflict
409 — An Idempotency-Key that can’t be honoured.
Either it was already spent on a different request, or its first request is still in flight. A key identifies one request, matched on method, path and body.
This is the one failure a retrying client must not retry blindly. Look at what the first request did before you decide. Minting a new key is how you say I looked at the contract first — see Idempotency.
selection_closed
409 — (Customer API only.) A build-a-box pick that arrived past the plan’s selection cutoff, or before next period’s menu was published.
Same reasoning as export_in_progress: the request was fine, the timing was not. Branch on it by
telling the customer when they can edit, not by fixing the 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 |
forbidden | 403 · Customer API only |
plan_upgrade_required | 403 |
not_found | 404 |
method_not_allowed | 405 |
invalid_request | 400 |
export_in_progress | 409 |
state_conflict | 409 |
idempotency_conflict | 409 |
selection_closed | 409 · Customer API only |
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 "idempotency_conflict":
// Never retry blindly: the first request may already have moved a charge.
// Re-read the contract, then decide — with a *new* key if you still want it.
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.