For developers
HTTP API reference
Everything the website does is also available over HTTP. Every /api/*
endpoint is gated by a single shared bearer
token (INTERNAL_API_TOKEN) — the voice orchestrator
(ellie_ai) is the only legitimate caller in v1.
Every request to /api/*
must carry Authorization: Bearer $INTERNAL_API_TOKEN. Missing or
wrong token returns 401 Unauthorized. The token is
shared via env between resto and ellie; rotate it by setting the
new value on both services and restarting (no code change needed).
curl -sH "authorization: Bearer $INTERNAL_API_TOKEN" \ https://resto-demo.raidzero.icu/api/customers
Bearer auth says "you're allowed to use the API at all." The cancel_token
says "you specifically own this
reservation." Both are required to mutate or delete a reservation.
-
POST /api/reservationscreates a row and the response body includes both anidand acancel_token. That token is shown once, in that response — it's the only place the server will ever hand it to you. -
Save it. Pass it as
?token=<value>on everyPATCH,PUT, orDELETEfor that reservation's:id. -
There's no token recovery and no admin override. Lose it and the
booking is read-only forever — anyone with the bearer can
GETit, but only the holder of the cancel_token can mutate or cancel it.
localStorage["seasons:tokens"], so the website's
edit/cancel buttons work without re-pasting. The HTTP API has no
such convenience — you carry the token yourself.
GET
/api/menu
▶
Three services (breakfast / lunch / dinner) with prices in cents and dietary tags on each menu item.
{
"services": [
{
"service": "breakfast",
"items": [
{ "name": "Sourdough Toast & Jam",
"price_cents": 700,
"dietary": ["vegan"] },
...
]
},
{ "service": "lunch", "items": [...] },
{ "service": "dinner", "items": [...] }
]
}
GET
/api/tables
▶
The static floor plan — 9 tables totalling 30 seats.
{
"seat_total": 30,
"tables": [
{ "id": "T1", "seats": 2, "shape": "round", "x": 0, "y": 0 },
...
{ "id": "T9", "seats": 6, "shape": "rect", "x": 0, "y": 2 }
]
}
GET
/api/availability?date=YYYY-MM-DD
▶
Per-table list of taken intervals for a date. Empty list means the table is free all day.
date— optional, ISO date. Defaults to today.
{
"date": "2026-05-07",
"tables": [
{
"table_id": "T1",
"reservations": [
{ "id": "uuid",
"table_id": "T1",
"starts_at": "2026-05-07T15:00:00Z",
"ends_at": "2026-05-07T17:00:00Z",
"party_size": 2,
"special_requests": "gluten free",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid",
"salutation": "Ms",
"first_name": "Avery", "last_name": "Chen",
"...": "..." },
"contact_id": "contact-uuid",
"contact": { "id": "contact-uuid", "kind": "phone",
"value": "+14155550142", "preferred": true } }
]
},
{ "table_id": "T2", "reservations": [] },
...
]
}
400—dateisn't a valid ISO date.
GET
/api/customers
▶
List customers, newest activity first. Default limit 500, max 1000.
limit— optional positive integer, max 1000.
{
"customers": [
{ "id": "cust-uuid",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"notes": null,
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-08T18:00:00Z",
"contacts": [
{ "id": "c1", "kind": "phone", "value": "+14155550100",
"label": null, "preferred": true },
{ "id": "c2", "kind": "email", "value": "lois@example.com",
"label": null, "preferred": true }
] },
...
]
}
GET
/api/customers/:id
▶
Fetch one customer by id.
:id— customer UUID.
{
"customer": {
"id": "cust-uuid",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"notes": "regular — vegan",
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-08T18:00:00Z",
"contacts": [
{ "id": "c1", "kind": "phone", "value": "+14155550100",
"label": "mobile", "preferred": true },
{ "id": "c2", "kind": "email", "value": "lois@example.com",
"label": null, "preferred": true }
]
}
}
404— no customer with that id.
GET
/api/customers/by_phone/:phone
▶
Look up a customer by E.164 phone number — the natural key. Walks contacts.value → customer. Used by ellie's lookup-customer waterfall when a call comes in.
-
:phone— E.164 string (URL-encode the leading+as%2B).
{
"customer": {
"id": "cust-uuid",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"notes": "regular — vegan",
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-08T18:00:00Z",
"contacts": [
{ "id": "c1", "kind": "phone", "value": "+14155550100",
"label": "mobile", "preferred": true },
{ "id": "c2", "kind": "email", "value": "lois@example.com",
"label": null, "preferred": true }
]
}
}
404— no contact with that phone number.
POST
/api/customers
▶
Idempotent on phone. Creates a new customer + phone contact if the phone is unseen, otherwise returns the existing customer. If email is provided and the customer has no email contact, adds one. Safe to retry.
phone— required, E.164 string.first_name,last_name— optional.-
salutation— optional, one ofMr,Mrs,Ms. -
email— optional. When provided and unseen, creates a preferred email contact. notes— optional staff free text.
{
"phone": "+14155550100",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"email": "lois@example.com"
}
{
"customer": {
"id": "cust-uuid",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"notes": "regular — vegan",
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-08T18:00:00Z",
"contacts": [
{ "id": "c1", "kind": "phone", "value": "+14155550100",
"label": "mobile", "preferred": true },
{ "id": "c2", "kind": "email", "value": "lois@example.com",
"label": null, "preferred": true }
]
}
}
400 Missing phone— request body had nophone.-
422 phone must be in E.164 format— leading+, then 7..15 digits, first non-zero. 422 must look like an email address.
PATCH
/api/customers/:id
▶
Update a customer's name, email, salutation, or notes. tel is the natural key — change it via a new POST instead.
:id— customer UUID.
{
"email": "lois.tester@example.com",
"notes": "allergic to peanuts"
}
{
"customer": {
"id": "cust-uuid",
"salutation": "Ms",
"first_name": "Lois",
"last_name": "Tester",
"notes": "regular — vegan",
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-08T18:00:00Z",
"contacts": [
{ "id": "c1", "kind": "phone", "value": "+14155550100",
"label": "mobile", "preferred": true },
{ "id": "c2", "kind": "email", "value": "lois@example.com",
"label": null, "preferred": true }
]
}
}
404 Not Found.422— same validation family as POST.
GET
/api/customers/:customer_id/reservations
▶
List one customer's reservations, oldest first. Same JSON shape as /api/reservations.
:customer_id— customer UUID.
{
"reservations": [
{ "id": "uuid",
"table_id": "T1",
"starts_at": "2026-05-07T15:00:00Z",
"ends_at": "2026-05-07T17:00:00Z",
"party_size": 2,
"special_requests": "gluten free",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid", "first_name": "Avery",
"last_name": "Chen", "...": "..." },
"contact_id": "contact-uuid",
"contact": { "kind": "phone", "value": "+14155550142", "...": "..." } },
...
]
}
GET
/api/reservations[?date=YYYY-MM-DD][&customer_id=UUID]
▶
List reservations, optionally filtered to a single calendar day or a single customer.
date— optional, ISO date.customer_id— optional UUID.
{
"reservations": [
{ "id": "uuid",
"table_id": "T1",
"starts_at": "2026-05-07T15:00:00Z",
"ends_at": "2026-05-07T17:00:00Z",
"party_size": 2,
"special_requests": "gluten free",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid", "first_name": "Avery",
"last_name": "Chen", "...": "..." },
"contact_id": "contact-uuid",
"contact": { "kind": "phone", "value": "+14155550142", "...": "..." } },
...
]
}
400— baddate.
GET
/api/reservations/:id
▶
Fetch a single reservation. The cancel_token is never returned by this endpoint — only on creation.
:id— UUID returned at booking time.
{
"reservation": {
"id": "uuid",
"table_id": "T1",
"starts_at": "2026-05-07T15:00:00Z",
"ends_at": "2026-05-07T17:00:00Z",
"party_size": 2,
"special_requests": "gluten free",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid",
"salutation": "Ms",
"first_name": "Avery", "last_name": "Chen",
"notes": null,
"first_seen_at": "2026-04-12T19:00:00Z",
"last_seen_at": "2026-05-07T15:00:00Z",
"contacts": null },
"contact_id": "contact-uuid",
"contact": { "id": "contact-uuid",
"customer_id": "cust-uuid",
"kind": "phone",
"value": "+14155550142",
"label": null,
"preferred": true }
}
}
404— no reservation with that id.
POST
/api/reservations
▶
Create a reservation. Bookings are 2 hours, anchored to a 30-min boundary, between 10:00 and 20:00 local time (the restaurant closes at 22:00). The cancel_token in the response is the only way to mutate the reservation later — store it! Customer must already exist; create them via POST /api/customers first.
table_id— required, one of T1..T9.-
starts_at— required, ISO datetime on a :00 or :30 boundary, local hour 10..20. -
customer_id— required UUID. SeePOST /api/customers. party_size— required integer, 1..table seats.special_requests— optional free text.remarks— optional free text.
{
"table_id": "T5",
"starts_at": "2026-05-08T18:00:00Z",
"customer_id": "cust-uuid",
"party_size": 3,
"special_requests": "vegan tasting menu"
}
{
"reservation": {
"id": "uuid",
"cancel_token": "QMyYa1Iv4c5Rs7Itx3VPtg",
"table_id": "T5",
"starts_at": "2026-05-08T18:00:00Z",
"ends_at": "2026-05-08T20:00:00Z",
"party_size": 3,
"special_requests": "vegan tasting menu",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid", "tel": "+14155550100", "...": "..." }
}
}
# 1. ensure the customer exists (idempotent on phone) and grab the id
CUST_ID=$(curl -sX POST localhost:4000/api/customers \
-H "authorization: Bearer $INTERNAL_API_TOKEN" \
-H 'content-type: application/json' \
-d '{"phone":"+14155550100","first_name":"Lois","last_name":"Tester","email":"lois@example.com"}' \
| jq -r .customer.id)
# 2. book — capture both id and cancel_token from the response
curl -sX POST localhost:4000/api/reservations \
-H "authorization: Bearer $INTERNAL_API_TOKEN" \
-H 'content-type: application/json' \
-d "{
\"table_id\": \"T5\",
\"starts_at\": \"2026-05-08T18:00:00Z\",
\"customer_id\": \"$CUST_ID\",
\"party_size\": 3,
\"special_requests\": \"vegan\"
}"
# → { "reservation": { "id": "...", "cancel_token": "...", ... } }
-
422 unknown table T999—table_idnot in the floor plan. -
422 must align to a 30-minute slot— badstarts_atminutes. 422 must be between 10:00 and 20:00— outside opening hours.422 table is already booked for this time slot— overlap.-
422 does not exist—customer_iddoesn't match any customer. 422 must be greater than 0— non-positiveparty_size.422 is more than the table's N seats— too many for this table.422 can't be blank— missing required field.
PATCH
/api/reservations/:id?token=…
▶
Partial update. Send any subset of the booking fields. The same validations as create apply, plus an overlap check that excludes the row being updated.
-
:id— reservation UUID returned byPOST /api/reservations. -
token— required query param. Pass thecancel_tokenstring from the originalPOSTresponse. Same value as long as the reservation exists; never rotated.
{
"starts_at": "2026-05-08T19:00:00Z",
"party_size": 4,
"special_requests": "vegan + nut allergy"
}
{
"reservation": {
"id": "uuid",
"table_id": "T5",
"starts_at": "2026-05-08T19:00:00Z",
"ends_at": "2026-05-08T21:00:00Z",
"party_size": 4,
"special_requests": "vegan + nut allergy",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid", "...": "..." }
}
}
# ID and TOKEN come from the POST /api/reservations response above
ID="69f3b15c-d7b4-42ea-a319-d99a2f766fd8"
TOKEN="QMyYa1Iv4c5Rs7Itx3VPtg"
curl -sX PATCH "localhost:4000/api/reservations/$ID?token=$TOKEN" \
-H "authorization: Bearer $INTERNAL_API_TOKEN" \
-H 'content-type: application/json' \
-d '{ "party_size": 4, "special_requests": "vegan + nut allergy" }'
-
400 Missing token query parameter— you forgot?token=…. 403 Invalid cancel token— token doesn't match this reservation.-
404 Not Found— no reservation with that:id(or it was already cancelled). 422— same family of validation errors as POST.
PUT
/api/reservations/:id?token=…
▶
Full replace. Same handler as PATCH — Phoenix routes both verbs to the update action. Send all required fields.
-
:id— reservation UUID from the originalPOSTresponse. -
token— required query param. Thecancel_tokenfromPOST /api/reservations— same value used by PATCH and DELETE.
{
"table_id": "T5",
"starts_at": "2026-05-08T19:00:00Z",
"customer_id": "cust-uuid",
"party_size": 4,
"special_requests": "vegan + nut allergy"
}
{
"reservation": {
"id": "uuid",
"table_id": "T5",
"starts_at": "2026-05-08T19:00:00Z",
"ends_at": "2026-05-08T21:00:00Z",
"party_size": 4,
"special_requests": "vegan + nut allergy",
"remarks": null,
"customer_id": "cust-uuid",
"customer": { "id": "cust-uuid", "...": "..." }
}
}
# PUT requires all the required fields. ID and TOKEN are still from POST.
curl -sX PUT "localhost:4000/api/reservations/$ID?token=$TOKEN" \
-H "authorization: Bearer $INTERNAL_API_TOKEN" \
-H 'content-type: application/json' \
-d "{
\"table_id\": \"T5\",
\"starts_at\": \"2026-05-08T19:00:00Z\",
\"customer_id\": \"$CUST_ID\",
\"party_size\": 4,
\"special_requests\": \"vegan + nut allergy\"
}"
400 Missing token query parameter.403 Invalid cancel token.404 Not Found.-
422— same validation errors as POST (alignment, table fit, overlap, missing fields).
DELETE
/api/reservations/:id?token=…
▶
Cancel a reservation. Constant-time token check — bad tokens get a 403, not a 404.
:id— reservation UUID from thePOSTresponse.-
token— required query param. Thecancel_tokenfromPOST /api/reservations. Once the reservation is deleted the token is gone too — no undo.
204 No Content (empty body)
# same TOKEN as PATCH/PUT — there's only ever one per reservation curl -i -X DELETE "localhost:4000/api/reservations/$ID?token=$TOKEN" \ -H "authorization: Bearer $INTERNAL_API_TOKEN" # → HTTP/1.1 204 No Content
400 Missing token query parameter.403 Invalid cancel token.404 Not Found.