Skip to content
For Integrators & IT evaluators · 12-minute read

API reference

PrepTable exposes a JSON HTTP API at /api/v1/ for the web app, for the Saavor POS webhook, and for any future kitchen-display hardware. This page is the contract surface a reviewer needs to understand the integration footprint.

The API is the only programmatically documented contract. The internal Go stores and SQL migrations are not public interfaces and may change without notice.

Conventions

Versioned path

Every route is prefixed /api/v1/. Breaking changes will land under a new prefix (/api/v2/), not in-place. The current version is stable and will not change before the next major release.

Most endpoints require an authenticated session. Authenticate once via the WebAuthn login flow (see Auth below); the server returns Set-Cookie: preptable_session=..., and all subsequent requests carry the cookie. There is no bearer-token or API-key alternative.

The cookie is HTTP-only, SameSite=Lax, and Secure whenever the request arrives over HTTPS (auto-detected from WEBAUTHN_RP_ORIGIN).

JSON

Requests and responses are application/json unless otherwise noted (text/csv for the two export endpoints). Top-level shapes are documented per route below; timestamps are RFC 3339 UTC. Currency values are integer cents (int64).

Errors

Errors return plain-text HTTP bodies with the appropriate 4xx or 5xx status. There is no JSON error envelope. For example:

http
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8

Forbidden

The three most common error codes:

CodeMeaning
400 Bad RequestMissing or malformed parameter, including an unrecognized ?format= value
401 UnauthorizedNo session, or session expired — see Authentication lifecycle
403 ForbiddenAuthenticated, but role/scope check failed (e.g., a manager trying to write to a campus they're not assigned to)
404 Not FoundResource ID doesn't exist (or, for campus-scoped resources, exists but is outside the caller's scope — reads return an empty list in that case instead)
500 Internal Server ErrorUnexpected failure; the server has logged the stack trace

IDs

All resource identifiers are UUIDs (RFC 4122 v4). They appear as strings in JSON.

Role gating

Three human roles, plus a kiosk service account, with distinct capabilities per route. The full role design is in Roles & permissions; the short version:

RoleWhoWhat they can do
staffBack-of-house workerRead everything in their campus. Write operational data (counts, waste, placements, dayparts). Cannot manage users, locations, vendors, or menus. Campus scoping is a deferred follow-on; in v1, staff reads/writes apply tenant-wide.
managerCampus managerEverything staff can do, plus write access to vendor/PO/meal/days-and-placements data for the campuses they're assigned to via user_locations. Reads scoped same way. Cannot manage users, locations, or menus.
adminApplication adminEverything everywhere. Only role that can manage users, locations, kiosk tokens, and read the audit log.
kioskService accountOne per location. Holds a single long-lived token; exchanges it for a session via /api/v1/signage/auth. Used by signage displays.

The role is enforced server-side on every route. The API performs three checks:

  1. Authentication — session cookie valid? (401 if not)
  2. Role gate — does the role permit this route at all? (403 if not; e.g., a staff user hitting POST /api/v1/admin/users)
  3. Scope gate — for resources with a location_id, does the user's user_locations membership cover that location_id? (403 on writes; empty list on reads)

Routes that include adminMW in their registration fail at the role gate for non-admins. Routes that include requireManagerOfLocation fail at the scope gate for managers whose assignments don't include the target campus. Admins pass both gates implicitly.

Authentication lifecycle

The login flow is WebAuthn (FIDO2) only. There is no password fallback.

First-run bootstrap

When the users table has no admins, the API creates a one-time admin invitation on startup. The bootstrap URL is printed to stdout in dev mode (or only when no admin exists in prod). Visiting it in a browser starts the WebAuthn registration flow.

After bootstrap, every new admin is created through the invitations UI.

Register

http
POST /api/v1/auth/register/options
POST /api/v1/auth/register/verify

Two-step WebAuthn ceremony. The first call returns the challenge and the relying-party info; the browser produces the attestation; the second call verifies it and writes the user row. The verify response sets the session cookie.

Login

http
POST /api/v1/auth/login/options
POST /api/v1/auth/login/verify

Same two-step shape as register. Used by all subsequent logins.

Session management

