# Eatzino Connect – guide for AI coding agents

You are building or editing a restaurant's website. This guide tells you how to put the
restaurant's menu (categories, dishes, prices, descriptions, photos, sizes, add-ons,
allergens) into **Eatzino** and how to add Eatzino online ordering to the site.
Follow it step by step. Human docs: https://eatzino.com/developers

Eatzino is a food-ordering platform with 0 % commission for restaurants. Guests order on
eatzino.com, in the Eatzino app, or on the restaurant's own website through the Eatzino
widget. The menu lives in Eatzino; the API keeps it in sync with the website's code.

---

## 1. What you need from the restaurant

1. **A restaurant account on Eatzino.** If they don't have one, ask the owner to sign up
   (free, ~5 minutes): https://eatzino.com/be-seller – you cannot create one through the API.
2. **An API key** (starts with `ezk_`). The owner creates it at
   https://partner.eatzino.com/seller/developers → "Create key".
3. Store the key as a **server-side secret**: environment variable `EATZINO_API_KEY`
   (e.g. in `.env`, which must be in `.gitignore`). **Never** put the key in browser
   JavaScript, public HTML or a git commit – it can change the menu.

If the key is missing, stop and tell the user exactly these two links.

## 2. Basics

- Base URL: `https://api.eatzino.com/api/v1/connect`
- Auth header on every call: `Authorization: Bearer $EATZINO_API_KEY`
- Bodies are JSON: `Content-Type: application/json`
- Every response has `"ok": true|false`. Errors look like
  `{"ok": false, "error": {"code": "invalid_menu", "message": "…", "details": [{"path": "categories[0].dishes[2].price", "message": "…"}]}}`
- Limits per key: 120 requests/minute, 30 writes/minute.

## 3. Endpoints

| Method | Path | What it does |
|---|---|---|
| GET | `/restaurant` | Restaurant id, name, currency, status, page URL, ordering snippets |
| GET | `/menu` | The current menu in exactly the format `PUT /menu` accepts |
| PUT | `/menu` | Create/update the whole menu in one call (`?dry_run=1` = test only) |
| PATCH | `/dishes/{id}` | Change one dish, e.g. `{"price": 10.5}` or `{"available": false}` |
| DELETE | `/dishes/{id}` | Remove one dish |
| POST | `/images` | Upload a photo (multipart `file`, or JSON `{"data": "<base64>"}` or `{"url": "https://…"}`) → `{"url": "…"}` |
| GET | `/public/restaurants/{restaurantId}/menu` | No key. The live menu guests can order (for showing it on the site) |

## 4. Recommended workflow

1. `GET /restaurant` – check the key works; note `currency` (send prices in it) and `id`.
2. `GET /menu` – see what is already there. Dishes typed in the Eatzino dashboard have
   ids like `ez-<uuid>`; reuse those ids if the same dish exists in the codebase,
   otherwise Eatzino matches by name on the first sync.
3. Find the menu in the codebase (JSON/YAML/MDX/CMS data, React components, HTML).
   Build ONE menu object (section 5). Prefer the codebase's existing ids/slugs as `id`.
4. Local images (e.g. `/public/images/pizza.jpg`) are not reachable from the internet:
   upload each with `POST /images` and use the returned `url` as the dish `image`.
   Public `https://` image URLs can be used directly (Eatzino downloads them).
5. `PUT /menu?dry_run=1` – nothing is saved. Fix every item in `error.details`.
   Show the user the `summary` (how many dishes would be created/updated).
6. `PUT /menu` for real. Show the user `summary`, `warnings` and `menu_url`.
7. Add ordering to the website (section 9).
8. Add a sync script to the project (e.g. `scripts/eatzino-sync.mjs` + an npm script
   `eatzino:sync`) that rebuilds the menu object and sends it, so it can be re-run
   after menu changes. Re-sending an unchanged menu changes nothing.

## 5. The menu object (PUT /menu)

```json
{
  "language": "en",
  "mode": "merge",
  "currency": "EUR",
  "categories": [
    {
      "id": "pizza",
      "name": {"en": "Pizza", "de": "Pizza"},
      "description": "Stone-baked, 30 cm",
      "dishes": [
        {
          "id": "margherita",
          "name": {"en": "Pizza Margherita", "de": "Pizza Margherita"},
          "description": {"en": "Tomato, mozzarella, basil", "de": "Tomate, Mozzarella, Basilikum"},
          "price": 9.5,
          "image": "https://example.com/img/margherita.jpg",
          "available": true,
          "labels": ["vegetarian"],
          "allergens": ["gluten", "milk"],
          "additives": [],
          "options": [
            {
              "id": "size",
              "name": {"en": "Size", "de": "Größe"},
              "choices": [
                {"name": "26 cm", "price": 0},
                {"name": "32 cm", "price": 3}
              ]
            }
          ],
          "addons": ["extra-cheese"]
        }
      ]
    }
  ],
  "addons": [
    {"id": "extra-cheese", "name": {"en": "Extra cheese", "de": "Extra Käse"}, "price": 1.5}
  ]
}
```

