Terug naar Eat That

Open API voor kassasystemen

Een set JSON-endpoints waarmee elk restaurant ter wereld zijn kaart en bestellingen kan doorgeven. Geen SDK nodig, alleen HTTP en JSON. Gebouwd op de dingen die kassaleveranciers echt nodig hebben: idempotency, eigen bonnummers, modifiers, allergenen, btw, 86-ing, pollen en ondertekende webhooks met retries.

In het kort

Endpoints

Authenticatie

Maak je locatie aan in het dashboard en genereer daar een token (tzk_...). We bewaren alleen de hash, dus het token is daarna niet meer op te vragen. Stuur het mee bij elke schrijfactie:

Authorization: Bearer tzk_9f13c0a4e5b27d18aa63f0c4d9e2b7a15c8046f3ab19de52

Gebruik het token alleen server-side. Leesacties voor gasten werken zonder token. Je kunt per koppeling een apart token maken en losse tokens intrekken zonder de rest te raken.

1. Kaart pushen

Met PUT vervang je de hele kaart. Elk item ondersteunt varianten, modifiergroepen, allergenen, btw en vertalingen. Geef priceCents door als integer, dan zijn er geen afrondingsverschillen.

PUT /api/public/v1/venues/trattoria-aurora-bologna
Authorization: Bearer tzk_9f13c0a4e5b27d18aa63f0c4d9e2b7a15c8046f3ab19de52
Content-Type: application/json

{
  "name": "Trattoria Aurora",
  "city": "Bologna",
  "country": "IT",
  "currency": "EUR",
  "locale": "it-IT",
  "timezone": "Europe/Rome",
  "languages": ["nl", "en", "it"],
  "menu": [
    {
      "id": "tagliatelle",
      "name": "Tagliatelle al ragu",
      "course": "hoofd",
      "priceCents": 1650,
      "vatRate": 9,
      "currency": "EUR",
      "description": "Verse pasta, ragu van 4 uur.",
      "tags": ["klassieker"],
      "allergens": ["gluten", "ei", "selderij"],
      "available": true,
      "sku": "PASTA-001",
      "plu": "1042",
      "prepMinutes": 14,
      "variants": [
        { "id": "tagliatelle-klein", "name": "Klein", "priceCents": 1250 },
        { "id": "tagliatelle-groot", "name": "Groot", "priceCents": 1650 }
      ],
      "modifierGroups": [
        {
          "id": "extras",
          "name": "Extra's",
          "min": 0,
          "max": 3,
          "options": [
            { "id": "extra-parmezaan", "name": "Extra parmezaan", "priceCents": 150 },
            { "id": "zonder-ui", "name": "Zonder ui", "priceCents": 0 }
          ]
        }
      ],
      "translations": {
        "en": { "name": "Tagliatelle al ragu", "description": "Fresh pasta, 4 hour ragu." }
      }
    }
  ]
}

2. Kaart partieel bijwerken

Alleen een prijs of een nieuw gerecht? Stuur dan een PATCH met alleen wat verandert. Onbekende ids worden aangemaakt, mits name en een prijs zijn meegestuurd.

PATCH /api/public/v1/venues/trattoria-aurora-bologna/menu
Authorization: Bearer tzk_...
Content-Type: application/json

{
  "upsert": [
    { "id": "tagliatelle", "priceCents": 1750 },
    {
      "id": "tiramisu",
      "name": "Tiramisu della casa",
      "course": "nagerecht",
      "priceCents": 850,
      "vatRate": 9,
      "allergens": ["ei", "melk", "gluten"]
    }
  ],
  "remove": ["oude-soep"]
}

Response

HTTP/1.1 200 OK

{
  "venueId": "trattoria-aurora-bologna",
  "created": ["tiramisu"],
  "updated": ["tagliatelle"],
  "removed": ["oude-soep"],
  "menuSize": 24,
  "webhooks": [{ "id": "wh_1c4f", "deliveryId": "whdel_8a1c", "status": 200, "attempts": 1, "ok": true }]
}

3. Beschikbaarheid en 86-ing

Uitverkocht tijdens de service? Een enkele call zet items, varianten of losse modifieropties uit. De gastenkaart verbergt ze direct.

POST /api/public/v1/venues/trattoria-aurora-bologna/menu/availability
Authorization: Bearer tzk_...
Content-Type: application/json

{
  "items": [
    { "itemId": "tagliatelle", "available": false },
    { "itemId": "tagliatelle", "variantId": "tagliatelle-groot", "available": false },
    { "itemId": "tagliatelle", "optionId": "extra-parmezaan", "available": true }
  ]
}

Response

HTTP/1.1 200 OK

