NomNom Developer Platform
The reservation book, as an API.
Read the bookings for the venues you integrate. Built for EPOS and till vendors who need today's covers on the terminal. Its one write tells the venue a party has sat down, left or not turned up. v1 cannot create or amend bookings yet — that is coming, and is not here yet.
Every response is JSON. Every request is authenticated with a single bearer token. There is no SDK to install and nothing to negotiate before you start — ask us for a key and make your first call in about five minutes.
Looking for what NomNom connects to without writing any code — Stripe, webhooks, Reserve with Google, your own website? See NomNom integrations.
curl https://api.nomnom-app.net/api/v1/reservations \
-H "Authorization: Bearer nn_live_..." \
-G -d date=2026-08-20
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
var json = await http.GetStringAsync(
"https://api.nomnom-app.net/api/v1/reservations?date=2026-08-20");
const res = await fetch(
'https://api.nomnom-app.net/api/v1/reservations?date=2026-08-20',
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const { data } = await res.json();
import requests
r = requests.get(
"https://api.nomnom-app.net/api/v1/reservations",
headers={"Authorization": f"Bearer {api_key}"},
params={"date": "2026-08-20"},
)
{
"data": [
{
"id": "res_9c41e0",
"reference": "NN-4831",
"state": "booked",
"date": "2026-08-20",
"time": "19:00",
"timezone": "Europe/London",
"adults": 2,
"children": 1,
"tables": ["12", "13"]
}
],
"has_more": false,
"next_cursor": ""
}
Quickstart
Three steps to a working integration. Start in your own sandbox: a pretend restaurant with a fortnight of bookings, where nothing you do reaches a real restaurant or a real guest — see Authentication.
1. Get a key
We issue keys by hand for now: talk to us. You get an nn_test_… key for your own sandbox first, and an nn_live_… key for a real venue once that venue has agreed. We send the key itself over something better than email. It is shown once — we store only a hash of it and cannot recover it for you, so put it straight into wherever your software keeps its secrets.
2. Make your first call
List today's bookings. If you get a 200 with a data array, you are integrated.
3. Sync, don't poll hard
Pass updated_since to fetch only what changed. This is the endpoint your till should live on — it is cheap, it is stable under concurrent writes, and it will not fall behind a busy service.
There is no webhook for API integrators yet, so this endpoint is how you stay in step. Polling every few seconds across many venues is the fastest way to make an integration feel slow for everyone: keep it to once a minute or slower and always pass updated_since. Signed webhooks are coming, and will become the recommended pattern when they land.
# everything that changed since your last sync
curl https://api.nomnom-app.net/api/v1/reservations \
-H "Authorization: Bearer $NOMNOM_KEY" \
-G -d updated_since=2026-08-13T18:30:00 \
-d limit=100
var url = "https://api.nomnom-app.net/api/v1/reservations"
+ "?updated_since=2026-08-13T18:30:00&limit=100";
var page = await http.GetFromJsonAsync<Page>(url);
foreach (var r in page.Data)
tillBook.Upsert(r.Id, r.Date, r.Time, r.State);
const url = new URL('https://api.nomnom-app.net/api/v1/reservations');
url.searchParams.set('updated_since', lastSync);
url.searchParams.set('limit', '100');
const { data, has_more, next_cursor } =
await (await fetch(url, { headers })).json();
r = requests.get(
"https://api.nomnom-app.net/api/v1/reservations",
headers={"Authorization": f"Bearer {api_key}"},
params={"updated_since": last_sync, "limit": 100},
)
page = r.json()
Authentication
Send your key as a bearer token on every request. There is no session, no login call and no token refresh.
| Prefix | Mode | Reads |
|---|---|---|
| nn_test_ | Sandbox | Your own sandbox venue. Fake bookings you are free to change. |
| nn_live_ | Production | The venue that issued the key. Real bookings. |
Each integrator gets a sandbox of their own: two rooms (with a table
1 in each, on purpose), a combined table, the six statuses a real
venue might use, and six bookings a day from a week ago to a week ahead,
with fake names and no contact details. Nothing it does sends an
email, a text, a push or a webhook, and it never appears on Google or in
our listings. Ask and we reset it to its starting data.
The two kinds of key never cross: an nn_test_ key works only with
its sandbox and an nn_live_ key only with a real venue. Anything else
answers 403 wrong_mode.
Scopes
Keys are scoped. A key without the scope an endpoint needs gets 403 with the required scope named in the message — you should never have to guess which permission is missing.
Three scopes are live today. reservations:write covers one thing so far — changing a booking's state — and is granted only when you ask for it. The rest are named here so you know what they will be called, not because you can ask for them yet.
| Scope | Grants | Status |
|---|---|---|
| reservations:read | List and retrieve bookings | Live |
| reservations:write | Change a booking's state. Creating, amending and cancelling are not here yet. | Live |
| customers:read | Look up guest profiles | Not in v1 |
| availability:read | Query bookable slots | Not in v1 |
| venues:read | The venue, its rooms, tables and statuses | Live |
A key is a bearer credential: whoever holds it can act as you. Keep it server-side — never in a mobile app, a browser bundle or a public repository. If one leaks, tell us and we will revoke it; revocation takes effect immediately, on the very next request.
Authorization: Bearer nn_live_a1b2c3d4e5f6g7h8i9j0klmnopqrstuv
{
"error": {
"type": "authentication_error",
"code": "invalid_api_key",
"message": "The API key provided is not valid.",
"param": "",
"request_id": "req_8f2a41"
}
}
{
"error": {
"type": "permission_error",
"code": "insufficient_scope",
"message": "This API key does not have the
required scope: reservations:read"
}
}
Dates & times
A booking happens at a wall-clock time in a restaurant, not at an instant on a global timeline. The API reflects that literally, and it is worth two minutes of your attention because getting it wrong is the classic reservation-integration bug.
Every reservation carries three fields:
dateandtime— the venue's local wall clock. This is what the host stand sees and what you should print on a ticket.timezone— the venue's IANA zone, e.g.Europe/London.
Combine them to get an unambiguous instant. We deliberately do not hand you a pre-computed UTC timestamp in v1, because a single datetime field invites exactly the mistake we are trying to avoid: a value that looks like an instant, is actually a local time, and is silently an hour out for half the year.
A starts_at field carrying a true offset will be added in a future release. Adding a field is not a breaking change — see Versioning — so you can adopt it whenever suits you.
Timestamps
created_at and updated_at are in the same venue-local frame, formatted YYYY-MM-DDTHH:MM:SS with no zone designator. Pass updated_since in that same shape.
// A 19:00 booking in Europe/London on 20 Aug
// is 18:00 UTC - British Summer Time.
const instant = Temporal.ZonedDateTime.from(
`${r.date}T${r.time}[${r.timezone}]`
);
instant.toInstant().toString();
// 2026-08-20T18:00:00Z
{
"date": "2026-08-20",
"time": "19:00",
"timezone": "Europe/London",
"created_at": "2026-08-01T10:15:00",
"updated_at": "2026-08-13T18:30:12"
}
Pagination
List endpoints are cursor-paginated. Read the first page, then pass next_cursor back until has_more is false.
Cursors are opaque: treat them as a blob and pass them back byte-for-byte. Do not parse, construct or store them beyond the sweep you are in.
Why not page numbers
A reservation book takes writes constantly. With numbered pages, a booking created while you are paging pushes rows down and you see one twice; a cancellation pulls rows up and you never see one at all. A cursor names the exact row the next page continues after, so the boundary holds no matter what changed in between.
| Field | Meaning |
|---|---|
| data | The page of results, most recently changed first |
| has_more | true when another page exists |
| next_cursor | Pass as cursor for the next page. Empty when there is none. |
limit defaults to 50 and caps at 200. Asking for more is clamped rather than rejected, and has_more stays accurate.
let cursor = '', all = [];
do {
const u = new URL(base + '/api/v1/reservations');
u.searchParams.set('updated_since', lastSync);
if (cursor) u.searchParams.set('cursor', cursor);
const page = await (await fetch(u, { headers })).json();
all.push(...page.data);
cursor = page.next_cursor;
} while (cursor);
cursor, all_rows = "", []
while True:
params = {"updated_since": last_sync}
if cursor:
params["cursor"] = cursor
page = requests.get(url, headers=h, params=params).json()
all_rows += page["data"]
cursor = page["next_cursor"]
if not cursor:
break
Errors
Every error returns the same shape. Branch on code — it is a stable machine token that will not change within v1. message is for humans and may be reworded.
| Status | code | What to do |
|---|---|---|
| 400 | invalid_parameter | Fix the input named in param. Do not retry unchanged. |
| 400 | invalid_cursor | Restart the sweep without a cursor. |
| 401 | missing_api_key | Send the Authorization header. |
| 401 | invalid_api_key | Check the key. Same response whether it is malformed or unknown. |
| 401 | revoked_api_key | The venue revoked it. Ask for a new one. |
| 403 | insufficient_scope | The message names the scope you need. |
| 403 | wrong_mode | The key does not fit the venue: test keys work only with a sandbox, live keys only with a real venue. |
| 404 | not_found | No such record for this key's venue. |
| 409 | no_matching_status | The venue has no status for that state. Send a status_id instead. |
| 409 | reservation_cancelled | The booking is cancelled; its state cannot change. |
| 409 | table_clash | The change needs a table another party now holds. Ask the venue to move the booking. |
| 429 | rate_limited | Back off for Retry-After seconds. Read that header, not the body: a 429 is raised before the API sees the request, so it is the one response that does not use the error shape opposite. |
| 500 | internal_error | Ours. Retrying is reasonable. Quote the request_id. |
Request IDs
Every response carries an X-Request-Id header, echoed in the error body. Log it. If you contact support, that one value lets us find the exact request in our logs — it is the difference between a five-minute answer and a long conversation about what time it happened.
A record belonging to another venue returns exactly the same 404 as one that does not exist. That is deliberate: it stops the endpoint being used to discover whether a given id exists anywhere on the platform.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_parameter",
"message": "date_from must be YYYY-MM-DD.",
"param": "date_from",
"request_id": "req_8f2a41"
}
}
if (!res.ok) {
// 429 has its own body shape - branch on the status
if (res.status === 429)
return retryAfter(res.headers.get('Retry-After'));
const { error } = await res.json();
switch (error.code) {
case 'revoked_api_key':
return alertOps(error.request_id);
default:
throw new Error(`${error.code}: ${error.message}`);
}
}
List reservations
Returns bookings for the venue this key can access, ordered by updated_at — most recently changed first, not by service date. That is what makes a sweep safe to resume; sort by date and time yourself if you are drawing a service. This is the endpoint a till syncs from.
Query parameters
YYYY-MM-DD, in the venue's local calendar. The usual "what's on tonight" call.date is given.updated_at you have seen and pass it back next time.booked, seated, departed, cancelled, no_show. state=booked,seated is "who is here or still expected". Filtered on our side, so every page is full and has_more stays accurate. An unknown state is a 400.next_cursor on the previous page. Opaque.curl https://api.nomnom-app.net/api/v1/reservations \
-H "Authorization: Bearer $NOMNOM_KEY" \
-G -d date=2026-08-20 \
-d limit=50
var q = "?date=2026-08-20&limit=50";
var page = await http.GetFromJsonAsync<Page>(
$"https://api.nomnom-app.net/api/v1/reservations{q}");
const u = new URL(base + '/api/v1/reservations');
u.searchParams.set('date', '2026-08-20');
u.searchParams.set('limit', '50');
const page = await (await fetch(u, { headers })).json();
{
"data": [ { /* reservation objects */ } ],
"has_more": true,
"next_cursor": "djF8MjAyNi0wOC0xMyAxODozMDoxMnxyZXNf..."
}
Retrieve a reservation
Fetches a single booking by its NomNom id. Returns the same object shape as the list endpoint, unwrapped.
Path parameters
id. Opaque — store it exactly as returned; do not assume a format or a length.An id belonging to a different venue returns 404, identical to an id that does not exist.
curl https://api.nomnom-app.net/api/v1/reservations/res_9c41e0 \
-H "Authorization: Bearer $NOMNOM_KEY"
{
"error": {
"type": "invalid_request_error",
"code": "not_found",
"message": "No reservation found with that id."
}
}
Change a reservation's state
Tells the venue a party has sat down, left or not turned up. Needs the reservations:write scope; try it on your sandbox with your nn_test_ key first. It does exactly what a member of staff changing the status does: a no_show frees the table, departed frees it from now, moving a party back from departed gives the table back, and the change appears in the venue's history.
Body — send one of
seated, departed, no_show or booked. Sets the venue's first status with that state, in the venue's own order. 409 no_matching_status if it has none.Returns the reservation as it now stands. Sending the status it already has changes nothing and returns it unchanged, so retries are safe. Cancelling is not a state change — it emails the guest and settles any deposit — and is not offered here.
curl https://api.nomnom-app.net/api/v1/reservations/res_9c41e0/state \
-H "Authorization: Bearer $NOMNOM_KEY" \
-H "Content-Type: application/json" \
-d '{"state": "seated"}'
{
"id": "res_9c41e0",
"state": "seated",
"status": "Arrived",
/* ...the rest of the reservation object */
}
The reservation object
Every field below is part of the v1 contract. We may add fields; we will not remove, rename or retype one.
NN-4831.booked, seated, departed, cancelled, no_show. Closed for the life of v1 — no new values will appear.first_name, last_name, email, mobile. Unset values are empty strings, never missing keys.11,12 arrives as ["11","12"], so you never have to split an element yourself. Empty array when unallocated — the key is always present.rooms[].name and tables[].room on the venue. Two rooms can each have a table 1, so match a table on its room and its name. Empty string when the booking has no room.4000 is £40.00. Never a decimal. Usually that is the deposit, but a booking charged in full reads the full amount, and there is no companion field for what is still owed: 0 means nothing has been paid, not that nothing is due.online, walk_in, staff.updated_at is what updated_since compares against.{
"id": "res_9c41e0",
"reference": "NN-4831",
"state": "booked",
"status": "Confirmed",
"date": "2026-08-20",
"time": "19:00",
"timezone": "Europe/London",
"duration_minutes": 120,
"adults": 2,
"children": 1,
"guest": {
"first_name": "Jane",
"last_name": "Smith",
"email": "jane.smith@example.com",
"mobile": "+447700900123"
},
"tables": ["12", "13"],
"room": "Restaurant",
"notes": "Window table if possible.",
"deposit_paid_minor": 4000,
"currency": "GBP",
"source": "online",
"created_at": "2026-08-01T10:15:00",
"updated_at": "2026-08-13T18:30:12"
}
Venues
The venue this key belongs to, with its rooms, tables and status labels — the reference data you need to make sense of a reservation. Fetch it when you connect a venue and again when you sync, so a renamed table or a new status reaches your terminal. Needs the venues:read scope.
The list uses the same envelope as List reservations. A key covers one venue today, so the list holds one venue and has_more is always false. The single fetch returns the same object, unwrapped.
Path parameters
id from the list — its public booking code, the last part of its booking link.An id belonging to a different venue returns 404, identical to an id that does not exist.
curl https://api.nomnom-app.net/api/v1/venues \
-H "Authorization: Bearer $NOMNOM_KEY"
{
"data": [ { /* one venue object */ } ],
"has_more": false,
"next_cursor": ""
}
The venue object
Every field below is part of the v1 contract. We may add fields; we will not remove, rename or retype one.
id, name, and online — whether the room is offered on the venue's booking page. In the venue's own order.id, name, room, seats, online. name is exactly the label a reservation carries in its tables, and room is the room's name. A combined table such as 11,12 is not listed: reservations always publish it as its separate tables, so every label you see on a booking is in this list. Two rooms can each have a table with the same name.id, name, state. name is what a reservation carries in status; state is the state a booking in that status reports — booked, seated, departed or no_show. A status whose name is a cancellation (Cancelled) reports cancelled, because putting a booking in it cancels the booking.{
"id": "8b92b865",
"name": "Harbour Kitchen",
"timezone": "Europe/London",
"currency": "GBP",
"address": "12 Quay Street",
"postcode": "PL28 8AA",
"country": "United Kingdom",
"phone": "01841 000000",
"website": "https://harbour.example",
"booking_url": "https://book.nomnom-app.net/8b92b865",
"rooms": [
{ "id": "room-a", "name": "Restaurant", "online": true }
],
"tables": [
{ "id": "tbl-1", "name": "11", "room": "Restaurant",
"seats": 4, "online": true }
],
"statuses": [
{ "id": "sts-1", "name": "Arrived", "state": "seated" }
]
}
Versioning
The version is in the path: /api/v1/. Here is what that buys you.
Within a major version we add, and never remove or change.
We may add: new endpoints, new optional parameters, new response fields.
We will not: remove or rename a field, change its type or nullability, change a default, add a required parameter, tighten validation, or change authentication.
What we ask of you: ignore fields you do not recognise. That single habit is what lets us ship improvements without ever versioning again.
Closed enums stay closed
Adding a value to state would break any client with an exhaustive switch — which, in C#, Java and Delphi, is most of them. So state is frozen for the life of v1. Anything needing new vocabulary goes in the free-text status, which you should never branch on.
If we ever retire a version
You get at least twelve months from the moment a successor is stable, Deprecation and Sunset headers on every response, and direct emails to every key owner. We know EPOS release cycles are slow and often gated on your own customers' upgrade windows.
v1 is currently in preview. The shape above is implemented and frozen by automated contract tests, but while preview lasts we may still refine it in response to what integrators tell us. Build against it; talk to us before you ship to production customers.
// System.Text.Json ignores unknown members by
// default - that is the behaviour you want.
public record Reservation(
string Id,
string Reference,
string State,
string Date,
string Time);
// Branch on State, never on Status.
var seated = rows.Where(r => r.State == "seated");