# Storefront

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## GET /storefront/data/{siteId}

**Get everything a storefront landing page needs**

`operationId: StorefrontController_data`

One round trip that returns the full payload for a storefront home page: site configuration, brands, collections, a product page, the catalog price bounds, the category tree and a featured-products bundle.

Use this instead of calling `/brands`, `/collections`, `/products` and `/categories` separately — it is a single query pass on the server and avoids four round trips from the browser.

Every list-shaped query parameter (`brand`, `collection`, paging, sorting) and every product filter is forwarded to the underlying list calls, so the same filtering vocabulary as `GET /storefront/products` applies.

#### Signature

```http
GET /storefront/data/{siteId} (siteId: string, brand?: string, collection?: string, categories?: string, category?: string, tags?: string, tag?: string, brand?: string, minPrice?: number, maxPrice?: number, <attribute>?: string, p?: integer, ps?: integer, l?: string, s?: string, st?: string) -> The full landing-page bundle
```

#### Access

Public — no credentials required.

#### Notes

- `featuredProductDTO` is always the first 10 products in the `featured` category and ignores your filters.
- Products with `data.hide: true` are excluded from every bundle in this response.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | SITE_NOT_FOUND | Site 'main-store' not found | `siteId` matches no site record in the org. | Check the site `name` or `sk`. Sites are listed under the site management API. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/products`
- `GET /storefront/categories`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `siteId` | path | string | yes | Site `sk` or `name`. Identifies which site configuration (filters, currencies) to bundle. |
| `brand` | query | string | — | Brand name, matched case-insensitively. Repeat for several brands. |
| `collection` | query | string | — | Restrict the `collectionDTO` bundle to this collection name. |
| `categories` | query | string | — | Comma-separated category names. Matched case-insensitively against `post.categories`. |
| `category` | query | string | — | Single category name. Alias for a one-value `categories`. |
| `tags` | query | string | — | Comma-separated tags, matched case-insensitively against `post.tags`. |
| `tag` | query | string | — | Single tag. Alias for a one-value `tags`. |
| `minPrice` | query | number | — | Lower price bound, inclusive. |
| `maxPrice` | query | number | — | Upper price bound, inclusive. |
| `<attribute>` | query | string | — | Any query parameter that is not a reserved name is treated as a product attribute facet, comma-separated for multiple values — e.g. `?size=330ml,500ml&color=red`. Matched against `data.attributes[].options[].value`. |
| `p` | query | integer | — | Page number, 1-based. |
| `ps` | query | integer | — | Page size — how many records to return. Large values are slower; prefer cursor paging via `l` for deep scans. |
| `l` | query | string | — | Cursor for keyset pagination: the sort value of the last record from the previous page. Pass it to continue past that record instead of skipping with `p`, which stays fast at any depth. |
| `s` | query | string | — | Field to sort by. |
| `st` | query | "asc" \| "desc" | — | Sort direction. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The full landing-page bundle |
| `404` | Site 'main-store' not found — `siteId` matches no site record in the org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/brands/{brand}

**List brands**

`operationId: StorefrontController_brands`

Returns the brands (`sf_brand`) defined for the org, paged. Supply the `brand` path segment to fetch one brand by its exact `name`; omit it to list all of them.

#### Signature

```http
GET /storefront/brands/{brand} (brand: string, p?: integer, ps?: integer, l?: string, s?: string, st?: string) -> A page of brand records
```

#### Access

Public — no credentials required.

#### Notes

- Matching on `brand` is exact, not a prefix or substring search.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/products`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `l` | query | string | — | Cursor for keyset pagination: the sort value of the last record from the previous page. Pass it to continue past that record instead of skipping with `p`, which stays fast at any depth. |
| `st` | query | "asc" \| "desc" | — | Sort direction. |
| `s` | query | string | — | Field to sort by. |
| `ps` | query | integer | — | Page size — how many records to return. Large values are slower; prefer cursor paging via `l` for deep scans. |
| `p` | query | integer | — | Page number, 1-based. |
| `brand` | path | string | yes | Exact brand `name`. Omit the segment entirely to list every brand. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | A page of brand records |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "data": [
    {
      "id": "66f1a2b3c4d5e6f708192a3b",
      "datatype": "sf_brand",
      "name": "fizzco",
      "title": "FizzCo",
      "data": {
        "name": "fizzco",
        "title": "FizzCo",
        "logo": null
      }
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 50
}
```

## GET /storefront/attributes/{attribute}

**List storefront filter attributes**

`operationId: StorefrontController_attributes`

Returns the faceted filter attributes (`sf_attribute`) used to build storefront filter UI, sorted by `filterPosition` ascending. Supply `attribute` to fetch one by exact `name`.

#### Signature

```http
GET /storefront/attributes/{attribute} (attribute: string) -> A page of attribute records
```

#### Access

Public — no credentials required.

#### Notes

- **Legacy behaviour, verified in `StorefrontCatalogService.getAttributes`:** when the `attribute` segment is omitted this endpoint returns **brands** (`sf_brand`), not attributes. To list all attributes you must currently read them through the repository API. Treat the no-argument form as unreliable and do not build against it.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/brands/{brand}`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `attribute` | path | string | yes | Exact attribute `name`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | A page of attribute records |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/collections/{collection}

**List collections**

`operationId: StorefrontController_collections`

Returns merchandising collections (`sf_collection`), paged. Supply `collection` to fetch one by exact `name`.

#### Signature

```http
GET /storefront/collections/{collection} (collection: string, p?: integer, ps?: integer, l?: string, s?: string, st?: string) -> A page of collection records
```

#### Access

Public — no credentials required.

#### Errors

Plus the standard platform errors: `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `l` | query | string | — | Cursor for keyset pagination: the sort value of the last record from the previous page. Pass it to continue past that record instead of skipping with `p`, which stays fast at any depth. |
| `collection` | path | string | yes | Exact collection `name`. |
| `p` | query | integer | — | Page number, 1-based. |
| `ps` | query | integer | — | Page size — how many records to return. Large values are slower; prefer cursor paging via `l` for deep scans. Defaults to 100 here. |
| `s` | query | string | — | Field to sort by. |
| `st` | query | "asc" \| "desc" | — | Sort direction. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | A page of collection records |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/products

**List and search products**

`operationId: StorefrontController_products`

The main catalog query. Filters on categories, tags, brand, price range and arbitrary product attributes, then enriches every result with `data.calculatedPrice` resolved for the calling customer.

**Attribute filtering is open-ended.** Any query parameter that is not one of the reserved names (`categories`, `category`, `tags`, `tag`, `brand`, `price`, `minPrice`, `maxPrice`, `sort`, `sortType`, `random`, `page`, `pageSize`, `lastItem`, `sort_by`, and the short forms `p`, `ps`, `s`, `st`, `l`) is treated as a product attribute facet. So `?size=330ml,500ml&color=red` filters on the `size` and `color` attributes with no server configuration.

Text matching on categories, tags and brands is **case-insensitive and exact per value** — `cold-drinks` matches `Cold-Drinks` but not `cold-drinks-large`.

#### Signature

```http
GET /storefront/products (categories?: string, category?: string, tags?: string, tag?: string, brand?: string, minPrice?: number, maxPrice?: number, <attribute>?: string, p?: integer, ps?: integer, l?: string, s?: string, st?: string, random?: boolean) -> A page of products, each with calculated pricing, plus catalog price bounds
```

#### Access

Public — no credentials required.

#### Notes

- Products with `data.hide: true` are never returned. Products without the field are returned.
- `calculatedPrice` depends on who is calling — an anonymous request and a signed-in customer can see different `finalPrice` values for the same product. Do not cache the response across customers.
- If pricing fails for an individual product, that product is returned without `calculatedPrice` rather than failing the whole request. Clients should fall back to `data.price`.
- Prefer the `l` cursor over large `p` values: deep offset paging degrades on big catalogs.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/product/{id}`
- `GET /storefront/data/{siteId}`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `categories` | query | string | — | Comma-separated category names. Matched case-insensitively against `post.categories`. |
| `category` | query | string | — | Single category name. Alias for a one-value `categories`. |
| `tags` | query | string | — | Comma-separated tags, matched case-insensitively against `post.tags`. |
| `tag` | query | string | — | Single tag. Alias for a one-value `tags`. |
| `brand` | query | string | — | Brand name, matched case-insensitively. Repeat for several brands. |
| `minPrice` | query | number | — | Lower price bound, inclusive. |
| `maxPrice` | query | number | — | Upper price bound, inclusive. |
| `<attribute>` | query | string | — | Any query parameter that is not a reserved name is treated as a product attribute facet, comma-separated for multiple values — e.g. `?size=330ml,500ml&color=red`. Matched against `data.attributes[].options[].value`. |
| `p` | query | integer | — | Page number, 1-based. |
| `ps` | query | integer | — | Page size — how many records to return. Large values are slower; prefer cursor paging via `l` for deep scans. |
| `l` | query | string | — | Cursor for keyset pagination: the sort value of the last record from the previous page. Pass it to continue past that record instead of skipping with `p`, which stays fast at any depth. |
| `s` | query | string | — | Field to sort by. |
| `st` | query | "asc" \| "desc" | — | Sort direction. |
| `random` | query | boolean | — | Return results in random order instead of by `s`/`st`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | A page of products, each with calculated pricing, plus catalog price bounds |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "data": [
    {
      "id": "66f1a2b3c4d5e6f708192a3b",
      "datatype": "sf_product",
      "name": "cola-330ml",
      "title": "Cola 330ml",
      "data": {
        "sku": "DRK-COLA-330",
        "title": "Cola 330ml",
        "price": 12,
        "brand": "fizzco",
        "hide": false,
        "calculatedPrice": {
          "originalPrice": 12,
          "finalPrice": 9.6,
          "discount": 2.4,
          "discountPercent": 20,
          "appliedRule": {
            "name": "summer-sale"
          },
          "appliedDiscounts": [
            {
              "name": "summer-sale",
              "amount": 2.4
            }
          ],
          "freeShipping": false
        }
      }
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 50,
  "minMaxPrice": {
    "minPrice": 1.5,
    "maxPrice": 499
  }
}
```

## GET /storefront/product/{id}

**Get a product**

`operationId: StorefrontController_productBySlug`

Fetches one product and enriches it with `data.calculatedPrice` for the calling customer at quantity 1.

The `id` segment is flexible: the record `sk`, the product `name` (its URL slug) or the `sku` all resolve, so a storefront can route `/p/cola-330ml` straight through without a lookup table.

#### Signature

```http
GET /storefront/product/{id} (id: string) -> The product with calculated pricing, or `null` when `id` is empty
```

#### Access

Public — no credentials required.

#### Notes

- A product that does not exist resolves to `null` with a `200`, not a `404`. Check for a null body.
- Pricing is computed at quantity 1. Quantity-tiered prices are resolved at cart time, not here.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/products`
- `GET /storefront/product/{id}/related`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Product `sk`, `name` (slug) or `sku`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The product with calculated pricing, or `null` when `id` is empty |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/product/{id}/related

**Get related products**

> **Deprecated.**

`operationId: StorefrontController_productRelated`

> **Deprecated.** Not implemented. `StorefrontCatalogService.getProductRelated` throws unconditionally, so this endpoint always returns `500`. Do not integrate against it; it is documented only so its behaviour is not a surprise.

Intended to return products related to the given one.

#### Signature

```http
GET /storefront/product/{id}/related (id: string)
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `500` | NOT_IMPLEMENTED | An unexpected error occurred. Our team has been notified. | Always — the handler is a stub that throws `Method not implemented.` | Use `GET /storefront/products` with a shared category or tag to build a related-items rail instead. |

Plus the standard platform errors: `429`.

#### See also

- `GET /storefront/products`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Product SKU. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` |  |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — Always — the handler is a stub that throws `Method not implemented.` |

## GET /storefront/categories

**Get the storefront category tree**

`operationId: StorefrontController_categories`

Returns the storefront category tree as a nested array.

The tree is not one record per category — it lives entirely in the `children` array of a single `category` record named `storefront`. The `category` collection is shared across domains (CRM, content, storefront), each with its own root, which is why every storefront category call resolves that root first.

The root is created on demand, so a brand-new org gets `[]` rather than an error.

#### Signature

```http
GET /storefront/categories () -> The root category's children, or `[]` when the tree is empty
```

#### Access

Public — no credentials required.

#### Notes

- The root `storefront` node itself is never returned — only its children.
- Calling this on a fresh org has a side effect: it creates the root `category` record.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/categories`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The root category's children, or `[]` when the tree is empty |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
[
  {
    "name": "drinks",
    "title": "Drinks",
    "description": "Everything to drink",
    "children": [
      {
        "name": "cold-drinks",
        "title": "Cold Drinks",
        "description": "Chilled sodas, juices and water",
        "children": []
      }
    ]
  }
]
```

## POST /storefront/categories

**Add a storefront category**

`operationId: StorefrontController_addCategory`

Adds a category to the storefront tree. It always lands under the root `storefront` category, which is created automatically if the org does not have one yet. Pass `parent` (an existing category name) to nest it one level deeper instead of at the top level.

**Idempotent.** Posting a name that already exists *anywhere* in the tree returns that node unchanged, with a `201`, and writes nothing. Retrying a request whose response you lost is therefore safe.

**Names are slugified and globally unique.** `name` (or `title` when `name` is absent) is lowercased, non-alphanumeric runs collapse to `-`, leading and trailing dashes are trimmed and the result is cut to 50 characters — so `"Cold Drinks"` is stored as `cold-drinks`. Uniqueness is enforced across the whole tree, not just among siblings, because `name` is the only thing a product carries in `post.categories`; two nodes sharing a name would each list the other's products.

`parent` is matched by name anywhere in the tree, so you can nest under a category without knowing its path.

#### Signature

```http
POST /storefront/categories (body) -> The added (or already-existing) node, plus the full tree
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- The write is a partial update on `data.children` only, so the root record's other fields — and every other root sharing the `category` collection — are untouched.
- There is no update or delete endpoint for storefront categories; edit the `category` record named `storefront` through the repository API.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | CATEGORY_NAME_REQUIRED | Category name is required | Neither `name` nor `title` is supplied, or the value slugifies to an empty string (e.g. `"!!!"`). | Send a `name` containing at least one alphanumeric character. |
| `404` | PARENT_NOT_FOUND | Parent category 'drinks' not found | `parent` is set but no category with that name exists anywhere in the tree. | Create the parent first, or call `GET /storefront/categories` to see the available names. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `GET /storefront/categories`
- `GET /storefront/products`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The category to add. Same shape as the `category` model, plus an optional `parent`.

```json
{
  "name": "Drinks",
  "description": "Everything to drink"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The added (or already-existing) node, plus the full tree |
| `400` | Category name is required — Neither `name` nor `title` is supplied, or the value slugifies to an empty string (e.g. `"!!!"`). |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Parent category 'drinks' not found — `parent` is set but no category with that name exists anywhere in the tree. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "category": {
    "name": "cold-drinks",
    "title": "Cold Drinks",
    "description": "Chilled sodas, juices and water",
    "children": []
  },
  "categories": [
    {
      "name": "drinks",
      "title": "Drinks",
      "description": "",
      "children": [
        {
          "name": "cold-drinks",
          "title": "Cold Drinks",
          "description": "Chilled sodas, juices and water",
          "children": []
        }
      ]
    }
  ]
}
```

## GET /storefront/pos-categories

**Get POS quick-pick categories**

`operationId: StorefrontController_posCategories`

The top-of-screen quick category buckets used by the in-store POS (Apps / Mains / Drinks / …).

These are deliberately *not* the storefront category tree: they are sourced from the `posCategory` entry in the `sf_attribute` collection so operators can edit the POS layout in one place without touching the catalog taxonomy.

#### Signature

```http
GET /storefront/pos-categories () -> The option list, or `[]` when `posCategory` has not been seeded
```

#### Access

Public — no credentials required.

#### Notes

- An empty array means the `posCategory` attribute has not been configured yet, not that there was an error.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/categories`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The option list, or `[]` when `posCategory` has not been seeded |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
[
  {
    "label": "Apps",
    "value": "appetisers"
  },
  {
    "label": "Mains",
    "value": "mains"
  },
  {
    "label": "Drinks",
    "value": "drinks"
  }
]
```