{
  "venueId": "trattoria-aurora-bologna",
  "applied": 3,
  "unknown": [],
  "webhooks": [{ "id": "wh_1c4f", "status": 200, "attempts": 1, "ok": true }]
}

4. Openingstijden met meerdere tijdvakken

Per dag mag je zoveel tijdvakken zetten als je wilt, bijvoorbeeld lunch en diner apart. Dag 0 is zondag, dag 6 is zaterdag. Tijden zijn lokale tijd van de locatie, in HH:MM. Is de sluittijd kleiner dan de openingstijd, dan loopt het tijdvak door na middernacht. Je mag ranges gebruiken of gewoon meerdere regels met dezelfde day. Dagen die je niet meestuurt gelden als gesloten. Het veld openNow rekent alles door in de tijdzone van de locatie, dus de gids en de gastenkaart tonen altijd de juiste status.

curl -X PUT https://eatthat.nl/api/public/v1/venues/bistro-de-vork/hours \
  -H "Authorization: Bearer tzk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "openingHours": [
      { "day": 1, "closed": true },
      { "day": 2, "ranges": [{ "opens": "12:00", "closes": "15:00" }, { "opens": "17:30", "closes": "23:00" }] },
      { "day": 5, "ranges": [{ "opens": "12:00", "closes": "15:00" }, { "opens": "17:30", "closes": "01:00" }] },
      { "day": 6, "opens": "17:00", "closes": "01:00" }
    ]
  }'

Response

{
  "venueId": "bistro-de-vork",
  "timezone": "Europe/Amsterdam",
  "openNow": true,
  "openingHours": [
    { "day": 2, "closed": false, "opens": "12:00", "closes": "15:00" },
    { "day": 2, "closed": false, "opens": "17:30", "closes": "23:00" }
  ],
  "week": [
    { "day": 1, "closed": true, "ranges": [{ "opens": "17:00", "closes": "23:00" }] },
    { "day": 2, "closed": false, "ranges": [{ "opens": "12:00", "closes": "15:00" }, { "opens": "17:30", "closes": "23:00" }] }
  ]
}

5. Bulk-import via CSV

Levert je kassa geen JSON? Stuur dan een CSV met kopregel. Met replace: true vervang je de hele kaart, met false vul je aan en werk je bij op id.

POST /api/public/v1/venues/trattoria-aurora-bologna/menu/import
Authorization: Bearer tzk_...
Content-Type: application/json

{
  "format": "csv",
  "replace": false,
  "delimiter": ",",
  "csv": "id,name,course,priceCents,vatRate,currency,description,tags,allergens,available\ntagliatelle,Tagliatelle al ragu,hoofd,1650,9,EUR,Verse pasta,klassieker,gluten|ei,true\ntiramisu,Tiramisu,nagerecht,850,9,EUR,Huisgemaakt,zoet,ei|melk,true"
}

Response

HTTP/1.1 200 OK

{
  "venueId": "trattoria-aurora-bologna",
  "imported": 2,
  "errors": [],
  "menuSize": 24
}

Kolommen

  • id: Optioneel. Zonder id maken we er een uit de naam.
  • name: Verplicht.
  • course: voor, hoofd, nagerecht, drank. Standaard hoofd.
  • price of priceCents: Kies er een. priceCents is een integer, dus 1650 voor 16,50.
  • vatRate: Btw-percentage, standaard 9.
  • currency: ISO-code van 3 letters, standaard EUR.
  • description: Korte omschrijving.
  • tags: Meerdere waarden gescheiden met een pipe, bijvoorbeeld vega|populair.
  • allergens: Zelfde pipe-notatie, bijvoorbeeld gluten|melk.
  • available: true of false. Standaard true.
  • sku, plu: Je eigen kassacodes, handig bij het terugkoppelen.
  • prepMinutes: Geschatte bereidingstijd in minuten.
  • imageUrl: Volledige https-URL naar een foto.

Je mag de CSV ook rechtstreeks posten met Content-Type: text/csv, dan gelden de standaardwaarden. Een GET op hetzelfde endpoint geeft de kolomlijst en een voorbeeldregel terug.

6. Bestelling doorgeven met idempotency

Stuur bij elke bestelling een unieke Idempotency-Key mee, bijvoorbeeld je bonnummer. Loopt een request in een timeout en probeer je het opnieuw, dan krijg je dezelfde bestelling terug in plaats van een dubbele. Met externalId koppel je jouw bonnummer.

POST /api/public/v1/venues/trattoria-aurora-bologna/orders
Authorization: Bearer tzk_...
Idempotency-Key: kassa-bon-2026-07-31-000412
Content-Type: application/json

