# Responses and errors

URL: /docs/concepts/errors

The success and error envelopes, every error code with its HTTP status and what to do, the shape of validation errors, and which errors to retry.

Every response from the API has the same outer shape, and every error has a stable `code` you can branch on. Branch on `error.code`, not on the HTTP status or the message: several codes share a status, and messages can be reworded.

## Success [#success]

A successful response wraps its result in `data`:

```json title="200 OK"
{ "data": { "id": 101, "name": "Oak desk lamp", "slug": "oak-desk-lamp" } }
```

Lists add `links` and `meta` for [pagination](/docs/concepts/pagination). Some responses add a `meta` object with extra facts, such as `meta.order_access_token` after a guest checkout or `meta.shipping_option_cleared` after an address change.

- Times are ISO 8601, in UTC.
- Money is always a [money object](/docs/concepts/money), never a bare number.
- New fields can appear at any time, so ignore fields you do not know. See [versioning](/docs/concepts/versioning).

## Errors [#errors]

Every error uses one envelope:

```json title="422 Unprocessable Content"
{
  "error": {
    "code": "COUPON_NOT_APPLICABLE",
    "message": "Spend £20.00 more to use this code.",
    "details": {
      "code": ["Spend £20.00 more to use this code."],
      "reason": "threshold",
      "remaining": { "amount": 2000, "currency": "GBP", "formatted": "£20.00" }
    }
  }
}
```

| Field | Meaning |
|---|---|
| `code` | A stable, upper-case code. Always present. |
| `message` | A sentence that names the problem and, where it can, the fix. Safe to log. Present on almost every error; a few store-identification errors carry only the code. |
| `details` | Extra facts for that code, when there are any. |

Every response also carries an `X-Request-Id` header. Log it, and quote it if you contact support about a request.

## Validation errors [#validation-errors]

`422 VALIDATION_FAILED` means the request was understood but some input was refused. `details` maps each refused field to a list of messages. Nested fields use dots:

```json title="422 Unprocessable Content"
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The billing.postcode field is required. (and 1 more error)",
    "details": {
      "billing.postcode": ["The billing.postcode field is required."],
      "billing.country_id": ["The billing.country_id field is required."]
    }
  }
}
```

Show each message next to its field. A few business checks use `VALIDATION_FAILED` too, under a key that is not a request field: for example, an item that has run out of stock at checkout is reported under `cart`.

## Error codes [#error-codes]

### Store and access [#store-and-access]

| Status | `code` | Meaning | What to do |
|---|---|---|---|
| 400 | `TENANT_KEY_MISSING` | No `X-Tenant-Key` header | Send the store key on every call |
| 404 | `TENANT_NOT_FOUND` | Unknown key, a suspended store, or a rotated key after its overlap | Use the store's current key |
| 402 | `TENANT_PAYMENT_REQUIRED` | The store is temporarily unavailable | Show a "shop temporarily unavailable" page. Do not retry the request. |
| 403 | `ORIGIN_NOT_ALLOWED` | A browser call from a website the store has not registered | Register the website, or call from your server. See [stores and keys](/docs/concepts/stores-and-keys). |
| 403 | `BROWSER_CALLS_DISABLED` | A browser call while browser calls are switched off | Call from your server with the secret |
| 403 | `TENANT_SECRET_REQUIRED` | A server call without `X-Tenant-Secret` | Send the secret on every server call |
| 403 | `TENANT_SECRET_INVALID` | A wrong secret, or a secret-only route called without it | Use the current secret from Settings → Storefront API |
| 401 | `UNAUTHENTICATED` | A missing, expired or revoked bearer token | Sign the customer in again, or check the merchant API key |
| 403 | `FORBIDDEN` | The token is valid but not allowed to do this, for example a merchant API key without the ability | Use a key with the right ability |
| 404 | `FEATURE_NOT_ENABLED` | The store's plan does not include this feature, such as the shop | Hide the feature for this store |
| 404 | `MODULE_NOT_ENABLED` | A module, such as live chat, is not switched on for the store | Hide the feature for this store |

### Requests [#requests]

| Status | `code` | Meaning | What to do |
|---|---|---|---|
| 422 | `VALIDATION_FAILED` | Some input was refused | Show `details` next to the fields |
| 404 | `NOT_FOUND` | The resource does not exist, or is not visible to this caller | Check the id, slug or reference |
| 400 and others | `HTTP_ERROR` | A request the API could not accept, such as an unknown filter, sort or include, or the wrong HTTP method; it keeps its real status | Fix the request |
| 409 | `IDEMPOTENCY_IN_PROGRESS` | A request with this `Idempotency-Key` is still running | Retry shortly with the same key |
| 422 | `IDEMPOTENCY_KEY_REUSED` | This key was already used for a different request | Make a new key for a new action. See [idempotency](/docs/concepts/idempotency). |
| 429 | `RATE_LIMITED` | Too many requests | Wait for `Retry-After`. See [rate limits](/docs/concepts/rate-limits). |
| 429 | `STAGING_LIMIT` | A staging store (where your plan includes them) has used its daily allowance | Wait for `Retry-After`, the end of the UTC day |
| 500 | `SERVER_ERROR` | Something went wrong on our side | Retry with backoff. Quote `X-Request-Id` if it persists. |
| 503 | `CAPTCHA_UNAVAILABLE` | A form's spam check could not get an answer | Retry the submission with the same `Idempotency-Key` |

