← back to floor plan

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.

Authentication

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
How cancel tokens work

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.

  1. POST /api/reservations creates a row and the response body includes both an id and a cancel_token. That token is shown once, in that response — it's the only place the server will ever hand it to you.
  2. Save it. Pass it as ?token=<value> on every PATCH, PUT, or DELETE for that reservation's :id.
  3. There's no token recovery and no admin override. Lose it and the booking is read-only forever — anyone with the bearer can GET it, but only the holder of the cancel_token can mutate or cancel it.
The browser stashes tokens for bookings made on this device in 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.

Response
{
  "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.

Response
{
  "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.

Parameters
  • date — optional, ISO date. Defaults to today.
Response
{
  "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": [] },
    ...
  ]
}
Errors
  • 400 — date isn't a valid ISO date.
GET /api/customers ▶

List customers, newest activity first. Default limit 500, max 1000.

Parameters
  • limit — optional positive integer, max 1000.
Response
{
  "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.

Parameters
  • :id — customer UUID.
Response
{
  "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 }
    ]
  }
}
Errors
  • 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.

Parameters
  • :phone — E.164 string (URL-encode the leading + as %2B).
Response
{
  "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 }
    ]
  }
}
Errors
  • 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.

Parameters
  • phone — required, E.164 string.
  • first_name, last_name — optional.
  • salutation — optional, one of Mr, Mrs, Ms.
  • email — optional. When provided and unseen, creates a preferred email contact.
  • notes — optional staff free text.
Request body
{
  "phone": "+14155550100",
  "salutation": "Ms",
  "first_name": "Lois",
  "last_name": "Tester",
  "email": "lois@example.com"
}
Response
{
  "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 }
    ]
  }
}
Errors
  • 400 Missing phone — request body had no phone.
  • 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.

Parameters
  • :id — customer UUID.
Request body
{
  "email": "lois.tester@example.com",
  "notes": "allergic to peanuts"
}
Response
{
  "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 }
    ]
  }
}
Errors
  • 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.

Parameters
  • :customer_id — customer UUID.
Response
{
  "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.

Parameters
  • date — optional, ISO date.
  • customer_id — optional UUID.
Response
{
  "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", "...": "..." } },
    ...
  ]
}
Errors
  • 400 — bad date.
GET /api/reservations/:id ▶

Fetch a single reservation. The cancel_token is never returned by this endpoint — only on creation.

Parameters
  • :id — UUID returned at booking time.
Response
{
  "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 }
  }
}
Errors
  • 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.

Parameters
  • 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. See POST /api/customers.
  • party_size — required integer, 1..table seats.
  • special_requests — optional free text.
  • remarks — optional free text.
Request body
{
  "table_id": "T5",
  "starts_at": "2026-05-08T18:00:00Z",
  "customer_id": "cust-uuid",
  "party_size": 3,
  "special_requests": "vegan tasting menu"
}
Response
{
  "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", "...": "..." }
  }
}
Example (curl)
# 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": "...", ... } }
Errors
  • 422 unknown table T999 — table_id not in the floor plan.
  • 422 must align to a 30-minute slot — bad starts_at minutes.
  • 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_id doesn't match any customer.
  • 422 must be greater than 0 — non-positive party_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.

Parameters
  • :id — reservation UUID returned by POST /api/reservations.
  • token — required query param. Pass the cancel_token string from the original POST response. Same value as long as the reservation exists; never rotated.
Request body
{
  "starts_at": "2026-05-08T19:00:00Z",
  "party_size": 4,
  "special_requests": "vegan + nut allergy"
}
Response
{
  "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", "...": "..." }
  }
}
Example (curl)
# 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" }'
Errors
  • 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.

Parameters
  • :id — reservation UUID from the original POST response.
  • token — required query param. The cancel_token from POST /api/reservations — same value used by PATCH and DELETE.
Request body
{
  "table_id": "T5",
  "starts_at": "2026-05-08T19:00:00Z",
  "customer_id": "cust-uuid",
  "party_size": 4,
  "special_requests": "vegan + nut allergy"
}
Response
{
  "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", "...": "..." }
  }
}
Example (curl)
# 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\"
  }"
Errors
  • 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.

Parameters
  • :id — reservation UUID from the POST response.
  • token — required query param. The cancel_token from POST /api/reservations. Once the reservation is deleted the token is gone too — no undo.
Response
204 No Content (empty body)
Example (curl)
# 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
Errors
  • 400 Missing token query parameter.
  • 403 Invalid cancel token.
  • 404 Not Found.