{
  "table": "Tavolo 7",
  "externalId": "BON-000412",
  "status": "in_kitchen",
  "note": "Gast heeft haast",
  "currency": "EUR",
  "placedAt": "2026-07-31T19:05:02.000Z",
  "items": [
    {
      "itemId": "tagliatelle",
      "variantId": "tagliatelle-groot",
      "quantity": 2,
      "note": "1x zonder peper",
      "modifiers": [{ "groupId": "extras", "optionId": "extra-parmezaan" }]
    }
  ]
}

Response

HTTP/1.1 201 Created
X-RateLimit-Remaining: 118

{
  "venueId": "trattoria-aurora-bologna",
  "duplicate": false,
  "order": {
    "id": "0f6c1b2e-7a41-4c0e-9f2a-6f0f3a1d55c8",
    "externalId": "BON-000412",
    "table": "Tavolo 7",
    "status": "in_kitchen",
    "currency": "EUR",
    "totalCents": 3600,
    "items": [
      {
        "itemId": "tagliatelle",
        "name": "Tagliatelle al ragu",
        "variantId": "tagliatelle-groot",
        "variantName": "Groot",
        "quantity": 2,
        "modifiers": [{ "groupId": "extras", "optionId": "extra-parmezaan", "name": "Extra parmezaan", "priceCents": 150 }],
        "unitPriceCents": 1800,
        "lineTotalCents": 3600,
        "note": "1x zonder peper"
      }
    ],
    "placedAt": "2026-07-31T19:05:02.000Z",
    "updatedAt": "2026-07-31T19:05:02.100Z"
  },
  "webhooks": [{ "id": "wh_1c4f", "deliveryId": "whdel_8a1c", "status": 200, "attempts": 1, "ok": true }]
}

Dezelfde key nog een keer? Dan volgt HTTP 200 met de bestaande bestelling, geen nieuwe webhook en geen dubbele regel.

HTTP/1.1 200 OK
Idempotency-Replayed: true

{
  "venueId": "trattoria-aurora-bologna",
  "duplicate": true,
  "order": { "id": "0f6c1b2e-7a41-4c0e-9f2a-6f0f3a1d55c8", "externalId": "BON-000412" }
}

7. Statusupdates

De statussen zijn received, in_kitchen, ready, served en cancelled. Je mag het Eat That id of je eigen externalId in de URL gebruiken. Geannuleerde bestellingen tellen niet mee in de populariteit.

PATCH /api/public/v1/venues/trattoria-aurora-bologna/orders/BON-000412
Authorization: Bearer tzk_...
Content-Type: application/json

{ "status": "ready" }

Response

HTTP/1.1 200 OK

{
  "venueId": "trattoria-aurora-bologna",
  "event": "order.updated",
  "order": { "id": "0f6c1b2e-...", "externalId": "BON-000412", "status": "ready" },
  "webhooks": [{ "id": "wh_1c4f", "status": 200, "attempts": 1, "ok": true }]
}

8. Bestellingen ophalen (pull)

Kan je kassa niet pushen? Poll dan dit endpoint. Filter met since, until, status en table, en blader met cursor. Maximaal 200 per pagina.

GET /api/public/v1/venues/trattoria-aurora-bologna/orders
      ?since=2026-07-31T19:00:00.000Z
      &status=received
      &limit=100
      &order=asc

Response

HTTP/1.1 200 OK

{
  "venueId": "trattoria-aurora-bologna",
  "count": 100,
  "hasMore": true,
  "nextCursor": "2026-07-31T19:41:12.000Z",
  "orders": [ { "id": "...", "externalId": "BON-000412", "status": "received", "totalCents": 3600 } ]
}

Een complete synchronisatielus:

let cursor = null;
let since = lastSyncedAt; // ISO 8601 uit je eigen opslag

do {
  const url = new URL(`https://eatthat.nl/api/public/v1/venues/${venueId}/orders`);
  url.searchParams.set("since", since);
  url.searchParams.set("order", "asc");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);

  const res = await fetch(url);
  const page = await res.json();

  for (const order of page.orders) await verwerkInKassa(order);

  cursor = page.nextCursor;
  if (page.orders.length) since = page.orders[page.orders.length - 1].placedAt;
} while (cursor);

9. Webhooks met retries

Registreer een URL en kies je events. Zet maxRetries op 0 tot 5.

POST /api/public/v1/venues/trattoria-aurora-bologna/webhooks
Authorization: Bearer tzk_...
Content-Type: application/json

{
  "url": "https://kassa.trattoria-aurora.it/hooks/eatthat",
  "events": ["order.created", "order.updated", "menu.availability_changed"],
  "maxRetries": 3
}

Response

HTTP/1.1 201 Created