### Carts [#carts]

| Status | `code` | Meaning | What to do |
|---|---|---|---|
| 404 | `CART_NOT_FOUND` | The cart token is unknown, expired, or its cart became an order | Create a new cart |
| 404 | `CART_LINE_NOT_FOUND` | The line id is not in this cart | Refresh the cart |
| 422 | `COUPON_INVALID` | No such code | Show `message` |
| 422 | `COUPON_EXPIRED` | The code has not started or has ended (`details.reason`) | Show `message` |
| 422 | `COUPON_USED_UP` | The code has reached its usage limit | Show `message` |
| 422 | `COUPON_LIMIT_REACHED` | This shopper has used the code as often as allowed | Show `message` |
| 422 | `COUPON_NOT_APPLICABLE` | Nothing in the basket qualifies (`details.reason`, and `details.remaining` for a spend threshold) | Show `message` |
| 422 | `GIFT_LINE_LOCKED` | A free gift's quantity cannot be changed | Let the shopper remove it instead |
| 422 | `ADDRESS_NOT_FOUND` | A saved `address_id` that is not the customer's, or sent without signing in (`details.reason` is `not_found` or `sign_in_required`) | Refresh the address book, or ask the shopper to sign in or type the address |
| 422 | `SHIPPING_ADDRESS_REQUIRED` | Shipping options were asked for before a shipping address was set | Send the shopper to the address step |

### Checkout and payments [#checkout-and-payments]

| Status | `code` | Meaning | What to do |
|---|---|---|---|
| 402 | `PAYMENT_FAILED` | The payment was declined or not taken; `details.decline_code` says why on a provider decline | See [payments](/docs/concepts/payments#declined-payments) |
| 409 | `SHIPPING_OPTION_CHANGED` | The chosen shipping option is gone or its price changed | Show `details.options` and let the shopper choose again |
| 409 | `PROMOTIONS_CHANGED` | The basket's discounts changed since payment was set up | Show the new total from `details.cart` and set up payment again |
| 409 | `PAYMENT_REFUNDED` | The payment was taken but the order could not be placed, so it was refunded | Start a new checkout |
| 409 | `PAYMENT_REFUND_PENDING` | As above, with the refund still being retried | Do not ask the shopper to pay again for this cart |
| 409 | `PAYMENT_ALREADY_USED` | The payment already paid for another checkout | Set up a new payment |
| 409 | `PAYMENT_SETUP_IN_PROGRESS` | Another request is setting up this cart's PaymentIntent | Retry shortly |
| 422 | `PAYMENT_GATEWAY_UNAVAILABLE` | The gateway is unknown or not set up for this store | Only offer gateways from `checkout/config` |
| 403 | `ORDER_ACCESS_TOKEN_INVALID` | A guest order token is missing, tampered with or expired (`details.reason` is `invalid` or `expired`) | Ask for the order reference and email again |

### Accounts, content and CRM [#accounts-content-and-crm]

| Status | `code` | Meaning | What to do |
|---|---|---|---|
| 409 | `ACCOUNT_SHARED` | The account shares its customer record, so only the store can erase it | Ask the shopper to contact the store |
| 403 | `PREVIEW_TOKEN_INVALID` | A page preview link is invalid or expired (`details.reason`) | Ask the editor for a new preview link |
| 422 | `CONSENT_CONFIRMATION_INVALID` | A marketing consent confirmation link or code could not be confirmed | Ask the person to request a new confirmation |
| 404 | `CONVERSATION_NOT_FOUND` | The chat token does not match a conversation | Start a new conversation |
| 409 | `CONTACT_EXISTS` | A CRM contact with this email already exists; `details.id` names it | Update that contact instead |
| 409 | `ORDER_TRANSITION_NOT_ALLOWED` | An order status change the order's current state does not allow | Check the order's state first |
| 409 | `REFUND_NOT_ALLOWED` | A refund the order's payments do not allow | Check the amount and the order's payments |

## Which errors to retry [#which-errors-to-retry]

| Retry | Errors |
|---|---|
| Yes, after a short wait, with the same `Idempotency-Key` | `500 SERVER_ERROR`, network errors and timeouts, `409 IDEMPOTENCY_IN_PROGRESS`, `409 PAYMENT_SETUP_IN_PROGRESS`, `503 CAPTCHA_UNAVAILABLE` |
| Yes, after `Retry-After` | `429 RATE_LIMITED`, `429 STAGING_LIMIT` |
| Only after the shopper changes something | `402 PAYMENT_FAILED`, the `409` checkout conflicts, `422` errors |
| No | `400`, `401`, `403` and `404` errors: the same request gets the same answer |

Use exponential backoff with jitter for automatic retries, and stop after a few attempts.

```ts title="Branch on error.code"
const res = await fetch(url, init)

if (!res.ok) {
  const { error } = await res.json()
  switch (error.code) {
    case 'VALIDATION_FAILED':
      return showFieldErrors(error.details)
    case 'CART_NOT_FOUND':
      return startNewCart()
    case 'RATE_LIMITED':
      return retryAfter(Number(res.headers.get('Retry-After') ?? 1))
    default:
      console.error(error.code, error.message, res.headers.get('X-Request-Id'))
      return showGenericError()
  }
}
```
