> For the complete documentation index, see [llms.txt](https://docs.zapiet.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.zapiet.com/zapiet-eats/catalog-api.md).

# Catalog API

REST API for native apps to render published menus, sections, products, and customizations.

Use this API when a native app needs to **read** published catalog data and render its own UI. For storefront JavaScript in the merchant theme, use the [Widget API](/zapiet-eats/widget-api.md) instead.

{% hint style="info" %}
Catalog API v1 is REST at `/api/catalog/v1`. GraphQL is not available yet. The API is enabled per store — until it is on, native apps should keep using a WebView of the merchant’s Zapiet ordering page.
{% endhint %}

{% hint style="danger" %}
Do **not** call the App Proxy JSON API (`/apps/{prefix}/api/menu/...`) from iOS or Android. Shopify signs those requests. Do not put the Shopify app client secret in the mobile binary.
{% endhint %}

A machine-readable OpenAPI 3.1 spec is published in this organization as **eats-catalog-v1**. Import it into Postman, Insomnia, or your codegen tool from [GitBook OpenAPI settings](https://app.gitbook.com/o/-MFBGACA_SwSzfvM6S0-/settings/openapi/eats-catalog-v1), or from the repo file `docs/openapi/catalog-v1.yaml`.

## Authentication

1. In Zapiet Eats go to **Settings → API credentials**.
2. Create a personal access token. Copy the secret once (it is not shown again).
3. Send it on every request:

```http
Authorization: Bearer {token}
Accept: application/json
```

The token is bound to **one store**. It cannot read another merchant’s menus.

Optional (must match the token’s store if present):

```http
X-Zapiet-Eats-Shop-Url: your-store.myshopify.com
```

A mismatched shop header returns **403**.

Required scope: `catalog:read` (native apps) or `api:*` (existing full-access merchant tokens).

* `catalog:read` is read-only catalog access. New tokens default to this. It cannot call other Zapiet REST surfaces.
* `api:*` is full REST access for the bound store. Do **not** put `api:*` in a native app.

## Rate limits

* Default: **40 requests per second** per store.
* Over the limit: **429** `{ "error": "rate_limited" }` with `Retry-After`.

## Errors

```json
{ "error": "unauthorized", "message": "Unable to authenticate the catalog request." }
```

| HTTP | `error`            | Meaning                                                      |
| ---- | ------------------ | ------------------------------------------------------------ |
| 401  | `unauthorized`     | Missing, expired, revoked, or unbound token                  |
| 403  | `forbidden`        | Shop header does not match the token’s store                 |
| 403  | `missing_scope`    | Valid token without `catalog:read` or `api:*`                |
| 404  | `not_found`        | Unknown id, other store’s id, draft menu, or API not enabled |
| 422  | `validation_error` | Bad query params                                             |
| 429  | `rate_limited`     | Too many requests                                            |

## REST

Base: `https://{eats-host}/api/catalog/v1`

Prices are **integer cents** in the shop currency.

### Localization

| Field                            | Localized?          | Response shape                 |
| -------------------------------- | ------------------- | ------------------------------ |
| Menu name                        | Yes                 | `name` + `translations`        |
| Section name                     | Yes                 | `name` + `translations`        |
| Section description              | No                  | `description` string or `null` |
| Product name                     | No (Shopify string) | `name` only                    |
| Allergens / dietary restrictions | Yes                 | `{ id, name, translations }`   |

`name` is the store **default locale** (Settings → Languages). `translations` is a locale → string map. When a menu or section has no localized names, `name` is `""` and `translations` is `{}`.

### List locations

```http
GET /api/catalog/v1/locations
```

### List published menus

```http
GET /api/catalog/v1/menus
GET /api/catalog/v1/menus?location_id=123
```

```json
{
  "data": [
    {
      "id": 1197,
      "name": "Dinner",
      "translations": { "en": "Dinner", "fr": "Diner" },
      "handle": "m-1197"
    }
  ],
  "links": { "first": "...", "next": null, "prev": null }
}
```

### Get a menu tree (slim product cards)

```http
GET /api/catalog/v1/menus/1197
```

Id or handle (`m-1197`) both work. Draft menus are omitted.

The menu root and each section include `name` and `translations`. Product cards omit `body_html`, `product_options`, and Shopify `options` / `option_groups`.

```json
{
  "data": {
    "id": 1197,
    "name": "Dinner",
    "translations": { "en": "Dinner", "fr": "Diner" },
    "sections": [
      {
        "id": 44,
        "name": "Mains",
        "translations": { "en": "Mains", "fr": "Plats" },
        "description": null,
        "sort_order": 0,
        "items": [
          {
            "id": 9001,
            "name": "Burger",
            "has_options": false,
            "allergens": [],
            "dietary_restrictions": []
          }
        ]
      }
    ]
  }
}
```

Each product card includes:

* `id`, `name`, `image_url`, `price_range` (`min` / `max` in cents), `calories`
* `variants[]` — `variant_id`, `name`, `price` (cents)
* `has_options` — `true` when published Product Options, more than one variant, or meal-deal / pick-a-side groups exist
* `allergens[]` and `dietary_restrictions[]` as `{ id, name, translations }`

### List section metadata (no products)

```http
GET /api/catalog/v1/menus/1197/sections
```

### Get one section (slim product cards)

```http
GET /api/catalog/v1/menus/1197/section/44
```

Use this after the section list when loading a large menu one section at a time.

### Get one product (full payload)

```http
GET /api/catalog/v1/menus/1197/section/44/products/9001
```

Returns `body_html`, published `product_options`, Shopify `options` / `option_groups`, full `variants`, and bundle / choice groups. **404** if the product is not on that published menu section.

```json
{
  "id": 7,
  "name": "Milk",
  "translations": { "en": "Milk", "fr": "Lait" }
}
```

### List allergens and dietary restrictions

```http
GET /api/catalog/v1/allergens
GET /api/catalog/v1/dietary-restrictions
```

Same `{ id, name, translations }` shape. Merchant Settings labels win over Zapiet defaults. Cache these maps.

### Loading pattern

* **Small menu:** `GET /menus/{id}`, then nested product URL on tap.
* **Large menu:** `GET /menus/{id}/sections`, then `GET /menus/{id}/section/{sectionId}` per section; nested product URL on tap.

## What v1 does not do

* Add to cart, checkout, or charge the customer (use Shopify / existing WebView).
* Return draft menus or draft customizations.
* Return another store’s data.
* Replace the App Proxy page for browser customers.

## Store the token safely

* iOS: Keychain. Android: EncryptedSharedPreferences / Keystore.
* One token per store. Rotate by revoking in Settings → API credentials.
* Never ship the Shopify app secret, and never log the Bearer token.

## Examples

```bash
export BASE="https://app.zapieteats.com/api/catalog/v1"
export TOKEN="eyJ..."

curl -sS -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
  "$BASE/menus"

curl -sS -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
  "$BASE/menus/1197"
```
