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.
- Log in to your Carte admin and open Settings.
- Find the API tokens panel. Give the token a name you'll recognise on a bad day — “kiosk”, “POS bridge”, “Zapier” — not “test”.
- Choose the access level. Read can read everything; read + write can also create bookings and redeem vouchers. Pick read unless you need to write.
- 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.
- Put it somewhere your code can read and a repository cannot: <code>export CARTE_TOKEN=ck_live_…</code>
- Call the API. Every sample on this page works as-is once <code>$CARTE_TOKEN</code> is set.
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.
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>.
https://app.cartelier.app/v1.php/venues/the-copper-pot/menus
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.
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.
{
"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.
{
"error": "This token is read-only"
}
| Status | Message | When |
|---|---|---|
| 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. |
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.
The venue profile: display name, slug, type, currency symbol, strapline, and whether the booking widget is switched on.
{
"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 -s https://app.cartelier.app/v1.php/venues/the-copper-pot \
-H "Authorization: Bearer $CARTE_TOKEN"
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.
{
"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 -s https://app.cartelier.app/v1.php/venues/the-copper-pot/wines \
-H "Authorization: Bearer $CARTE_TOKEN"
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.
| Field | In | Type | Notes |
|---|---|---|---|
| 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. |
{
"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 -s "https://app.cartelier.app/v1.php/venues/the-copper-pot/spaces?type=room" \
-H "Authorization: Bearer $CARTE_TOKEN"
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.
| Field | In | Type | Notes |
|---|---|---|---|
| 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. |
{
"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 -s "https://app.cartelier.app/v1.php/venues/the-copper-pot/bookings?status=confirmed&from=2026-07-01" \
-H "Authorization: Bearer $CARTE_TOKEN"
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.
| Field | In | Type | Notes |
|---|---|---|---|
| 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. |
{
"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."
}
{
"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 -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"}'
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.
{
"voucher": {
"code": "GV-4K2P-9QX7",
"status": "active",
"face_value": 50,
"balance": 30,
"expires": "2028-07-16"
}
}
curl -s https://app.cartelier.app/v1.php/venues/the-copper-pot/vouchers/GV-4K2P-9QX7 \
-H "Authorization: Bearer $CARTE_TOKEN"
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.
| Field | In | Type | Notes |
|---|---|---|---|
| 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". |
{
"amount": 20,
"note": "Table 6, Saturday lunch"
}
{
"redemption": {
"ok": true,
"balance": 30,
"status": "active",
"redeemed": 20
}
}
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.