New: three demo venues, live — a restaurant, a hotel and a pub. Explore the live demo

For developers

A REST API that's
boring in the best way.

Nine endpoints. One bearer token. Predictable JSON, honest errors, and a documented rate limit. Everything below is generated from the same source the server routes on, so it cannot drift out of date.

Get a token and make your first call

Two minutes, start to finish.

  1. Log in to your Carte admin and open Settings.
  2. Find the API tokens panel. Give the token a name you'll recognise on a bad day — “kiosk”, “POS bridge”, “Zapier” — not “test”.
  3. Choose the access level. Read can read everything; read + write can also create bookings and redeem vouchers. Pick read unless you need to write.
  4. Press Create token. The full <code>ck_live_…</code> value is shown once, on that screen, and never again — we only ever store its SHA-256 hash, so we genuinely cannot show it to you a second time or recover it if you lose it. Copy it now.
  5. Put it somewhere your code can read and a repository cannot: <code>export CARTE_TOKEN=ck_live_…</code>
  6. Call the API. Every sample on this page works as-is once <code>$CARTE_TOKEN</code> is set.
Terminal
export CARTE_TOKEN=ck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/menus \
  -H "Authorization: Bearer $CARTE_TOKEN"

Authentication

Every request needs a bearer token. Tokens are per venue — a token for one venue cannot read another. The value is <code>ck_live_</code> followed by 40 hex characters.

HTTP header
Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Two scopes: read and write. Any request that is not a GET requires write — checked before routing, so a read-only token gets 403 even for a path that doesn't exist.
  • Tokens are stored as a SHA-256 hash and shown once at creation. Lost means lost: revoke it and mint another.
  • Revoking is immediate. Anything using that token starts getting 401 on its next call.
  • Server-to-server only. The API sends no CORS headers, so a browser on your own domain cannot call it directly — and that is deliberate. Calling it from front-end JavaScript would put your token in every visitor's devtools. Proxy through your own backend.
  • Carte stamps a “last used” time on each token so you can spot one that has gone quiet — or one that shouldn't be busy.

Base URL & routing

The canonical form puts the path after the script name, as <code>PATH_INFO</code>. There is no pretty <code>/v1/…</code> rewrite — nginx hands <code>.php</code> straight to FPM on this host, so <code>/v1.php</code> is part of the URL, not an implementation detail leaking through. A <code>?path=</code> query fallback exists for servers that strip <code>PATH_INFO</code>.

Canonical
https://app.cartelier.app/v1.php/venues/the-copper-pot/menus
Fallback — only if your client or proxy strips PATH_INFO
https://app.cartelier.app/v1.php?path=/venues/the-copper-pot/menus

Rate limits

120 requests per 60 seconds, counted per token — not per IP, not per venue. It is a sliding window: each request is stamped, and stamps older than the window are discarded, so the allowance refills continuously rather than resetting on the minute. Two tokens on the same venue get an allowance each.

There are no rate-limit headers. Carte sends no X-RateLimit-Limit, no X-RateLimit-Remaining and no Retry-After. There is no way to read your remaining allowance without spending it. Over the limit you get a 429 with the usual error body and nothing else — so back off on your side: wait a few seconds and retry, and don't hammer a 429 in a tight loop. We would rather say this plainly than let you discover it in production.
HTTP/1.1 429
{
  "error": "Rate limit exceeded — 120 requests per minute"
}

Errors & status codes

Every failure is the same flat shape — one error key, a human-readable sentence, no error codes and no nesting. Check the HTTP status; the string is written for a person to read, so it is safe to show a member of staff, but do not branch on it.

