Widget embed.
A drop-in menu + booking widget a tenant pastes onto their own existing website — one <script> tag, an isolated Shadow DOM, zero frontend code required.
This is a separate system from the Headless CMS API: the widget renders a complete, pre-built UI inside the host page. Use this when a tenant already has a website and just wants a menu/booking panel on it; use Headless CMS when a tenant is building their own frontend from scratch and wants raw data instead of a pre-built UI.
Installation
Paste this snippet where the widget should appear. It builds an isolated Shadow DOM host (so the tenant's page styles never leak in or out) and loads the widget bundle asynchronously.
<script async
src="https://your-app-domain.com/widget/loader.js"
data-key="pk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX">
</script>| Field | Required | Notes |
|---|---|---|
| data-key | Yes | Tenant's publishable key (pk_live_… / pk_test_…) |
| data-api | No | Override API base URL — defaults to the script's own origin |
| data-primary | No | Accent color override, default "#c0392b" — see Theming below |
| data-font | No | Font stack, default "system-ui, -apple-system, sans-serif" |
| data-radius | No | Corner radius override, default "12px" — see Theming below |
| data-target | No | CSS selector for an existing mount element (otherwise auto-created) |
Note
Theming
There are two ways brand colors/corner-radius reach the widget, and only one of them is what a real tenant embed actually uses:
1. Dashboard-persisted (the real path). A tenant sets accent color and corner style in their dashboard's widget settings; this is saved to their account and served back automatically in the catalog API's store.brand field on every load. This is what an actual embed snippet relies on — the copyable snippet in the dashboard never includes data-primary/data-radius.
2. Script-tag override. The data-primary/data-radius attributes on the loader tag exist and do work, but in this codebase they're only used by the dashboard's own live-preview iframe (so a color-picker change reflects instantly, before the debounced save completes).
Note
GET/api/public/v1/catalog
Store info, booking flags, and the full menu catalog. This is what the widget bundle itself calls on mount.
Auth: ?key= query param. Cache: public, s-maxage=60, stale-while-revalidate=300 · Rate limit: 120 requests/minute per key.
Note
pk_test_… key bypasses the subscription-active check, so you can keep testing on staging after a trial lapses. This bypass is unique to this endpoint — it does not apply to availability or bookings below.{
"store": {
"name": "Restzo Cafe",
"tagline": "Authentic. Hygienic. Welcoming.",
"themeId": "classic",
"whatsappNumber": "918349215106",
"acceptingOrders": true,
"gstPercent": 5,
"brand": { "primary": "#c0392b", "radius": "12px" }
},
"booking": { "enabled": true, "advanceDays": 14, "maxPartySize": 20 },
"categories": [
{
"id": "uuid",
"name": "Paneer Specials",
"nameLocal": "पनीर स्पेशल",
"items": [
{
"id": "uuid",
"name": "Kaju Curry",
"nameLocal": "काजू करी",
"price": 240,
"priceHalf": 130,
"description": null,
"imageUrl": null,
"foodType": "VEG",
"isPopular": false
}
]
}
]
}Important
nameHi on categories/items, but this endpoint returns nameLocal (renamed when local-language support was generalized beyond Hindi). As shipped, the bundled widget UI won't render localized names correctly — this is a known field-name mismatch in the widget bundle, not something wrong with the API response documented here.403 { "error": "Plan does not include the headless widget" } if the tenant's plan doesn't have the widget feature key enabled.
GET/api/public/v1/availability
Live per-slot seat availability for a given date — used by the widget's “Reserve” tab.
Query params: key, date (YYYY-MM-DD). Cache: no-store — capacity is live and must never be served stale. Rate limit: 120 requests/minute per key.
Important
pk_test_… key still 404s once the tenant's subscription lapses. Also gated by a different feature key, booking, not widget — a tenant can have a working menu widget with no working reservations if their plan lacks booking.{
"date": "2026-07-10",
"slots": [
{ "time": "19:00", "available": 8 },
{ "time": "19:30", "available": 0 }
]
}Slots are generated from the tenant's booking configuration (open/close time, slot length, lead time, advance-booking window) and computed in fixed IST (UTC+5:30). Past slots and slots inside the lead-time window are excluded entirely, not returned with available: 0.
400: { "error": "Missing key or date" } or { "error": "Invalid date" }. 404: { "error": "Reservations are not enabled" } if the tenant has booking turned off.
POST/api/public/v1/bookings
Creates a table reservation. Rate limited much tighter than the read endpoints since this is a write.
Rate limit: 15 requests/minute per key.
Important
429 response does not include a Retry-After header, unlike catalog/availability's 429s which do — see the Errors table below for the full picture.{
"key": "pk_live_…",
"date": "2026-07-10",
"time": "19:00",
"partySize": 4,
"customerName": "Test Traveler",
"customerPhone": "+917508839991",
"notes": "Window seat if possible"
}| Field | Required | Notes |
|---|---|---|
| customerName | Yes | Trimmed, 2–80 chars |
| customerPhone | Yes | Whitespace stripped, then /^\+?\d{8,15}$/ |
| partySize | Yes | Integer, 1..tenant's seatsPerSlot |
| date / time | Yes | time must be a real, currently-bookable slot for that date, else a generic 400 "That time slot is not available" |
| notes | No | Optional, truncated to 500 chars |
Note
409 with the seats actually remaining, not a silent overbook.{
"id": "uuid",
"status": "CONFIRMED",
"date": "2026-07-10",
"time": "19:00",
"partySize": 4,
"businessName": "Restzo Cafe"
}{ "error": "This slot just filled up", "remaining": 2 }Important
409 { "error": "Reservations are not enabled" } — note this is 409 here but 404 on the availability endpoint above for the identical underlying condition.On success, a WhatsApp confirmation is queued to the customer's phone — this happens asynchronously and never blocks or alters the HTTP response, even if it fails.
Errors & rate limits
All three endpoints share the same auth model and CORS-open policy, but differ in a few real ways worth knowing before you build retry logic:
| Status | Meaning | Retry-After |
|---|---|---|
| 200 / 201 | Success | — |
| 400 | Missing/invalid input | — |
| 403 | Plan doesn't include the required feature key | — |
| 404 | Unknown/inactive key, or (availability only) reservations disabled | — |
| 409 | (bookings only) reservations disabled, or slot filled during a race | — |
| 429 (catalog) | Rate limited, 120/min | 60s |
| 429 (availability) | Rate limited, 120/min | 60s |
| 429 (bookings) | Rate limited, 15/min | not sent |
Plan gating is by feature key (widget for catalog, booking for availability/bookings) on whatever pricing plan a tenant is assigned — this is admin-configurable per plan, not a fixed hardcoded tier name, so don't hardcode “Gold/Platinum only” logic against the API itself.
Questions or something not covered here? Get in touch.