Kwipps for restaurants · Developers
Put a restaurant's bookings on any website
A restaurant that runs on Kwipps can keep the website it already has. Its booking form, its menu and its events can be shown there with a two-line widget, or built into the site with a small REST API. Bookings land in the restaurant's Kwipps console beside the ones taken by phone.
Before you start
The API only works for a restaurant with an active Kwipps subscription on the Tools, Business or Pro plan. It is checked on every request, not when a key is made: if the subscription ends, the widget and the API stop answering within a minute (with 402 subscription_required), and they start again on their own as soon as a plan is paid for. A payment Stripe is still retrying does not switch it off. A restaurant trying Kwipps on a free preview has everything else, but not the API. The one exception is our own demonstration restaurant, so that you can try the widget on this page.
Bookings through the API are requests unless the restaurant has switched on confirm at once in its console. As a request, the restaurant gets an email to confirm or decline, and the diner gets an acknowledgement straight away and the answer when it comes. With the switch on, a party the restaurant's tables can seat is confirmed the moment it arrives and the answer says confirmed; a party they can't seat at that time is still a request — exactly as for a booking made on a Kwipps website.
Get a key
The restaurant's owner creates keys in the Kwipps console, under Settings › Developers. There are two kinds: a publishable key for a web page, and a secret key for the restaurant's own server (below). A publishable key:
- looks like
kw_pk_live_followed by 32 letters and digits (a secret key:kw_sk_live_), and is shown once; - belongs to one restaurant, and only its public side: it can read the venue, availability, menu and events, and send booking requests;
- works only from the websites listed for it (for example
https://www.yourrestaurant.ch);http://localhostmay be listed for testing; - can have its websites changed or be revoked at any time, which takes effect within a minute. A restaurant can have five keys of both kinds together.
It is a publishable key: it is meant to be in a web page, like the widget snippet below. What makes that safe is the list of websites, the rate limit, and that everything it can do is either public or a request the restaurant still has to confirm.
The widget
Paste this where the "Book a table" button should appear:
<script src="https://kwipps.com/widget/v1/booking.js" defer></script>
<kwipps-booking key="kw_pk_live_…"></kwipps-booking>For a restaurant that takes table bookings it shows one Book a table button. Pressing it opens the restaurant's own booking page — the four steps on its Kwipps website, at bookings.page_url from GET /v1/venue — over your page: the whole screen on a phone, a centred window (up to 1100 px wide, 90% high) on a desktop. The ×, Esc or a click beside it closes it; while it is open the focus stays inside and your page does not scroll, and the focus returns to the button after. It runs in its own shadow root, so your CSS won't change it and it won't change your page.
Try it
This is the widget, live, for Marrow & Vine. Press the button: the restaurant's own four-step booking page opens over this page, exactly as it would on your website. Esc, the × or a click beside it closes it.
Marrow & Vine is our demonstration restaurant in Zürich. A booking sent here reaches its demo console like any other booking, but no table is waiting for you.
The code behind the button, with Marrow & Vine's publishable key:
<script src="https://kwipps.com/widget/v1/booking.js" defer></script>
<kwipps-booking key="kw_pk_live_i5a43Nya926HUgPrs83qJw8zoAxqsOlh" lang="en"></kwipps-booking>With mode="inline" it shows the form on the page instead: the open dates and times, the party sizes the restaurant accepts online, and fields for name, email, phone and a note. An appointment business always gets the form.
<kwipps-booking key="kw_pk_live_…" mode="inline"></kwipps-booking>Attributes
key— the publishable key. Required.lang—en,de,fr,es,itorpt. Defaults to the restaurant's language, then the page's<html lang>. The booking page opens in it too.mode—inlinefor the form on the page instead of the button.no-title— withmode="inline", leave out the "Book a table" heading.
Styling
kwipps-booking {
--kw-accent: #7a2e1f; /* the button */
--kw-radius: 10px; /* the fields */
--kw-font: inherit;
}Text colour is inherited from your page. The parts button (and, inline, title and form) can be styled with ::part().
Events
document.addEventListener("kwipps:requested", (e) => {
// e.detail = { date: "2026-09-20", time: "19:30", party: 4, status: "requested" }
});
// the same, when the restaurant confirms at once and the room took it
document.addEventListener("kwipps:confirmed", (e) => { /* e.detail.status === "confirmed" */ });
document.addEventListener("kwipps:error", (e) => console.warn(e.detail.message));
// the button's booking page opening and closing
document.addEventListener("kwipps:open", (e) => { /* e.detail.url */ });
document.addEventListener("kwipps:close", () => {});The REST API
Base URL:
https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/apiSend the key in the X-Kwipps-Key header. Requests and responses are JSON. Browsers get CORS headers for the websites listed on the key; a call from your own server (no Origin header) is allowed with the key alone. Dates are YYYY-MM-DD and times HH:MM, in the restaurant's own local time.
curl https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api/v1/venue \
-H "X-Kwipps-Key: kw_pk_live_…"The whole API — every route, field, status and error code, the restaurant's and the hotel's — is also an OpenAPI 3.1 file, generated from the API's code and checked against it on every change. Import it into Postman or Insomnia, or generate a client from it.
Endpoints
| Method | Path | What it returns |
|---|---|---|
| GET | /v1/venue | The restaurant: name, phone, address, opening hours, language, currency, and whether it takes bookings. |
| GET | /v1/availability | The dates open for booking in the next 30 days, and the free times on one of them — for a table, or for an appointment with a service and, optionally, a person. |
| POST | /v1/booking-requests | Send a booking request. The restaurant confirms it by email. |
| GET | /v1/menu | The current menu, with any dish that is off today marked. |
| GET | /v1/events | Upcoming published events. |
GET /v1/venue
{
"id": "03a3f8a1-…",
"name": "Marrow & Vine",
"phone": "+41 44 000 00 00",
"address": "Seefeldstrasse 12, 8008 Zürich",
"hours": { "tue": [["18:00", "23:00"]], "wed": [["18:00", "23:00"]] },
"language": "de",
"currency": "CHF",
"bookings": { "enabled": true, "kind": "table", "max_party_size": 8, "page_url": "https://www.marrowvine.ch/book/" },
"sms": false,
"questions": [
{ "id": "q3f9a1c20", "type": "text", "required": false, "label": "Anlass" },
{ "id": "q7be04d11", "type": "check", "required": false, "label": "Wir brauchen einen Kinderstuhl" }
]
}questions are the restaurant's own questions on its booking form (up to five): a line of text or a box to tick, labelled in ?lang= or the venue's language. Send the answers with the booking request.
A business that books appointments ("kind": "appointment") also lists its services and staff — see Appointments.
GET /v1/availability?date=2026-09-20
date is optional; without it you get the first open date's times.
{
"kind": "table",
"dates": ["2026-09-16", "2026-09-17", "2026-09-20"],
"date": "2026-09-20",
"times": ["18:00", "18:30", "19:00", "19:30"],
"max_party_size": 8
}POST /v1/booking-requests
curl -X POST https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api/v1/booking-requests \
-H "X-Kwipps-Key: kw_pk_live_…" -H "Content-Type: application/json" \
-d '{ "date": "2026-09-20", "time": "19:30", "party": 4,
"name": "Anna Keller", "email": "anna@example.com",
"phone": "+41 79 000 00 00", "notes": "Window, please", "lang": "de",
"sms_opt_in": true,
"answers": { "q3f9a1c20": "Geburtstag", "q7be04d11": true } }'date, time, party and name are required, and an email or a phone. lang decides the language of the diner's emails and of any refusal. sms_opt_in is the diner's own tick to be texted the confirmation and a reminder; it counts only when the venue's sms is true, and the widget shows the tick only then.answers is an object of question id to text, or to true/false for a box. Send it, and every question counts as asked: a required one left out is refused (422). Leave it out, and no question is required of you. A success is 201:
{ "status": "requested", "message": "We'll email you as soon as it's confirmed — it isn't booked until then." }or, when the restaurant confirms at once and the room took it:
{ "status": "confirmed", "message": "Booked — we've emailed you the confirmation." }A request that breaks one of the restaurant's rules (too many people, a date gone by, a closed day, too little notice) is 422, and the message is written for the diner:
{ "error": { "code": "refused", "message": "online reservieren wir für bis zu 8 Personen — für eine größere Gruppe rufen Sie bitte +41 44 000 00 00 an" } }Appointments
A salon, a clinic or a physio books a service, optionally with a person, rather than a table for a party.GET /v1/venue lists both — only what is offered online:
{
"bookings": { "enabled": true, "kind": "appointment", "max_party_size": 1, "page_url": null },
"services": [
{ "id": "5e000001-…", "name": "Cut", "description": "Wash and cut", "duration_minutes": 45,
"price": 85, "currency": "CHF", "staff_ids": ["5f000001-…", "5f000002-…"] }
],
"staff": [ { "id": "5f000001-…", "name": "Anna", "role": "Stylist" } ],
…
}price is null for "on request"; an empty staff_ids means anybody. Then ask for the free starts:
GET /v1/availability?service=…&staff=…&date=2026-09-20
{
"kind": "appointment",
"service": "5e000001-…",
"staff": null,
"duration_minutes": 45,
"dates": ["2026-09-17", "2026-09-18", "2026-09-20"],
"date": "2026-09-20",
"times": ["09:00", "09:30", "11:00"],
"free": { "09:00": ["5f000001-…", "5f000002-…"], "09:30": ["5f000002-…"], "11:00": ["5f000001-…"] }
}service defaults to the first one; staff narrows to one person; free says who could take each start. A start is only offered when the whole appointment fits before closing and somebody who gives the service is free for all of it — the same arithmetic as the business's own booking form, over its bookings at that moment. Ask again when the diner changes anything: a start taken a minute ago is not offered.
To book, send service and, optionally, staff to POST /v1/booking-requests instead of party. Without astaff, the business's first free person takes it. The widget does all of this by itself for an appointment business.
Sending twice
A double tap, a form sent again after a dropped connection, a job that retries: a booking is taken once.
Always: the same request — same date and time, same party size or service, from the same email address or telephone number, within 30 minutes — is answered as the first one was, with "duplicate": true added. Nothing new is stored and the restaurant is not emailed again. This holds for the widget, the API and the restaurant's own Kwipps website alike.
With an Idempotency-Key header on any POST, PATCH or DELETE — a UUID you make per request — the first answer is stored for 24 hours and a repeat with the same key gets it back, with Idempotent-Replayed: true. The same key with a different body is refused with 422 idempotency_key_reused; a repeat that arrives while the first is still running gets 409 idempotency_in_progress. An answer that asks you to retry (429, 5xx) is not stored, so retrying with the same key really retries. The widget sends one for you.
curl -X POST https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api/v1/bookings \
-H "X-Kwipps-Key: kw_sk_live_…" -H "Content-Type: application/json" \
-H "Idempotency-Key: 4f0c2a7e-1b3d-4c5e-9f6a-7b8c9d0e1f2a" \
-d '{ "date": "2026-09-20", "time": "19:30", "party": 4, "name": "Anna Keller" }'GET /v1/menu
{
"currency": "CHF",
"sections": [
{ "name": "Mains", "items": [
{ "name": "Sea bream, fennel, blood orange", "price": 38, "off": true },
{ "name": "Risotto, chanterelles", "price": 29, "off": false }
] }
]
}off is a dish the restaurant has taken off today. Show it struck through, or leave it out.
GET /v1/events
{
"events": [
{ "title": "Growers' night", "summary": "Six wines from two valleys",
"starts_on": "2026-09-26", "starts_time": "19:00", "ends_on": null, "ends_time": null,
"price": 65, "currency": "CHF", "photo_url": "https://…", "link_url": null }
]
}Your server: bookings
With a secret key the restaurant's own systems — a till, a phone system, a group's booking tool — can do what the console does to bookings. Every call runs the console's own action, with the same rules: a booking taken this way is confirmed at once and shows in the diary as coming from the API; confirming a request emails the guest; a table somebody else holds is refused.
Keep a secret key on your server. A request that carries one and an Origin header — that is, from a browser — is refused with 403 secret_key_in_browser, and it gets no CORS headers. The subscription rule and the rate limits are the same as for a publishable key.
| Method | Path | What it does |
|---|---|---|
| GET | /v1/bookings | The bookings on a date (?date=) or a range (?from=&to=), requests and cancellations included. |
| POST | /v1/bookings | Take a booking, confirmed at once — a till, a phone system, another booking tool. The console's own rules apply. |
| POST | /v1/bookings/{id}/confirm | Confirm a request. The guest is emailed, exactly as when the restaurant presses Confirm. |
| POST | /v1/bookings/{id}/decline | Decline a request. The guest is emailed. |
| POST | /v1/bookings/{id}/waitlist | Put a request on the waiting list. It can still be confirmed later. |
| POST | /v1/bookings/{id}/cancel | Cancel a booking; notify_guest emails them, with an optional reason. |
| PATCH | /v1/bookings/{id} | Move a booking: a new date, time or party size. A table somebody else holds is refused. |
| GET | /v1/webhooks | The webhooks, with when each last delivered or failed. |
| POST | /v1/webhooks | Add a webhook: an https address and the events it wants. The signing secret is in the answer, once. |
| DELETE | /v1/webhooks/{id} | Delete a webhook. |
| POST | /v1/webhooks/{id}/test | Send a signed ping to one webhook, within a minute. |
POST /v1/bookings
curl -X POST https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api/v1/bookings \
-H "X-Kwipps-Key: kw_sk_live_…" -H "Content-Type: application/json" \
-d '{ "date": "2026-09-20", "time": "19:30", "party": 4,
"name": "Anna Keller", "phone": "+41 79 000 00 00",
"email": "anna@example.com", "notify_guest": true }'date, time, party and a name or phone are required. With notify_guest and anemail, the guest gets the confirmation email with their own link to change or cancel. The answer is 201:
{
"booking": {
"id": "b0000001-…", "status": "confirmed", "confirmed_by": "restaurant",
"date": "2026-09-20", "time": "19:30", "party": 4,
"name": "Anna Keller", "email": "anna@example.com", "phone": "+41 79 000 00 00",
"notes": null, "answers": [{ "question": "Occasion", "answer": "Birthday" }],
"source": "api", "service": null, "duration_minutes": null,
"seated_at": null, "left_at": null,
"created_at": "2026-09-16T10:00:00Z", "updated_at": "2026-09-16T10:00:00Z"
},
"unchanged": false,
"warnings": ["Bigger than the 10 you take online."],
"guest_emailed": true
}A booking has this shape everywhere: in GET /v1/bookings ({ "bookings": [ … ] }), after a move, and inside a webhook. confirmed_by is restaurant, confirm_at_once (the restaurant's switch confirmed it as it arrived) or guest; warnings are the console's own notes — the booking was taken regardless. answers are what the guest answered to the restaurant's own questions, each with the question as it read when they booked; an empty list when there were none.
POST /v1/bookings/{id}/confirm
{ "status": "confirmed" }A request already answered is 409 conflict, with the console's words: Already confirmed.
Webhooks
A webhook tells your server the moment something happens, instead of your server asking. Add one in the console under Settings › Developers, or with POST /v1/webhooks and a secret key. Kwipps posts JSON to your https address for each event it asked for (none means all), whichever way the change happened — the website, the console, an email link, the guest's own page, or the API.
| booking.requested |
| booking.confirmed |
| booking.declined |
| booking.waitlisted |
| booking.cancelled |
| booking.no_show |
| booking.moved |
| order.paid |
POST https://api.yourrestaurant.ch/kwipps
Content-Type: application/json
Kwipps-Event: booking.confirmed
Kwipps-Delivery: 7c1e…
Kwipps-Signature: t=1789581600,v1=5f2b…
{
"id": "7c1e…", "type": "booking.confirmed", "created": "2026-09-16T10:00:02Z", "api_version": "v1",
"data": { "booking": { "id": "b0000001-…", "status": "confirmed", "confirmed_by": "confirm_at_once", … } }
}order.paid carries data.order instead (amounts in minor units). A test from the console is ping.id is the delivery's: a retry of the same delivery has the same id, so a receiver can ignore one it already handled.
Check the signature
Every delivery is signed with the webhook's secret (whsec_…, shown once when it is added). The header is t=unix seconds,v1=the hex HMAC-SHA256 of t + "." + the raw body. Refuse a timestamp more than five minutes old, and compare in constant time:
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}Delivery
Answer with any 2xx within 10 seconds. Anything else — an error, a timeout, a redirect — is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and a day, then given up; the console shows when each webhook last delivered or failed. Events are sent in about a minute, not in order: sort by created. Only public https addresses on the standard port receive webhooks, and deliveries stop while the restaurant has no active subscription.GET /v1/webhooks shows the same as the console: last_success_at, last_failure_at, last_status and in last_error why the last attempt failed — the status your server answered, a timeout, that a delivery was given up after the last attempt, or that nothing is being sent because the subscription lapsed.
Headers
Every answer carries these, whatever its status:
Content-Type: application/json; charset=utf-8— a preflight's204has no body and no type.X-Request-Id— new for every request, and in our logs beside the route and the status. Quote it when you write to us.Cache-Control: no-store— except the200a publishable key gets from/v1/venue,/v1/menu,/v1/eventsand/v1/hotel:private, max-age=60, withVary: Origin, X-Kwipps-Key. Only the visitor's own browser keeps it, for a minute. Availability, requests and anything a secret key reads are never kept.RateLimit-Limit,RateLimit-PolicyandRateLimit-Resetonce your key is counted: the limit that applied (120;w=60, and10;w=60on booking requests), and the seconds until the minute window turns. On a429, alsoRateLimit-Remaining: 0andRetry-Afterwith the same seconds.Idempotent-Replayed: trueon an answer given again for anIdempotency-Key.
A browser on a website listed for the key gets Access-Control-Allow-Origin with that website, andAccess-Control-Expose-Headers lets its script read X-Request-Id, Retry-After, Idempotent-Replayed and theRateLimit-* headers. A website not listed, and any call with a secret key, gets no CORS headers.
Errors
Every error has the same shape: { "error": { "code": "…", "message": "…" } }. The message is safe to show to a person.
| Status | Code | When |
|---|---|---|
| 401 | invalid_key | No key, a key we didn't issue, or a revoked one. |
| 403 | origin_not_allowed | Called from a website that isn't listed for the key. |
| 403 | secret_key_in_browser | A secret key was sent by a browser. Call from your server, and revoke the key if it was ever in a page. |
| 403 | secret_key_required | A staff route or webhook route called with a publishable key. |
| 403 | forbidden | The restaurant's account doesn't allow that action. |
| 409 | conflict | The booking was already answered, or a table is taken. The message says which. |
| 402 | subscription_required | The restaurant has no active Kwipps subscription. The API switches back on as soon as a plan is paid for. |
| 402 | plan_excludes_api | The restaurant's plan doesn't include the API (Website). Tools, Business and Pro do. |
| 409 | bookings_off | The restaurant has switched online bookings off. |
| 422 | refused | The booking request broke one of the restaurant's rules. The message says which, in the diner's language. |
| 429 | rate_limited | Too many requests with this key. Wait a minute (see Retry-After). |
| 400 | bad_request | A malformed date or Idempotency-Key, a body that isn't JSON, or a service or person the business doesn't offer. |
| 422 | idempotency_key_reused | An Idempotency-Key already used for a different request. Use a new key for a new request. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still being handled. Retry after a moment (see Retry-After). |
| 404 | not_found | No such endpoint. |
| 500 | server_error | Something went wrong on our side. A booking request that answers this was not stored; try again (with the same Idempotency-Key), or call the restaurant. |
| 502 | server_error | A Kwipps service behind the API didn't answer as expected. Try again in a moment; quote the X-Request-Id if you write to us. |
Limits
- 120 requests a minute per key, and 10 booking requests a minute per key. Booking requests are also limited per diner, as on Kwipps websites.
- Tables and appointments. A group booking across several services at once is not in the API; link to the business's booking page for that.
- Menus, the wine list, events, deposits and orders are changed in the console; a secret key reaches bookings and webhooks.
- Five webhooks per restaurant.
v1will stay compatible. A breaking change will be a new version, at a new path.
Privacy
The widget sets no cookies, stores nothing in the browser and loads nothing from any other company — its only requests are to the Kwipps API. What a diner types goes to the restaurant, which is responsible for it; the restaurant's privacy policy should say that bookings are handled by Kwipps on its behalf.
Questions: info@kwipps.com. Back to Kwipps for restaurants.