http
GET    /api/v1/auth/me
POST   /api/v1/auth/logout
GET    /api/v1/auth/sessions
POST   /api/v1/auth/sessions/revoke

/me returns the current user (id, email, role, status). /sessions lists all active sessions for the current user across devices; /sessions/revoke destroys one by ID.

Passkey management

http
GET    /api/v1/auth/credentials
POST   /api/v1/auth/credentials/options
POST   /api/v1/auth/credentials/verify
DELETE /api/v1/auth/credentials/{credID}

Add a second passkey to the same account (recommended), or remove one. The system refuses to remove the user's last credential — there must be at least one passkey at all times.

Public endpoints (no session)

MethodPathPurpose
GET/healthzLiveness probe. Returns 200 OK with ok body. Bypasses auth.
GET/api/v1/healthzSame, under the versioned prefix.
GET/api/v1/auth/invitations/{token}Look up an invitation by its token. Used by the registration page.
POST/api/v1/auth/register/{options,verify}WebAuthn registration ceremony.
POST/api/v1/auth/login/{options,verify}WebAuthn login ceremony.
POST/api/v1/signage/authKiosk token exchange. Body: {"token": "..."}. Returns a session cookie scoped to the kiosk's location.
POST/api/v1/saavor/webhookSaavor POS webhook (HMAC-signed; signature is the only auth). Receives order events to project onto the kitchen display.

The Saavor webhook is signed with HMAC-SHA256 over the raw request body using a shared secret. The signature is sent as the X-Saavor-Signature header. See Kiosk & integrations for the format and a debug example.

Stores

A store is a stocked location inside a campus location (e.g., "Walk-in cooler at Harlingen"). Campus-scoped reads and writes go through these routes.

http
GET    /api/v1/stores
GET    /api/v1/stores/{storeID}
POST   /api/v1/stores                  — admin only (write)
PUT    /api/v1/stores/{storeID}        — campus-scoped write
DELETE /api/v1/stores/{storeID}        — campus-scoped write
GET    /api/v1/stores/{storeID}/inventory
PUT    /api/v1/stores/{storeID}/items/{itemID}

PUT .../items/{itemID} is the set-quantity call: writes both the current stock level and a sibling stock_movements ledger row in one transaction. The body is {"quantity": 12.5, "reason": "receive" | "adjust" | "count_correction"}.

GET /stores is filtered by the caller's scope by default; pass ?location_id=<uuid> to scope to one campus.

Items (catalog)

The catalog is tenant-global — items.sku is UNIQUE across the whole install, never per campus. Writes here are admin-only.

http
GET    /api/v1/items
POST   /api/v1/items                          — admin only
GET    /api/v1/items/{itemID}
PUT    /api/v1/items/{itemID}                 — admin only
DELETE /api/v1/items/{itemID}                 — admin only
GET    /api/v1/items/{itemID}/inventory
POST   /api/v1/items/{itemID}/stores/{storeID}/adjust

The adjust route writes a stock movement with a signed positive-or-negative delta; the usage rollup queries the ledger.

Low-stock report (CSV-exportable)

http
GET /api/v1/items/low?location_id={uuid}&format=json|csv

Returns items at or below the reorder threshold at a campus. JSON shape:

json
{
  "low_stock": [
    {
      "item_id": "...",
      "item_name": "Chicken thigh, boneless",
      "item_sku": "PRO-CHK-THGH-50",
      "unit": "lb",
      "current_stock": 12.0,
      "par_level": 60.0,
      "deficit": 48.0
    }
  ]
}

Add ?format=csv to receive a text/csv download (filename low-stock-report.csv) with columns:

item_name,item_sku,unit,current_stock,par_level,deficit

See Data export for the rationale behind the format and what the columns mean downstream.

Locations

A location is a campus. Lists every campus in the install; admins create and edit them.

http
GET    /api/v1/locations
POST   /api/v1/locations                              — admin only
GET    /api/v1/locations/{locationID}
PUT    /api/v1/locations/{locationID}                 — admin only
DELETE /api/v1/locations/{locationID}                 — admin only
GET    /api/v1/locations/{locationID}/inventory
POST   /api/v1/locations/{locationID}/kiosk_token/rotate  — admin only
POST   /api/v1/locations/kiosk_token/rotate_all           — admin only