### Top level

| Field | Required | Meaning |
|---|---|---|
| `categories` | yes | List of menu sections, in the order guests should see them |
| `addons` | no | Paid extras guests can add to dishes (extra cheese, a dip, a drink). Dishes reference them by `id` |
| `language` | no | `"en"` (default) or `"de"`: the language of texts sent as plain strings |
| `mode` | no | `"merge"` (default): create/update what you send, keep everything else. `"replace"`: also remove dishes, categories and add-ons that were created through the API earlier and are no longer sent. Dishes added by hand in the dashboard are never removed |
| `currency` | no | If sent, must equal the restaurant's currency (Eatzino never converts) |
| `dry_run` | no | `true` = validate and count only (same as `?dry_run=1`) |

### Category

| Field | Required | Meaning |
|---|---|---|
| `id` | recommended | Your stable id (letters, digits, `. _ - : /`, max 100). Default: slug of the name |
| `name` | yes | Text or `{"en": "…", "de": "…"}` (max 120) |
| `description` | no | Text or per language |
| `image` | no | Photo URL |
| `available` | no | `false` hides the whole section |
| `dishes` | yes | List of dishes |

### Dish

| Field | Required | Meaning |
|---|---|---|
| `id` | recommended | Your stable id, unique across the whole menu. Keep it the same between syncs – renaming a dish then updates it instead of creating a new one |
| `name` | yes | Text or `{"en", "de"}` (max 150) |
| `price` | yes | Number in the restaurant's currency, e.g. `9.5` (not cents). Must be > 0. With options: the price of the cheapest combination |
| `description` | no | Text or per language (max 2000). `""` clears it |
| `image` | no | Public `https://` image URL, or a URL from `POST /images`. `null` removes the photo. JPG/PNG/WebP/GIF, ≤ 8 MB |
| `available` | no | `false` = sold out / hidden, `true` = orderable |
| `vegetarian` | no | `true`/`false` (also set by labels `vegetarian`/`vegan`) |
| `labels` | no | Any of `vegetarian`, `vegan`, `halal`, `spicy`, `very_spicy`, `gluten_free`, `lactose_free` |
| `allergens` | no | The 14 EU allergens: `gluten`, `crustaceans`, `eggs`, `fish`, `peanuts`, `soy`, `milk`, `nuts`, `celery`, `mustard`, `sesame`, `sulphites`, `lupin`, `molluscs`. German menu letters `A`–`N` also work. `[]` = declared "contains none". Leave out / `null` = not declared yet. **Only send allergens the codebase actually states – never guess.** |
| `additives` | no | `colouring`, `preservative`, `antioxidant`, `flavour_enhancer`, `sulphured`, `blackened`, `waxed`, `phosphate`, `sweetener`, `phenylalanine`, `caffeine`, `quinine`, `taurine` (or German footnote numbers `1`–`13`) |
| `options` | no | Required single choices, e.g. size or crust (max 4 per dish, max 60 combinations). Each: `id`, `name`, `choices: [{"name", "price"}]` where `price` is the **extra** on top of the dish price (0 for the base choice). `[]` removes all options |
| `addons` | no | List of add-on ids (from top-level `addons`). `[]` removes them |

### Add-on

| Field | Required | Meaning |
|---|---|---|
| `id` | recommended | Stable id |
| `name` | yes | Text or per language |
| `price` | yes | Extra price (0 allowed) |
| `available` | no | `false` hides it |

### Rules worth knowing

- **Fields you leave out stay as they are.** E.g. if the restaurant filled in allergens
  in the dashboard and you don't send `allergens`, they are kept.
- **Languages:** the site shows English and German. A text sent as a plain string is
  stored in `language` and also used for the other language until that one is set.
  Send `{"en": "…", "de": "…"}` when the codebase has both.
- **Matching:** your `id` → else an `ez-<uuid>` id from `GET /menu` → else (first sync)
  the same name among dishes typed in the dashboard. After the first sync the dish
  belongs to your id.
- **Prices with options:** Eatzino stores one price per combination =
  `price` + the chosen extras.
- **Photos:** a photo URL is downloaded once; sending the same URL again is free. At most
  25 new photos per request – if `warnings` says so, send the same menu again.
- New restaurants are hidden from guests until Eatzino has verified them
  (`GET /restaurant` → `visible_to_guests`). You can build the whole menu before that.

## 6. Response of PUT /menu

```json
{
  "ok": true,
  "dry_run": false,
  "mode": "merge",
  "summary": {
    "categories": {"created": 1, "updated": 0, "unchanged": 3, "removed": 0},
    "dishes": {"created": 4, "updated": 2, "unchanged": 30, "removed": 0},
    "addons": {"created": 1, "updated": 0, "unchanged": 5, "removed": 0},
    "images": {"downloaded": 4, "failed": 0, "not_checked": 0}
  },
  "warnings": [{"path": "categories[1].dishes[0].image", "message": "Photo not saved: The image URL answered HTTP 404"}],
  "menu_url": "https://eatzino.com/en/chemnitz/restaurant/trattoria-italiana"
}
```

