Headless CMS integration.
The API a bespoke-frontend tenant (e.g. a custom-coded restaurant site) calls to pull menu, footer, and SEO content, and to capture bulk-order/booking inquiries — instead of hardcoding any of it.
Every request is scoped to a tenant by a publishable key — no login, no cookies, no custom headers. All endpoints are CORS-open, so you can call them directly from client-side JS on your own domain.
Authentication
Live key: pk_cms_live_…. Test key (same tenant, same content — use for staging/local dev): pk_cms_test_….
Pass it as a ?key= query param on GET requests, or a key field in the JSON body on POST. Your platform admin gives you this key when your tenant is provisioned.
Note
404 { "error": "Not found" } on every endpoint — this is deliberate, to avoid leaking which keys exist.GET/api/cms/v1/public/menu
Categories, items, and portion variants.
Cache: public, s-maxage=60, stale-while-revalidate=300 · Rate limit: 120 requests/minute per key.
{
"categories": [
{
"id": "uuid",
"name": "Paneer Specials",
"nameLocal": "पनीर स्पेशल",
"imageUrl": null,
"items": [
{
"id": "uuid",
"name": "Kaju Curry",
"nameLocal": "काजू करी",
"description": null,
"descriptionLocal": null,
"price": 240,
"imageUrl": null,
"foodType": "VEG",
"isAvailable": true,
"isPopular": false,
"variants": [
{ "id": "uuid", "label": "Full", "price": 240, "isDefault": true },
{ "id": "uuid", "label": "Half", "price": 130, "isDefault": false }
]
}
]
}
]
}Tip
variants is non-empty, treat it as authoritative (e.g. show “Full ₹240 / Half ₹130”). The item's own price only matters when variants is empty — it's the uniform, single price (e.g. a ₹7 tandoori roti).Important
isAvailable: false means show it, don't hide it. The point of this flag is that customers see “currently unavailable,” not that the item silently disappears — render it visibly de-emphasized instead of filtering it out.GET/api/cms/v1/public/site
Footer nav, contact info, and SEO/JSON-LD — everything for page chrome and <head> tags, in one request.
Cache: public, s-maxage=60, stale-while-revalidate=300 · Rate limit: 120 requests/minute per key.
{
"tenant": {
"businessName": "Restzo Cafe",
"tagline": "Authentic. Hygienic. Welcoming.",
"logoUrl": null,
"coverImageUrl": null,
"primaryPhone": "08349215106",
"secondaryPhone": null,
"addressText": "NH 30, Piprod, Paduwa, MP",
"googleMapsUrl": null,
"openingHoursText": "12 PM – 11 PM"
},
"footer": {
"columns": [
{ "label": "Company", "labelLocal": null, "links": [{ "label": "About", "labelLocal": null, "url": "/about" }] }
]
},
"seo": {
"metaTitle": "Restzo Cafe | Best Cafe in Town",
"metaDescription": null,
"ogImageUrl": null,
"jsonLd": {
"@context": "https://schema.org",
"@type": "Restaurant",
"name": "Restzo Cafe",
"telephone": "08349215106",
"address": "NH 30, Piprod, Paduwa, MP",
"url": "https://restzocafe.com",
"openingHours": "12 PM – 11 PM",
"priceRange": "₹₹",
"servesCuisine": ["North Indian"]
}
}
}Embed seo.jsonLd verbatim in an inline script tag on every page:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data.seo.jsonLd) }}
/>Note
sameAs/servesCuisine/priceRange may be absent. Don't assume every Schema.org/Restaurant key will be there. footer.columns is [] until the tenant admin adds columns — render nothing rather than erroring on an empty array.GET/api/cms/v1/public/sitemap-urls
Cache: public, s-maxage=300, stale-while-revalidate=1800 · Rate limit: 60 requests/minute per key.
{
"urls": [
{ "path": "/", "lastModified": "2026-07-01T17:16:11.880Z", "changeFrequency": "daily", "priority": 1.0 }
]
}Important
"/" — we don't know your site's route structure. Build your own /sitemap.xml on your own domain (sitemaps must be same-origin with the site being indexed), merging this entry with your frontend's own static routes (/menu, /about, etc.).POST/api/cms/v1/public/inquiries
Bulk-order / group-booking / general-contact lead capture. Replaces a client-side wa.me deep link with a real backend record.
Rate limit: 10 requests per 10 minutes per (key, IP) — tuned for a rare, high-intent form submission, not a high-frequency action.
{
"key": "pk_cms_live_…",
"type": "BULK_ORDER",
"customerName": "Test Traveler",
"customerPhone": "+917508839991",
"guestCount": 25,
"eventDate": "2026-07-10",
"notes": "Bus tour group",
"items": []
}| Field | Required | Notes |
|---|---|---|
| key | Yes | Non-empty string |
| type | Yes | "BULK_ORDER" | "TABLE_ENQUIRY" | "GENERAL_CONTACT" |
| customerName | Yes | 2–80 chars |
| customerPhone | Yes | Digits, optional leading +, 8–15 digits total |
| guestCount | No | Positive integer, max 500 |
| eventDate | No | String, max 20 chars — send an ISO date like "2026-07-10" |
| notes | No | Max 500 chars |
| items | No | Array of { name, price, quantity }, max 50 — omit or send [] for a plain lead-capture form |
{ "id": "uuid", "status": "NEW" }{ "error": "Invalid input", "details": ["Too small: expected string to have >=2 characters"] }Note
201 response.Error shape summary
Every non-2xx response is { "error": string }, sometimes with an extra field (details on 400). There's no separate error-code enum — match on HTTP status.
| Status | Meaning |
|---|---|
| 200 | Success (GET) |
| 201 | Inquiry created (POST only) |
| 400 | Missing/invalid input — fix the request |
| 404 | Key doesn't exist, or tenant is deactivated |
| 429 | Rate limited — respect Retry-After |
Questions or something not covered here? Get in touch.