NomNomAPI
v1 · preview

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"},
)
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. 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.

Prefer webhooks to polling

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.

PrefixModeReads
nn_test_SandboxA seeded demo venue. Not available yet — see below.
nn_live_ProductionThe venue that issued the key. Real bookings.
Sandbox is not live yet

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.

ScopeGrants
reservations:readList and retrieve bookings
reservations:writeCreate, amend, cancel, change state
customers:readLook up guest profiles
availability:readQuery bookable slots
venues:readVenue, rooms, tables, services
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, revoke it from the venue's Integrations screen; revocation takes effect immediately.

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: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:

  • 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, newest 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.
404not_foundNo such record for this key's venue.
429rate_limitedBack off for Retry-After seconds.
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) {
  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

GET/api/v1/reservations

Returns bookings for the venue this key can access, newest first. 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.
limitintegeroptional
1–200. Defaults to 50; larger values are clamped.
cursorstringoptional
From next_cursor on the previous page. Opaque.
Not yet filterable by state

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();
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."
  }
}

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. Empty array when unallocated — the key is always present.
notesstring
The guest's own special requests. Staff-only internal notes are never exposed.
deposit_paid_minorinteger
Deposit taken, in minor units4000 is £40.00. Never a decimal.
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"],
  "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.

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");