On `422 invalid_menu` nothing was saved – fix each `details[].path` and send again.

## 7. Small changes

```bash
# sold out
curl -X PATCH https://api.eatzino.com/api/v1/connect/dishes/margherita \
  -H "Authorization: Bearer $EATZINO_API_KEY" -H "Content-Type: application/json" \
  -d '{"available": false}'

# new price (choices move with it)
curl -X PATCH https://api.eatzino.com/api/v1/connect/dishes/margherita \
  -H "Authorization: Bearer $EATZINO_API_KEY" -H "Content-Type: application/json" \
  -d '{"price": 10.5}'
```

PATCH accepts every dish field plus `category` (a category id). To add a single new dish,
`PUT /menu` with `mode: "merge"` and just that category + dish.

## 8. Uploading local photos

```bash
curl -X POST https://api.eatzino.com/api/v1/connect/images \
  -H "Authorization: Bearer $EATZINO_API_KEY" \
  -F "file=@public/images/margherita.jpg"
# → {"ok": true, "url": "https://api.eatzino.com/storage/images/products/connect-….jpg"}
```

Cache the returned URL per file (e.g. keyed by the file's hash) in the sync script so
photos are not uploaded again on every sync.

## 9. Online ordering on the website

Eatzino handles cart, checkout, guest accounts and order tracking. Add the widget once,
e.g. before `</body>` (replace 123 with the restaurant id from `GET /restaurant`):

```html
<script src="https://eatzino.com/embed.js" data-shop="123" async></script>
```

This shows a floating "Order online" button. Optional:

- Any link or button opens ordering: `<a href="#eatzino-order">Order online</a>`
- Open a specific dish: `<button data-eatzino-order data-eatzino-product="EATZINO_ID">Order</button>`
  (`eatzino_id` of the dish from `GET /menu`)
- Whole ordering menu inside the page: `<div data-eatzino-menu data-height="800"></div>`
- Table booking (if the restaurant has it on): `<a href="#eatzino-reserve">Book a table</a>`
- Language: `data-lang="de"` on the script tag (default: the page's `<html lang>`)
- JavaScript: `window.EatzinoWidget.open()`, `.close()`, `.reserve()`

React / Next.js: load the script once (e.g. `next/script` with `strategy="afterInteractive"`).
WordPress: use the plugin https://eatzino.com/downloads/eatzino-online-ordering.zip instead.

## 10. Showing the menu from Eatzino (optional)

If the website should render the menu from Eatzino instead of its own data, read
`GET https://api.eatzino.com/api/v1/connect/public/restaurants/{id}/menu` (no key,
CORS open, only live restaurants). Fetch it on the server or at build time and cache it
for a few minutes; don't call it on every page view.

## 11. Example sync script (Node 18+)

```js
// scripts/eatzino-sync.mjs – run: node scripts/eatzino-sync.mjs [--dry]
import { readFile } from "node:fs/promises";

const API = "https://api.eatzino.com/api/v1/connect";
const KEY = process.env.EATZINO_API_KEY;
if (!KEY) throw new Error("Set EATZINO_API_KEY");

const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

// 1. build the menu from this project's data (adapt to the codebase)
const data = JSON.parse(await readFile("data/menu.json", "utf8"));
const menu = {
  language: "en",
  mode: "replace",
  categories: data.sections.map((s) => ({
    id: s.slug,
    name: s.title,
    dishes: s.items.map((d) => ({
      id: d.slug,
      name: d.name,
      description: d.description ?? "",
      price: Number(d.price),
      image: d.image?.startsWith("http") ? d.image : undefined,
      labels: d.vegetarian ? ["vegetarian"] : [],
    })),
  })),
};

// 2. send it
const dry = process.argv.includes("--dry") ? "?dry_run=1" : "";
const res = await fetch(`${API}/menu${dry}`, { method: "PUT", headers, body: JSON.stringify(menu) });
const out = await res.json();
if (!out.ok) {
  console.error(out.error.message);
  for (const d of out.error.details ?? []) console.error(` - ${d.path}: ${d.message}`);
  process.exit(1);
}
console.log(out.dry_run ? "Dry run:" : "Synced:", JSON.stringify(out.summary));
for (const w of out.warnings) console.warn(` ! ${w.path}: ${w.message}`);
if (!out.dry_run) console.log("Live menu:", out.menu_url);
```

## 12. Checklist before you finish

- [ ] Key only in a server-side env var, `.env` git-ignored
- [ ] Dry run passed without errors, then real sync done
- [ ] Every dish has a price > 0 in the restaurant's currency
- [ ] Allergens only where the codebase states them
- [ ] Ordering script added with the right restaurant id
- [ ] Sync script + short README note on how to re-run it
- [ ] Told the user: check the menu at `menu_url` and in the dashboard (Menu → Dishes)

Questions: partner@eatzino.com