{
  "venueId": "trattoria-aurora-bologna",
  "webhook": {
    "id": "wh_1c4f9a2b7d3e",
    "url": "https://kassa.trattoria-aurora.it/hooks/eatthat",
    "events": ["order.created", "order.updated", "menu.availability_changed"],
    "createdAt": "2026-07-31T19:04:30.000Z",
    "lastStatus": null,
    "lastAttemptAt": null,
    "failures": 0,
    "maxRetries": 3,
    "lastError": null
  },
  "signingSecret": "whsec_5b8e1d0c73a49f26be1057d3ca82f419",
  "secretNote": "Bewaar dit signing secret, het wordt maar een keer getoond."
}

Events

  • order.created: Een nieuwe bestelling is binnengekomen.
  • order.updated: De status van een bestelling is gewijzigd.
  • order.cancelled: Een bestelling is geannuleerd.
  • menu.updated: De kaart is gewijzigd, via PUT, PATCH of CSV-import.
  • menu.availability_changed: Een item, variant of optie is uitverkocht gemeld of weer aangezet.
  • *: Alles hierboven op een enkele URL.

Dit is het bericht dat wij versturen:

POST /hooks/eatthat
X-EatThat-Event: order.created
X-EatThat-Delivery: whdel_8a1c4f7b2e93
X-EatThat-Attempt: 1
X-EatThat-Timestamp: 1785438302
X-EatThat-Signature: t=1785438302,sha256=6f2b...c91d
Content-Type: application/json

{
  "event": "order.created",
  "venueId": "trattoria-aurora-bologna",
  "deliveryId": "whdel_8a1c4f7b2e93",
  "attempt": 1,
  "sentAt": "2026-07-31T19:05:02.000Z",
  "data": {
    "order": {
      "id": "0f6c1b2e-7a41-4c0e-9f2a-6f0f3a1d55c8",
      "externalId": "BON-000412",
      "table": "Tavolo 7",
      "status": "received",
      "totalCents": 3600,
      "items": [{ "itemId": "tagliatelle", "name": "Tagliatelle al ragu", "quantity": 2 }],
      "placedAt": "2026-07-31T19:05:02.000Z"
    }
  }
}

De handtekening is een HMAC-SHA256 over timestamp + "." + ruwe body. Door de timestamp mee te ondertekenen kan een onderschept bericht niet later opnieuw worden afgespeeld. Weiger berichten die ouder zijn dan 5 minuten.

import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hooks/eatthat", express.raw({ type: "*/*" }), (req, res) => {
  const header = req.header("X-EatThat-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = Number(parts.t);

  // 1. Replaybescherming: niet ouder dan 5 minuten.
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return res.status(401).send("verlopen handtekening");
  }

  // 2. Handtekening over "timestamp.body".
  const expected = createHmac("sha256", process.env.EATTHAT_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body.toString()}`)
    .digest("hex");

  const given = parts.sha256 ?? "";
  if (given.length !== expected.length ||
      !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).send("ongeldige handtekening");
  }

  // 3. Idempotent verwerken op deliveryId.
  const payload = JSON.parse(req.body.toString());
  if (alVerwerkt(payload.deliveryId)) return res.send("ok");

  verwerk(payload);
  res.send("ok");
});

Antwoord binnen 5 seconden met een 2xx. Lukt dat niet, dan proberen we het opnieuw met oplopende wachttijden van 0,4s, 1,5s, 4s, 8s en 15s tot aan jouw maxRetries. Een 4xx die geen 408 of 429 is zien we als permanent en proberen we niet opnieuw. Elke poging heeft dezelfde deliveryId, dus verwerk daarop idempotent. De laatste status, het aantal mislukkingen en de laatste fout zie je via GET op het webhooks-endpoint en in het dashboard.

Rate limiting

Per locatie geldt een limiet van 120 requests per minuut over alle endpoints samen. Elke response bevat de actuele stand:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1785438360

Boven de limiet volgt HTTP 429 met een Retry-After-header. Wacht die periode af en probeer het daarna opnieuw met exponentiele backoff. Bulk-acties doe je het liefst via PATCH of CSV-import in plaats van honderden losse calls.

Foutresponses

HTTP/1.1 401 Unauthorized
{ "error": "Ontbrekend API-token. Stuur een header: Authorization: Bearer ..." }

HTTP/1.1 403 Forbidden
{ "error": "Ongeldig API-token voor deze locatie" }

HTTP/1.1 422 Unprocessable Entity
{ "error": "Validatie mislukt", "details": { "fieldErrors": { "table": ["Required"] } } }

HTTP/1.1 422 Unprocessable Entity
{ "error": "Onbekende variant tagliatelle-xl bij item tagliatelle" }

HTTP/1.1 429 Too Many Requests
Retry-After: 60
{ "error": "Te veel requests voor deze locatie", "limit": 120, "windowSeconds": 60 }

Regels