100% Offline POSCompare vs Toast
Resources · API Docs

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.

Embed snippet
<script async
  src="https://your-app-domain.com/widget/loader.js"
  data-key="pk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX">
</script>
FieldRequiredNotes
data-keyYesTenant's publishable key (pk_live_… / pk_test_…)
data-apiNoOverride API base URL — defaults to the script's own origin
data-primaryNoAccent color override, default "#c0392b" — see Theming below
data-fontNoFont stack, default "system-ui, -apple-system, sans-serif"
data-radiusNoCorner radius override, default "12px" — see Theming below
data-targetNoCSS selector for an existing mount element (otherwise auto-created)

Note

The publishable key format is base64url, not hex — if you've seen a schema comment elsewhere describing it as hex, that comment is stale. Treat the key as an opaque string either way.

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

Don't rely on script-tag theming attributes for production embeds — set brand colors in the dashboard instead. When either attribute is present on the tag, it suppresses the catalog API's persisted brand values for that page load.

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

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

The officially bundled widget currently expects a field named 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

Unlike catalog, there is no test-key bypass here — a 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.
Response — 200
{
  "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

This endpoint's 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.
Request body
{
  "key": "pk_live_…",
  "date": "2026-07-10",
  "time": "19:00",
  "partySize": 4,
  "customerName": "Test Traveler",
  "customerPhone": "+917508839991",
  "notes": "Window seat if possible"
}
FieldRequiredNotes
customerNameYesTrimmed, 2–80 chars
customerPhoneYesWhitespace stripped, then /^\+?\d{8,15}$/
partySizeYesInteger, 1..tenant's seatsPerSlot
date / timeYestime must be a real, currently-bookable slot for that date, else a generic 400 "That time slot is not available"
notesNoOptional, truncated to 500 chars

Note

Capacity is enforced with a database-level lock per (tenant, date, slot), so two simultaneous bookings for the last seat can't both succeed. If you lose that race, you get a 409 with the seats actually remaining, not a silent overbook.
Response — 201 (success)
{
  "id": "uuid",
  "status": "CONFIRMED",
  "date": "2026-07-10",
  "time": "19:00",
  "partySize": 4,
  "businessName": "Restzo Cafe"
}
Response — 409 (slot filled during the race)
{ "error": "This slot just filled up", "remaining": 2 }

Important

If the tenant has reservations turned off, this endpoint returns 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:

StatusMeaningRetry-After
200 / 201Success—
400Missing/invalid input—
403Plan doesn't include the required feature key—
404Unknown/inactive key, or (availability only) reservations disabled—
409(bookings only) reservations disabled, or slot filled during a race—
429 (catalog)Rate limited, 120/min60s
429 (availability)Rate limited, 120/min60s
429 (bookings)Rate limited, 15/minnot 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.