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.
Session-cookie auth
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
ForbiddenThe three most common error codes:
| Code | Meaning |
|---|---|
| 400 Bad Request | Missing or malformed parameter, including an unrecognized ?format= value |
| 401 Unauthorized | No session, or session expired — see Authentication lifecycle |
| 403 Forbidden | Authenticated, but role/scope check failed (e.g., a manager trying to write to a campus they're not assigned to) |
| 404 Not Found | Resource 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 Error | Unexpected 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:
| Role | Who | What they can do |
|---|---|---|
staff | Back-of-house worker | Read 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. |
manager | Campus manager | Everything 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. |
admin | Application admin | Everything everywhere. Only role that can manage users, locations, kiosk tokens, and read the audit log. |
kiosk | Service account | One 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:
- Authentication — session cookie valid? (401 if not)
- Role gate — does the role permit this route at all? (403 if not; e.g., a
staffuser hittingPOST /api/v1/admin/users) - Scope gate — for resources with a
location_id, does the user'suser_locationsmembership cover thatlocation_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/verifyTwo-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/verifySame 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)
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness probe. Returns 200 OK with ok body. Bypasses auth. |
GET | /api/v1/healthz | Same, 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/auth | Kiosk token exchange. Body: {"token": "..."}. Returns a session cookie scoped to the kiosk's location. |
POST | /api/v1/saavor/webhook | Saavor 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}/adjustThe 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|csvReturns 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,deficitSee 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 onlyThe 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 writeMeals are tenant-global, like items. Writes are admin-scoped because the cost rollup feeds every campus.
Menus, dayparts, placements (smart-menus)
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/currentMenu 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.
Menu-health checks
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}/vendorsThe 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}/movementsThe 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|csvRolls 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_centsAn 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 debugPer 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-logThe 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/outboundDrive 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
- Roles & permissions — the role and scope gate in narrative form, with the authorization sequence diagram
- Kiosk & integrations — the Saavor webhook signing format and kiosk token lifecycle
- Data export — the two CSV endpoints in workflow context
- The audit log — what shows up there, what doesn't