The kiosk-token rotation routes invalidate and re-issue the per-location long-lived token used by signage. The rotate_all form exists for fleet-wide key rotation.

A location is the unit of campus scoping: every operational resource that carries location_id is gated by requireManagerOfLocation against the caller's user_locations rows.

Meals

A meal is a recipe with a bill of materials. The meal-cost rollup computes unit_cost_cents from the SUM of its components' costs.

http
GET    /api/v1/meals
POST   /api/v1/meals                       — admin-only write (catalog)
GET    /api/v1/meals/{mealID}
PUT    /api/v1/meals/{mealID}              — admin-only write
DELETE /api/v1/meals/{mealID}              — admin-only write

Meals are tenant-global, like items. Writes are admin-scoped because the cost rollup feeds every campus.

A menu is a named schedule (e.g., "Fall lunch"); placements attach meals or items to specific dayparts (e.g., "Monday lunch in Waco"); the resolver returns the live placement for a given location.

http
GET    /api/v1/menus
POST   /api/v1/menus                       — admin only
GET    /api/v1/menus/{menuID}
PUT    /api/v1/menus/{menuID}              — admin only
DELETE /api/v1/menus/{menuID}              — admin only

GET    /api/v1/locations/{locationID}/dayparts
POST   /api/v1/locations/{locationID}/dayparts   — manager-of-location
PUT    /api/v1/dayparts/{daypartID}
DELETE /api/v1/dayparts/{daypartID}

GET    /api/v1/locations/{locationID}/placements
POST   /api/v1/locations/{locationID}/placements — manager-of-location
PUT    /api/v1/placements/{placementID}
DELETE /api/v1/placements/{placementID}

GET    /api/v1/locations/{locationID}/menu/current

