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. 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
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.

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_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
  • a missing Idempotency-Key on a subscription write, where the header is required rather than optional
  • on a subscription write: a next_billing_at that isn’t a future ISO 8601 timestamp within five years, a variant_id that isn’t a variant GID, a PATCH on a line with nothing to change, or a Shopify refusal that names a field you sent
  • 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.

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

CodeStatus
missing_credential401
invalid_credential401
credential_revoked401
credential_expired401
shop_unavailable401
insufficient_scope403
forbidden403 · Customer API only
plan_upgrade_required403
not_found404
method_not_allowed405
invalid_request400
export_in_progress409
state_conflict409
idempotency_conflict409
selection_closed409 · Customer API only
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 "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:

  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