# Errors and Rate Limits

The storefront API signals failures with conventional HTTP status codes and small JSON bodies. This page catalogs every error envelope the API produces, explains the validation error shape, documents the rate limit, and defines the pagination envelope used by all list endpoints. Endpoint reference pages link here instead of repeating these shapes.

All error bodies are JSON objects. Most carry a `message` field; two special cases differ and are called out below (the module-disabled error uses an `error` key, and the suspension error adds a machine-readable `code`).

## 401 Unauthenticated

Returned when the `Authorization: Bearer {api_key}` header is missing, malformed, or carries a revoked key. Also returned when the header uses a scheme other than `Bearer`.

```json title="Response"
{
  "message": "Unauthenticated."
}
```

Fix: send a valid, unrevoked API key as `Authorization: Bearer {api_key}`. Verify the key with `GET /v1/api/me`.

## 403 Forbidden

Three distinct conditions return 403. Distinguish them by the body shape.

### Tenant suspended

The store the key belongs to is suspended by Borkol. Every `/v1/api` request fails with this body until the suspension is lifted. The `code` field is stable; branch on `code === "tenant_suspended"`.

```json title="Response"
{
  "message": "Tenant suspended.",
  "code": "tenant_suspended"
}
```

### Missing scope

The key is valid but does not carry the scope the endpoint requires (for example calling a `commerce:write` endpoint with a key that only has `commerce:read`).

```json title="Response"
{
  "message": "Invalid ability provided."
}
```

Fix: create a key with the required scope in the client dashboard (Settings > API keys). Scopes cannot be added to an existing key.

### Module not enabled

The route belongs to a module that is not enabled for the store. This error uses an `error` key, not `message`, and wins over the scope check: it is returned even when the key carries the module's scope. `{key}` is the module key: `commerce`, `stock`, `customers`, or `objects`.

```json title="Response"
{
  "error": "Module not enabled: commerce"
}
```

Fix: module enablement is managed by Borkol per store; contact Borkol to enable the module.

## 404 Not Found

Returned for unknown routes and for path parameters that do not resolve to an existing record within the store. The body is `{"message": "..."}`; for a missed model lookup the message is Laravel's default, for example:

```json title="Response"
{
  "message": "No query results for model [Borkol\\Commerce\\Models\\Product] 9c4e7f0a-2b31-4d6e-8f0a-1d2c3b4a5e6f"
}
```

Records belonging to a different store are indistinguishable from nonexistent records: both return 404, never 403.

## 422 Validation error

Returned when a request body or query parameter fails validation. The shape is always:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | string | always | The first validation error message (a human-readable summary). |
| `errors` | object | always | Map of field name to an array of one or more error message strings for that field. Nested fields use dot notation (for example `lines.0.quantity`). |

```json title="Response"
{
  "message": "The email field is required.",
  "errors": {
    "email": ["The email field is required."],
    "lines.0.quantity": ["The lines.0.quantity field must be at least 1."]
  }
}
```

Each endpoint's reference page lists its fields with types, required flags, and validation rules; the 422 body always follows this envelope.

## 429 Too Many Attempts (rate limiting)

All `/v1/api` routes share one rate limit of **120 requests per minute per store**. The limit is keyed by the store (tenant), not by API key or IP: all keys of one store draw from the same budget. Unauthenticated requests to rate-limited routes are keyed by client IP. `GET /v1/ping` is not subject to this limit.

Every response on a rate-limited route includes:

| Header | Description |
| --- | --- |
| `X-RateLimit-Limit` | The per-minute budget (120). |
| `X-RateLimit-Remaining` | Requests left in the current window. |

When the budget is exhausted the API returns HTTP 429 with a `Retry-After` header (seconds until the window resets):

```json title="Response"
{
  "message": "Too Many Attempts."
}
```

Clients should watch `X-RateLimit-Remaining`, back off before hitting zero, and honor `Retry-After` on 429. For catalog-heavy storefronts, cache reads on your side rather than proxying every page view to the API.

## Pagination envelope

Every list endpoint in the storefront API returns the same envelope: a `data` array plus `links` and `meta` objects. This is documented once here; endpoint pages describe only the shape of the objects inside `data`.

List endpoints accept these query parameters:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer | No | Page number, 1-based. Defaults to 1. |
| `per_page` | integer | No | Items per page. Each endpoint documents its default and maximum (commonly default 25, maximum 100); values outside the allowed range are clamped. |

The envelope:

| Name | Type | Description |
| --- | --- | --- |
| `data` | array | The page of resources. |
| `links.first` | string | URL of the first page. |
| `links.last` | string | URL of the last page. |
| `links.prev` | string or null | URL of the previous page, null on the first page. |
| `links.next` | string or null | URL of the next page, null on the last page. |
| `meta.current_page` | integer | Current page number. |
| `meta.from` | integer or null | 1-based index of the first item on this page, null when the page is empty. |
| `meta.last_page` | integer | Total number of pages. |
| `meta.links` | array | Paginator link objects (`url`, `label`, `active`) for building page controls. |
| `meta.path` | string | Base URL of the endpoint without pagination parameters. |
| `meta.per_page` | integer | Effective page size. |
| `meta.to` | integer or null | 1-based index of the last item on this page, null when the page is empty. |
| `meta.total` | integer | Total number of matching items. |

```json title="Response"
{
  "data": [
    { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" }
  ],
  "links": {
    "first": "https://api.borkol.com/v1/api/commerce/products?page=1",
    "last": "https://api.borkol.com/v1/api/commerce/products?page=4",
    "prev": null,
    "next": "https://api.borkol.com/v1/api/commerce/products?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 4,
    "links": [
      { "url": null, "label": "&laquo; Previous", "active": false },
      { "url": "https://api.borkol.com/v1/api/commerce/products?page=1", "label": "1", "active": true }
    ],
    "path": "https://api.borkol.com/v1/api/commerce/products",
    "per_page": 25,
    "to": 25,
    "total": 87
  }
}
```

Endpoints that return a single object (such as `GET /v1/api/me`) return the bare JSON object with no `data` wrapper unless their reference page says otherwise.

## Error handling summary for clients

| Status | Detect by | Action |
| --- | --- | --- |
| 401 | `message == "Unauthenticated."` | Key missing or revoked; obtain a valid key. Not retryable. |
| 403 | `code == "tenant_suspended"` | Store suspended; stop calling until resolved. |
| 403 | `message == "Invalid ability provided."` | Key lacks the scope; issue a key with the right scopes. Not retryable. |
| 403 | body has `error` starting with `"Module not enabled:"` | Module disabled for the store. Not retryable. |
| 404 | `message` present | Route or record does not exist in this store. |
| 422 | `errors` object present | Fix the listed fields and resend. |
| 429 | `message == "Too Many Attempts."` | Wait `Retry-After` seconds, then retry. |
| 5xx | any | Server-side failure; retry with exponential backoff. |
