All errors are RFC 9457 Problem Details, with Content-Type: application/problem+json. No error: {} wrapper and no generic details.
Every type resolves to this page: the final slug is the anchor of its section, like #not-found-404.

The common shape

Six fields, always present:
string
required
Canonical, stable URI of the type. It is the only field worth comparing in code.
string
required
Stable text per type. It doesn’t describe the occurrence.
integer
required
The same HTTP code as the response.
string
required
Describes this occurrence. Don’t parse it: it is not an interface and can change without notice.
string
required
Short alias in snake_case. It is an open string: new codes can appear without breaking an old client. The slug of type is the same code in kebab-case.
string
required
Identifies the occurrence and matches the X-Request-ID header. It goes in every bug report.
string
Optional in RFC 9457, and the API doesn’t emit it.
Every error carries the X-Request-ID header. The 401 adds WWW-Authenticate: Bearer, and the 429 adds Retry-After.

The full catalog

Extensions sit at the root level of the Problem, not nested.

invalid_request — 400

The body or the parameters don’t meet the contract: a required field is missing, an undeclared one is present, or a value doesn’t validate.
array
required
At least one element, with a required code and detail and an optional pointer: an RFC 6901 JSON Pointer to the offending field, starting with /. Use it to flag the wrong input instead of showing a generic message.

unauthorized — 401

You didn’t send Authorization, or the key you sent is not valid. It is the same error for both causes: telling them apart would tell an attacker whether a key exists.
See Keys and security.

forbidden_connection — 403

The key authenticated fine, but the requested connection_id doesn’t belong to that store.
It is 403 and not 404 because the connection travels in the path: the client already knows it asked for it. Catalog resources work the other way: see not_found.

forbidden_scope — 403

The key authenticated fine, but it doesn’t have the operation’s scope: recommendations:read, catalog:read or signals:write. A key doesn’t change scopes: you need another one that has it.

not_found — 404

The resource doesn’t exist, or it exists and belongs to another store. No detail says which of the two it is.
An unexpected 404 with a valid key is almost always another store’s key, not a wrong id.

conflict — 409

The request clashes with the current state: a source_version regression in catalog ingestion. A signal older than the applied one doesn’t overwrite the catalog.
Don’t retry it unchanged: it will fail the same way. It is an event-ordering error, not a transient one.

cart_items_limit_exceeded — 422

The cart in a recommendations request exceeds the configured limit.
integer
required
The limit in effect, 200 by default. It comes in the response so you don’t hardcode it.
integer
required
How many items you sent.

signal_batch_limit_exceeded — 422

A storefront signal batch carries more than allowed.
integer
required
The limit in effect, 50 by default. The SDK already splits batches by this number.
integer
required
How many signals you sent.

rate_limited — 429

The key spent its 60 requests per minute; Retry-After says how many seconds to wait. See Quota and cache.

internal_error — 500

Something broke on the server side. The detail never carries the stack, the internal message or a component’s name.
Here the request_id is essential: it connects what you saw with the server log.

signals_unavailable — 503

The storefront signal store is unavailable. It is transient: retry with backoff. The SDK does it on its own, with the same signal_id, so a retry never counts twice.

How to handle them from the SDK

The SDK translates any of these Problems into a typed CrossUpApiError and preserves the extensions it doesn’t know, so it doesn’t break when the API adds fields.
Use isCrossUpApiError(err) and not err instanceof CrossUpApiError: instanceof fails if there are two copies of the package in the module tree.
Three SDK rules for when something really goes wrong:
  1. The Content-Type is not checked; the shape of the body is. A proxy can rewrite the header, and the error is still typed.
  2. The transport status wins. If the body declares another, the difference is kept in extensions.status: proof that something in the middle rewrites responses.
  3. errorFromResponse never throws. Given HTML, an empty body or JSON that is not a Problem, it returns a synthetic error with code: "unexpected_response", the real status and the raw body.