Any failure
{
  "error": "This token is read-only"
}
StatusMessageWhen
200 Success on every GET, and on POST …/redeem — nothing is created by a redemption, so it is not a 201.
201 Success on POST …/bookings. The booking exists and the confirmation email has been sent.
401 Invalid or missing API token No Authorization header, a header that is not "Bearer …", a value that does not start with ck_live_, or a token that has been revoked. Carte cannot tell these apart on purpose.
403 This token is read-only Any non-GET request with a token that lacks the write scope. Checked before routing, so it fires even on a path that does not exist.
403 Token has no scopes The token record carries neither read nor write. Not reachable through the admin UI, which always grants at least read — it exists to fail closed.
404 Not found — try /venues/{slug}/menus The path has fewer than two segments, or does not begin with /venues. This is what a bare GET /v1.php returns.
404 Venue not found No venue has that slug. Returned before authentication, so slugs are discoverable without a token — they are public anyway (they are in your menu URLs).
404 Menu not found No live menu has that id. A draft menu returns exactly this, so drafts stay invisible.
404 Voucher code required GET or POST on /vouchers with no code segment.
404 Voucher not found No voucher with that code, or the voucher belongs to a different venue.
404 Unknown resource The venue and token are fine but the resource segment is not one of menus, wines, spaces, bookings or vouchers.
422 Could not create booking Fallback only. In practice you get the specific reason instead — "Please tell us your name.", "That email address doesn't look right.", "Party size must be between 1 and 40.", "Choose a date.", "Pick a time.", "Check-out must be after check-in.", "That date has already been.", "Sorry — that time has just been taken.", and so on. Show it to the user: the strings are written to be read.
422 Could not redeem Fallback only. In practice: "Voucher not found.", "This voucher has expired.", "Voucher is void.", "Redemption amount must be positive.", or "Only £30.00 left on this voucher."
429 Rate limit exceeded — 120 requests per minute More than 120 requests in the last 60 seconds on this token. See the rate-limit section: no Retry-After header is sent.
Two things the API does not do. There is no 405: a POST to a read-only path such as /wines is not rejected for its method — provided the token has the write scope, it is served exactly like the GET, returning 200 and the list. Use the right verb; nothing will stop you if you don't. And there is no 500 handler: an unexpected server fault surfaces as a PHP-level error, not as JSON, so treat any response your JSON parser rejects as a transport failure and retry.

Endpoints

All nine, against base URL https://app.cartelier.app/v1.php. Success is always a single JSON object keyed by the resource name. Every example below is real output from the demo venue, the-copper-pot.

GET /venues/{slug} read 200

The venue profile: display name, slug, type, currency symbol, strapline, and whether the booking widget is switched on.

200 OK
{
  "venue": {
    "name": "The Copper Pot",
    "slug": "the-copper-pot",
    "type": "restaurant",
    "currency": "£",
    "strapline": "Restaurant with rooms in the heart of Bath",
    "booking_enabled": true
  }
}
curl
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot \
  -H "Authorization: Bearer $CARTE_TOKEN"
GET /venues/{slug}/menus read 200

Every published menu, with sections and dishes. Draft menus are never returned. Dishes that are 86'd are omitted, and a section with no remaining dishes is omitted with them — so an empty menu comes back as an empty "sections" list, not an error.

200 OK
{
  "menus": [
    {
      "id": "mdinner01",
      "name": "Dinner — Spring",
      "updated": "2026-07-06T17:40:34+00:00",
      "sections": [
        {
          "name": "To begin",
          "dishes": [
            {
              "id": "d7321b40e4b",
              "name": "Cured chalk-stream trout",
              "desc": "pickled fennel, buttermilk, dill oil",
              "price": 12.5,
              "tags": ["gf"],
              "allergens": ["fish", "milk", "sulphites"]
            }
          ]
        }
      ]
    }
  ]
}
curl
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/menus \
  -H "Authorization: Bearer $CARTE_TOKEN"
GET /venues/{slug}/menus/{id} read 200

One published menu by id, in the same shape as a member of the list. A draft menu returns 404 "Menu not found" — the same response an unknown id gives, so a draft is indistinguishable from a typo by design.

