NomNom Developer Platform
The reservation book, as an API.
Read and write bookings for the venues you integrate. Built for EPOS and till vendors who need today's covers on the terminal, and walk-ins back in the book.
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 — get a test key and make your first call in about five minutes.
curl https://api.nomnom-app.net/api/v1/reservations \
-H "Authorization: Bearer nn_test_..." \
-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. The whole thing runs against a sandbox venue, so nothing you do here touches a real restaurant's book.
1. Get a test key
A venue admin issues you a key from Settings → Integrations. Keys look like nn_test_… for sandbox and nn_live_… for production. The key is shown once — we store only a hash of it and cannot recover it for you.
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.
Polling every few seconds across many venues is the fastest way to make an integration feel slow for everyone. Signed webhooks are coming, and will be the recommended pattern; until then keep polling to once a minute or slower and always pass updated_since.
# 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 | A seeded demo venue. Not available yet — see below. |
| nn_live_ | Production | The venue that issued the key. Real bookings. |
The seeded demo venue is still being built. Until it lands, the
prefix is a label only: an nn_test_ key reads exactly
the same real data as an nn_live_ one. Ask for a live key
against a venue you are happy to read, and treat it accordingly.
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.
| Scope | Grants |
|---|---|
| reservations:read | List and retrieve bookings |
| reservations:write | Create, amend, cancel, change state |
| customers:read | Look up guest profiles |
| availability:read | Query bookable slots |
| venues:read | Venue, rooms, tables, services |
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, revoke it from the venue's Integrations screen; revocation takes effect immediately.
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:write"
}
}
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, newest 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. |
| 404 | not_found | No such record for this key's venue. |
| 429 | rate_limited | Back off for Retry-After seconds. |
| 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) {
const { error } = await res.json();
switch (error.code) {
case 'rate_limited':
return retryAfter(res.headers.get('Retry-After'));
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, newest first. 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.next_cursor on the previous page. Opaque.There is no state query parameter yet. Filter client-side on
the state field instead — it is on every reservation. A
server-side filter is coming, and adding a parameter is additive, so it will
not change anything you build now.
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."
}
}
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.4000 is £40.00. Never a decimal.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"],
"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"
}
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");