NomNomAPI
v1 · preview

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"},
)
200 · Response
{
  "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.

Polling is the pattern today

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.

PrefixModeReads
nn_test_SandboxYour own sandbox venue. Fake bookings you are free to change.
nn_live_ProductionThe venue that issued the key. Real bookings.
Your sandbox

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.

ScopeGrantsStatus
reservations:readList and retrieve bookingsLive
reservations:writeChange a booking's state. Creating, amending and cancelling are not here yet.Live
customers:readLook up guest profilesNot in v1
availability:readQuery bookable slotsNot in v1
venues:readThe venue, its rooms, tables and statusesLive
Keep your key secret

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.

Request header
Authorization: Bearer nn_live_a1b2c3d4e5f6g7h8i9j0klmnopqrstuv
401 · Missing or invalid key
{
  "error": {
    "type": "authentication_error",
    "code": "invalid_api_key",
    "message": "The API key provided is not valid.",
    "param": "",
    "request_id": "req_8f2a41"
  }
}
403 · Scope missing
{
  "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:

  • date and time — 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.

Coming, additively

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.

Resolving an instant
// 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
What you receive
{
  "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.

FieldMeaning
dataThe page of results, most recently changed first
has_moretrue when another page exists
next_cursorPass 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.

StatuscodeWhat to do
400invalid_parameterFix the input named in param. Do not retry unchanged.
400invalid_cursorRestart the sweep without a cursor.
401missing_api_keySend the Authorization header.
401invalid_api_keyCheck the key. Same response whether it is malformed or unknown.
401revoked_api_keyThe venue revoked it. Ask for a new one.
403insufficient_scopeThe message names the scope you need.
403wrong_modeThe key does not fit the venue: test keys work only with a sandbox, live keys only with a real venue.
404not_foundNo such record for this key's venue.
409no_matching_statusThe venue has no status for that state. Send a status_id instead.
409reservation_cancelledThe booking is cancelled; its state cannot change.
409table_clashThe change needs a table another party now holds. Ask the venue to move the booking.
429rate_limitedBack 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.
500internal_errorOurs. 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.

On 404s

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 shape
{
  "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

GET/api/v1/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

datestringoptional
A single service date, YYYY-MM-DD, in the venue's local calendar. The usual "what's on tonight" call.
date_from date_tostringoptional
An inclusive range. Ignored if date is given.
updated_sincestringoptional
The sync primitive. Returns only bookings changed at or after this venue-local timestamp, including cancellations. Store the highest updated_at you have seen and pass it back next time.
statestringoptional
Only bookings in these states, comma-separated: 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.
limitintegeroptional
1–200. Defaults to 50; larger values are clamped.
cursorstringoptional
From 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();
200 · Response
{
  "data": [ { /* reservation objects */ } ],
  "has_more": true,
  "next_cursor": "djF8MjAyNi0wOC0xMyAxODozMDoxMnxyZXNf..."
}

Retrieve a reservation

GET/api/v1/reservations/{id}

Fetches a single booking by its NomNom id. Returns the same object shape as the list endpoint, unwrapped.

Path parameters

idstringrequired
The reservation's 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"
404 · Not found
{
  "error": {
    "type": "invalid_request_error",
    "code": "not_found",
    "message": "No reservation found with that id."
  }
}

Change a reservation's state

POST/api/v1/reservations/{id}/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

status_idstring
One of the venue's own statuses, from the venue object. Exact: use it to set a venue's "Starters" or "Mains".
stateenum
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"}'
200 · Response
{
  "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.

idstring
Opaque identifier. Store it as your foreign key.
referencestring
The human booking reference staff and guests quote, e.g. NN-4831.
stateenum
Branch on this. One of booked, seated, departed, cancelled, no_show. Closed for the life of v1 — no new values will appear.
statusstring
Never branch on this. The venue's own label — free text they can rename or invent. One venue runs its whole service through it ("Starters", "Plates Cleared"). Display it; do not parse it.
date timestring
Venue-local wall clock. See Dates & times.
timezonestring
IANA zone name for the venue.
duration_minutesinteger
Booked turn length.
adults childreninteger
Cover counts. Total party size is the sum.
guestobject
first_name, last_name, email, mobile. Unset values are empty strings, never missing keys.
tablesarray
Physical table labels as the venue writes them, one table per element. A party seated across a combined table the venue calls 11,12 arrives as ["11","12"], so you never have to split an element yourself. Empty array when unallocated — the key is always present.
roomstring
The room's name — the same string as 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.
notesstring
The guest's own special requests. Staff-only internal notes are never exposed.
deposit_paid_minorinteger
Total paid against the booking, in minor units — 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.
currencystring
ISO 4217, upper case.
sourcestring
How the booking arrived: online, walk_in, staff.
created_at updated_atstring
Venue-local timestamps. updated_at is what updated_since compares against.
The object in full
{
  "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

GET/api/v1/venues
GET/api/v1/venues/{id}

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

idstringrequired
The venue's 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"
200 · Response
{
  "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.

idstring
The venue's public booking code. Store it as your foreign key for the venue.
name address postcode country phone websitestring
As the venue entered them. Unset values are empty strings.
timezonestring
IANA zone name — the same value every reservation carries.
currencystring
ISO 4217, upper case.
booking_urlstring
The venue's public booking page. Empty if it has none.
roomsarray
id, name, and online — whether the room is offered on the venue's booking page. In the venue's own order.
tablesarray
Physical tables: 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.
statusesarray
The venue's own status labels: 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.
The object in full
{
  "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.

Our promise

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.

Preview

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.

Tolerant reading
// 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");