200 OK
{
  "menu": {
    "id": "mdinner01",
    "name": "Dinner — Spring",
    "updated": "2026-07-06T17:40:34+00:00",
    "sections": [
      {
        "name": "To begin",
        "dishes": [
          {
            "id": "d7321b40e4b",
            "name": "Cured chalk-stream trout",
            "desc": "pickled fennel, buttermilk, dill oil",
            "price": 12.5,
            "tags": ["gf"],
            "allergens": ["fish", "milk", "sulphites"]
          }
        ]
      }
    ]
  }
}
curl
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/menus/mdinner01 \
  -H "Authorization: Bearer $CARTE_TOKEN"
GET /venues/{slug}/wines read 200

The cellar. Only bins with stock above zero are returned — the stock figure itself is not exposed. "prices" always carries all three formats; a format the venue does not pour is 0, not null.

200 OK
{
  "wines": [
    {
      "id": "w173b9cb432",
      "bin": 34,
      "name": "Meursault",
      "producer": "Domaine Roulot",
      "vintage": 2021,
      "region": "Burgundy · France",
      "grape": "Chardonnay",
      "style": "White — taut, mineral",
      "prices": { "glass": 18, "carafe": 58, "bottle": 86 }
    }
  ]
}
curl
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/wines \
  -H "Authorization: Bearer $CARTE_TOKEN"
GET /venues/{slug}/spaces read 200

Bookable spaces. Archived spaces are never returned. This lists what exists, not what is free — there is no availability endpoint on /v1; POST a booking and let it succeed or fail.

FieldInTypeNotes
type query string Exact match on one of room | apartment | venue | table. An unrecognised value is not an error — it simply matches nothing and returns an empty list.
200 OK
{
  "spaces": [
    {
      "id": 22,
      "type": "room",
      "name": "Garden Room",
      "description": "King bed, roll-top bath, view over the kitchen garden.",
      "capacity": 2,
      "quantity": 3,
      "price": 145,
      "price_unit": "night"
    }
  ]
}
curl
curl -s "https://app.cartelier.app/v1.php/venues/the-copper-pot/spaces?type=room" \
  -H "Authorization: Bearer $CARTE_TOKEN"
GET /venues/{slug}/bookings read 200

The diary, ordered by date ascending (earliest first), then time, then id. Hard-capped at 500 rows with no pagination and no total count: if you have more than 500 matching bookings, narrow with ?from=. Guest email, phone, notes and pre-orders are deliberately not exposed.

FieldInTypeNotes
status query string One of pending | confirmed | cancelled. Anything else — including a misspelling — is silently ignored and you get the unfiltered list, not a 422.
from query YYYY-MM-DD Keeps bookings whose date_to is strictly after this date. It filters on the END of a stay, so an in-progress stay that started earlier is still returned. An unparseable date is silently ignored.
200 OK
{
  "bookings": [
    {
      "ref": "BK-YSXGBS",
      "status": "confirmed",
      "space": "The Dining Room",
      "guest_name": "Ana Petrova",
      "party_size": 4,
      "date_from": "2026-07-06",
      "date_to": "2026-07-07",
      "time": "19:30",
      "total": null,
      "created": "2026-07-06T17:40:34+00:00"
    }
  ]
}
curl
curl -s "https://app.cartelier.app/v1.php/venues/the-copper-pot/bookings?status=confirmed&from=2026-07-01" \
  -H "Authorization: Bearer $CARTE_TOKEN"
POST /venues/{slug}/bookings write 201

Create a booking. Runs the same validation, availability check and confirmation email as the public widget. Returns 201 with the created booking. Every validation failure is a 422 carrying the human-readable reason — see the error table.

