> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors & scopes

> The error classes the kit throws, the scopes a publishable key can carry, and the campaign storefront refusals.

<Info>
  This page is the curated reference. The complete, generated type reference lives at [/kit/api/core](/kit/api/core) and is regenerated from the published typings.
</Info>

## Error classes

Everything the kit throws extends `ShopkitError`, so one `catch` can narrow. All of them are exported from `@quickbutik/kit`, `@quickbutik/kit/sdk` and, on a script-tag page, `window.Quickbutik`.

| Class | Thrown when | Members |
| - | - | - |
| `ShopkitError` | Base class | `message` |
| `ShopkitConfigError` | A malformed key or a `qb_pat_` token, a missing `fetch`, no provider or shop above a component, nothing to check out, no numeric store id for `hostedUrl()`, a conflicting campaign storefront, a malformed currency code (anything but three letters in `currency`, `setCurrency()` or a per-call `currency`), a platform with no checkout host for the shop | |
| `ShopkitScopeError` | A call the declared scopes do not cover. Thrown **before** the request | `required`, `declared` |
| `ShopkitApiError` | Any non-2xx response | `status`, `body`, `details`, `retryable` |
| `ShopkitNetworkError` | No response at all: offline, DNS, abort, timeout | `cause` |

<ResponseField name="ShopkitApiError.status" type="number">The HTTP status.</ResponseField>
<ResponseField name="ShopkitApiError.body" type="unknown">The parsed response body.</ResponseField>
<ResponseField name="ShopkitApiError.details" type="unknown">The machine-readable half, normalised across the platform's error envelopes (its `details`, `context` and validation `errors` all land here). For example which lines are out of stock on a 409.</ResponseField>
<ResponseField name="ShopkitApiError.retryable" type="boolean">True for 408, 429 and 5xx.</ResponseField>

```ts theme={null}
import { ShopkitApiError, ShopkitNetworkError, ShopkitScopeError } from "@quickbutik/kit"

try {
  await shopkit.cart.add({ productId })
} catch (error) {
  if (error instanceof ShopkitScopeError) console.error(error.required, error.declared)
  else if (error instanceof ShopkitApiError && error.status === 409) showOutOfStock(error.details)
  else if (error instanceof ShopkitNetworkError) showOffline()
  else throw error
}
```

<Note>
  A **well-formed** currency the shop does not offer is not an error: the platform answers in the shop's own currency, so `product.currency` can differ from `shopkit.currency`. Only a malformed code throws, locally, before any request. See [Currencies](/kit/concepts/currencies).
</Note>

## Missing is not an error

These resolve to `null` on 404 or 410 instead of throwing, because a remembered id that has expired is a normal thing for a storefront to meet:

* `products.get`, `products.getBySlug`
* `categories.get`
* `cart.get`, `cart.current`
* `checkout.getSession`, `checkout.syncCart`

The cart store (`CartStore`, `useCart`, the cart elements) captures mutation errors into its snapshot's `error` instead of throwing, so a failed "add to cart" surfaces in the UI rather than in an unhandled rejection.

## Scopes

A publishable key carries scopes. The kit checks them **locally** before a request leaves the process, so a missing scope is a named `ShopkitScopeError` instead of an opaque 403. The platform enforces them again either way.

| Scope | Grants | Needed by |
| - | - | - |
| `products:read` | Products, variants, images **and categories** | `products.*`, `categories.*` |
| `cart:read` | Read a cart | `cart.get`, `cart.current` |
| `cart:write` | Create carts and add, update or remove items | `cart.create`, `addItem`, `updateItem`, `removeItem`, `delete`, `ensure`, `add`, `clear` |
| `checkout:read` | Read a session and poll the confirmation, **and `shop.get()`** | `checkout.getSession`, `confirmation`, `pollConfirmation`, `shop.get` |
| `checkout:write` | Create and patch checkout sessions | `checkout.createSession`, `start`, `buyNow`, `mount`, `syncCart` |
| `storefront:read` | Pages, navigation menus, theme content | Raw API calls only |

`DEFAULT_SHOPKIT_SCOPES` is the first five. `start()` and `buyNow()` also read the cart (`cart:read`), and write one (`cart:write`) when none is remembered.

Two mappings surprise people and both are deliberate: **categories sit behind `products:read`**, and **`shop.get()` needs `checkout:read`**, because it is served by the checkout's shop endpoint.

Pass `scopes: null` when the app genuinely does not know the key's scopes; only the friendly local error is lost.

```ts theme={null}
shopkit.scopes.declared            // string[]
shopkit.scopes.enforced            // boolean
shopkit.scopes.has("cart:write")   // boolean
```

A publishable key can never carry merchant scopes. The platform refuses to mint one with them, and refuses a key presenting them.

## Campaign storefront refusals

A checkout started for a campaign storefront can be refused with a machine-readable reason. `storefrontRefusal(error)` decodes it, matching on `details.reason` rather than the HTTP status, and returns `null` for anything else, so it is safe to call on whatever was thrown.

```ts theme={null}
type StorefrontRefusal =
  | { reason: "storefront_not_live"; storefrontId: string | null; effectiveState: string }
  | {
      reason: "storefront_quantity_limit"
      storefrontId: string | null
      limitType: "per_order" | "pool"
      productId: string | null
      variantId: number | null
      maxQuantity: number | null
    }
  | { reason: "storefront_mismatch"; storefrontId: string | null; cartStorefrontId: string | null }
```

| Reason | Status | Meaning | What to do |
| - | - | - | - |
| `storefront_not_live` | 409 | The campaign is `paused`, `expired` or `coming_soon` (rarely `draft` or `archived`), in `effectiveState` | Tell the shopper the campaign is closed |
| `storefront_quantity_limit` | 409 | `per_order`: reduce the line to `maxQuantity`. `pool`: the campaign's stock is down to `maxQuantity` (0 means sold out) | Show the limit on the offending line |
| `storefront_mismatch` | 400 | The checkout named one campaign but the cart was created for another | A programming error: bind the client, or check out a cart created for the campaign |

See [Campaign storefronts](/kit/concepts/campaign-storefronts).

## Common platform errors

| Error | Cause |
| - | - |
| `400 "successUrl must use https"` | `http` on a host that is not loopback, or a non-http scheme |
| `400 "successUrl is not a valid URL"` | A relative path passed to the SDK. Pass `${origin}/success` |
| `400 "Cannot hand off an empty cart to the checkout"` | `start()` without `cartId` on a client that remembers no cart |
| `400 "Invalid limit value…"` | `limit` outside 1 to 200 |
| `401` on every call | A revoked or rotated key, or an `apiUrl` override pointing elsewhere |
| `403` with `required_scopes` | The key lacks the scope. Use the key from the admin's Custom storefront view |

More in [Troubleshooting](/kit/troubleshooting).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.