Menu CRUD is admin-only because menu definitions are tenant-global (a per-campus manager shouldn't be able to edit a menu other campuses depend on). Placements — the per-campus operational layer above the menu — are manager-of-location gated.

http
GET /api/v1/menu-health?location_id={uuid}

Runs the deterministic variety + cost-outlier checks against the current placements for a campus. Returns a list of findings, each with a code and a free-text explanation. Manager-of-location can view; staff cannot.

Vendors and purchase orders

http
GET    /api/v1/vendors
POST   /api/v1/vendors                       — admin write (catalog)
GET    /api/v1/vendors/{vendorID}
PUT    /api/v1/vendors/{vendorID}            — admin write
DELETE /api/v1/vendors/{vendorID}            — admin write
GET    /api/v1/vendors/{vendorID}/items
PUT    /api/v1/vendors/{vendorID}/items/{itemID}
POST   /api/v1/vendors/{vendorID}/items/{itemID}/preferred
DELETE /api/v1/vendors/{vendorID}/items/{itemID}
GET    /api/v1/items/{itemID}/vendors

The preferred route sets which vendor is preferred for a given item; on success it recomputes the item's unit_cost_cents to that vendor's price, since items.unit_cost_cents is an auto-maintained cache of the preferred vendor's price (not a live JOIN — see Vendor management for why).

http
GET    /api/v1/purchase-orders
POST   /api/v1/purchase-orders
POST   /api/v1/purchase-orders/from-low-stock
GET    /api/v1/purchase-orders/{poID}
PUT    /api/v1/purchase-orders/{poID}
POST   /api/v1/purchase-orders/{poID}/status
DELETE /api/v1/purchase-orders/{poID}

from-low-stock pre-fills a PO with one line per item in the low-stock report for a campus (preferred vendor + quantity to restore par). All POs are campus-scoped writes via requireManagerOfLocation.

Stock movements (waste, usage, ledger)

http
POST /api/v1/items/{itemID}/waste
GET  /api/v1/stores/{storeID}/usage
GET  /api/v1/stores/{storeID}/movements

The waste route records a stock_movement with reason=waste and a mandatory waste_reason from the 5-value sub-vocabulary (spoiled, over_prep, contamination, expired, other).

Usage report (CSV-exportable)

http
GET /api/v1/stores/{storeID}/usage
    ?from=2026-06-01&to=2026-06-30
    &reasons=waste,adjust,count_correction,receive
    &format=json|csv

Rolls up movements by item for a window. Filters apply identically to both encodings. JSON shape:

json
{
  "from": "2026-06-01T00:00:00Z",
  "to":   "2026-06-30T23:59:59Z",
  "reasons": ["waste","adjust","count_correction","receive"],
  "rows": [
    {
      "item_id": "...",
      "item_name": "Chicken thigh, boneless",
      "unit": "lb",
      "unit_cost_cents": 389,
      "total_used": 47.5,
      "by_reason": {"waste": 4.5, "adjust": 0.0, "count_correction": 0.0, "receive": 43.0},
      "value_used_cents": 18478
    }
  ],
  "total_value_cents": 18478
}

Add ?format=csv for a text/csv download (filename usage-report.csv) with columns:

item_name,unit,total_used,used_waste,used_adjust,used_count_correction,used_receive,unit_cost_cents,value_used_cents

An unknown format value (format=excel, format=pdf, etc.) returns 400. The CSV is generated in the same handler as the JSON — see Data export for why this matters.

movements returns the raw ledger (per-event) view, intended for admin investigation, not for weekly reporting.

Kitchen display

http
GET  /api/v1/locations/{locationID}/orders
POST /api/v1/locations/{locationID}/orders/{orderID}/transition
GET  /api/v1/locations/{locationID}/events               — Server-Sent Events
POST /api/v1/saavor/orders/prepared                      — Saavor-ready outbound
GET  /api/v1/saavor/payloads                             — admin debug
GET  /api/v1/saavor/payloads/{payloadID}                 — admin debug

Per the Saavor boundary, grill_orders is a read-only projection of what Saavor pushed. PrepTable advances only the prep state (queued → preparing → ready), not the order itself.

The events endpoint is a Server-Sent Events stream; the kitchen-display UI subscribes here to get real-time order pushes without polling. The stream stays open for the life of the session.

Admin

All /api/v1/admin/* routes are admin-only.

http
GET    /api/v1/admin/users
PUT    /api/v1/admin/users/{userID}/role
GET    /api/v1/admin/users/{userID}/locations
PUT    /api/v1/admin/users/{userID}/locations
POST   /api/v1/admin/invitations
GET    /api/v1/admin/invitations
DELETE /api/v1/admin/invitations/{invitationID}
GET    /api/v1/admin/audit-log

The role change refuses to demote the only active admin (last admin protection). The location-assignment route is what backs the Granting campus scope to a manager flow — admins open a manager, replace user_locations with the new campus list, and the manager's API access reflects it on the next request.

The audit-log endpoint is paginated (?limit= and ?before=) and returns the recent history of mutating operations across items, stores, vendors, purchase_orders, dayparts, placements, locations, users, and invitations — full schema in The audit log.

Saavor test endpoints (admin only)

http
POST /api/v1/saavor/test/inbound
POST /api/v1/saavor/test/outbound

Drive the demo's Saavor integration flows without requiring the real POS. inbound synthesizes a webhook payload (signed with the configured shared secret) and posts it to the same handler that processes real Saavor events. outbound synthesizes a "ready for handoff" call the same way.

The endpoints only exist in the running API; they aren't called from any real client. Documented because they are part of the demo flow.

Rate limits and quotas

There are none in v1. The system assumes a single tenant per install and is sized for one campus's traffic per server. If load grows beyond that, the answer is "run another API process behind a load balancer with Redis-shared sessions," not "add rate limiting." Documented here so reviewers don't ask.

What's stable, what's not

Stable across minor releases:

  • Every URL path documented above
  • Every JSON field named in this reference
  • Every status code, including the 400 on unknown ?format=
  • The session-cookie auth shape and the WebAuthn flow

Subject to change without a major version bump:

  • The set of reasons on stock_movements (today: receive, adjust, waste, count_correction)
  • The 5-value waste sub-vocabulary
  • The CSV column ordering (we'd avoid reordering; new columns may be appended)
  • The Saavor webhook payload shape (track whatever Saavor publishes; we mirror their wire format)

These are operational details, not contract changes. The contract is the URL paths and the JSON top-level shapes.

Where to go next

Built for campus dining operations teams.