FieldInTypeNotes
resource required body integer Space id, from GET /spaces.
name required body string Guest name. Trimmed to 120 characters.
email required body string Must pass PHP's FILTER_VALIDATE_EMAIL and be 200 characters or fewer. The confirmation email goes here.
phone body string Trimmed to 50 characters.
party required body integer 1 to 40, and never more than the space's capacity.
from required body YYYY-MM-DD Arrival / check-in. Cannot be in the past, and cannot be further ahead than the venue's max_advance_days.
to body YYYY-MM-DD Required and must be after "from" for room and apartment spaces. For venue and table spaces it is ignored and defaults to the next day. Stays over 60 nights are rejected.
time body HH:MM Required for table spaces only, and must match a bookable slot exactly. Ignored for every other type.
notes body string Trimmed to 2000 characters.
preorder body object Optional pre-order, same shape the widget posts. Anything that is not an object is dropped.
Request body
{
  "resource": 22,
  "name": "Ana Petrova",
  "email": "ana@example.com",
  "phone": "07700 900123",
  "party": 2,
  "from": "2026-08-14",
  "to": "2026-08-16",
  "notes": "Late arrival, around 9pm."
}
201 Created
{
  "booking": {
    "ref": "BK-YSXGBS",
    "status": "confirmed",
    "space": "Garden Room",
    "guest_name": "Ana Petrova",
    "party_size": 2,
    "date_from": "2026-08-14",
    "date_to": "2026-08-16",
    "time": null,
    "total": 290,
    "created": "2026-07-16T09:12:00+00:00"
  }
}
curl
curl -s -X POST https://app.cartelier.app/v1.php/venues/the-copper-pot/bookings \
  -H "Authorization: Bearer $CARTE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"resource":22,"name":"Ana Petrova","email":"ana@example.com","party":2,"from":"2026-08-14","to":"2026-08-16"}'
GET /venues/{slug}/vouchers/{code} read 200

Look a gift voucher up by its code. Status is one of pending | active | redeemed | expired | void. Purchaser and recipient details are never exposed. A voucher belonging to another venue returns 404, not 403 — codes are not enumerable across venues.

200 OK
{
  "voucher": {
    "code": "GV-4K2P-9QX7",
    "status": "active",
    "face_value": 50,
    "balance": 30,
    "expires": "2028-07-16"
  }
}
curl
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/vouchers/GV-4K2P-9QX7 \
  -H "Authorization: Bearer $CARTE_TOKEN"
POST /venues/{slug}/vouchers/{code}/redeem write 200

Redeem an amount against a voucher, transactionally. Partial redemption is supported: the balance falls and the status stays "active" until it reaches zero, at which point it becomes "redeemed". Returns 200 — NOT 201, because nothing is created. This is the only endpoint whose body carries an "ok" key; it is the raw internal return value and is kept for compatibility.

FieldInTypeNotes
amount required body number Must be greater than zero and no more than the remaining balance. Rounded to 2dp.
note body string Free text stored against the redemption, trimmed to 200 characters. Defaults to "API redemption".
Request body
{
  "amount": 20,
  "note": "Table 6, Saturday lunch"
}
200 OK
{
  "redemption": {
    "ok": true,
    "balance": 30,
    "status": "active",
    "redeemed": 20
  }
}
curl
curl -s -X POST https://app.cartelier.app/v1.php/venues/the-copper-pot/vouchers/GV-4K2P-9QX7/redeem \
  -H "Authorization: Bearer $CARTE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":20,"note":"Table 6, Saturday lunch"}'

Per venue type

  • Restaurant: Pull /menus into your kiosk or signage; POST /bookings from your own reservation form.
  • Hotel: List /spaces?type=room, create /bookings, and check voucher balances at check-in.
  • Pub: Show /menus on your site, take table /bookings, and redeem roast vouchers at the till.

Keep reading

  • Webhooks — all nine events, payload shapes, and how to verify the signature.
  • Integrations reference — every connector, what it needs, and whether it's live or simulated.

Service starts now

Tonight's menu,
everywhere at once.

Start your 14-day free trial. No card required, no designer round-trips, no stale PDFs.

14 days free  ·  Cancel anytime  ·  Set up in an afternoon

See it live

Three demo venues are running right now.

A restaurant, a hotel and a pub, each built on Carte. The booking widgets on them are real — book a table and the booking lands in the admin behind it, which you can open too.

Open the demo admin

Your own private copy of the admin, built in one click. No signup, no card.