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
- Basis-URL:
https://eatthat.nl/api/public/v1 - Locaties en tokens maak je zelf aan in het dashboard, niet via de API.
- Alle schrijfacties vragen om een Bearer-token van die locatie.
- Bedragen bij voorkeur in centen als integer, tijden in ISO 8601 (UTC).
Endpoints
- GET
/api/public/v1/venues/{venueId}publiekLocatiegegevens plus het menu, gesorteerd op populariteit.
- PUT
/api/public/v1/venues/{venueId}token vereistVervang de volledige kaart en de locatie-instellingen in een keer.
- GET
/api/public/v1/venues/{venueId}/hourspubliekOpeningstijden per dag, meerdere tijdvakken mogelijk, plus openNow in de tijdzone van de locatie.
- PUT
/api/public/v1/venues/{venueId}/hourstoken vereistZet de openingstijden, bijvoorbeeld lunch en diner apart, of een sluittijd na middernacht.
- GET
/api/public/v1/venues/{venueId}/menupubliekDe ruwe kaart met varianten, modifiers, allergenen en btw per item.
- PATCH
/api/public/v1/venues/{venueId}/menutoken vereistWerk losse items bij of verwijder ze, zonder de hele kaart te sturen.
- POST
/api/public/v1/venues/{venueId}/menu/availabilitytoken vereist86-ing: zet items, varianten of opties in een enkele call uitverkocht of weer beschikbaar.
- POST
/api/public/v1/venues/{venueId}/menu/importtoken vereistBulk-import van de kaart via CSV, voor kassa's die geen JSON exporteren.
- POST
/api/public/v1/venues/{venueId}/orderstoken vereistStuur een bestelling door. Ondersteunt Idempotency-Key en je eigen bonnummer.
- GET
/api/public/v1/venues/{venueId}/orderspubliekPull-endpoint met since, until, status, table, cursor-paginatie en sorteerrichting.
- GET
/api/public/v1/venues/{venueId}/orders/{orderId}publiekEen enkele bestelling op Eat That id of op jouw externalId.
- PATCH
/api/public/v1/venues/{venueId}/orders/{orderId}token vereistZet de status: received, in_kitchen, ready, served of cancelled.
- GET
/api/public/v1/venues/{venueId}/livepubliekLive overzicht voor de gastenkaart: populairste gerechten en recente bestellingen.
- POST
/api/public/v1/venues/{venueId}/webhookstoken vereistRegistreer een webhook-URL met de events die je wilt ontvangen.
- GET
/api/public/v1/venues/{venueId}/webhookstoken vereistBekijk je webhooks met laatste status, aantal mislukkingen en laatste fout.
- DELETE
/api/public/v1/venues/{venueId}/webhooks?id={webhookId}token vereistVerwijder een webhook.
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_9f13c0a4e5b27d18aa63f0c4d9e2b7a15c8046f3ab19de52Gebruik 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=ascResponse
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: 1785438360Boven 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
- Alle requests en responses zijn JSON, UTF-8, met CORS open voor elke origin.
- Tijdstempels in ISO 8601 (UTC), bedragen bij voorkeur in centen als integer.
- Bij validatiefouten volgt HTTP 422 met een veld-per-veld toelichting.
- Stuur geen persoonsgegevens mee: alleen tafelaanduiding, bonnummer en items.
- Onbekende velden negeren we, dus we kunnen uitbreiden zonder jouw koppeling te breken.