Put your hotel's bookings on any website
A Kwipps hotel has a booking page of its own and a small REST API. The page is a link; the API lets your developer show your room types, prices and what is free on any dates, send stay requests, and — from your own server — run the desk: take, confirm, change and cancel stays, check guests in and out, and hear about every change by webhook.
Before you start
The API works while the hotel's Kwipps subscription is active, on Tools, Business or Pro. The free preview has everything else, but not the API: other people's websites depending on a business that is not a customer is exactly what it must not allow. A subscription that lapses stops the API within a minute; one that recovers starts it again within one.
The hotel also has to be taking bookings online — the switch under Policies in the console — for availability and stay requests to answer. GET /v1/hotel says whether it is.
Get a key
In the console, under Settings › Developers, the owner creates a key. A publishable key (kw_pk_live_ followed by 32 characters) lives in a web page and is tied to the websites listed for it; it can read the hotel and the availability and send stay requests. A secret key (kw_sk_live_…) lives on your server only, is refused the moment a browser sends it, and can also do what the desk does. Send it in the X-Kwipps-Key header.
The booking page
Every hotel taking bookings has a page at /stay/ on its Kwipps address — its own domain when it has one, its preview address until then. It carries the whole form: dates, guests, room type, the price for those nights with the tourist tax, the deposit, and a request that the hotel confirms. Link to it from your website, your Google Business Profile and your Instagram; nothing to install.
<a href="https://your-hotel.ch/stay/">Book a room</a>The REST API
Base address: https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api. JSON in, JSON out; UTF-8; dates as YYYY-MM-DD; money in the hotel's currency's minor units (Rappen), with the currency named in every answer.
curl "https://nebrrtkdrsljywhkxxrs.supabase.co/functions/v1/api/v1/hotel" \
-H "X-Kwipps-Key: kw_pk_live_…"Every route, field, status and error code — the hotel's and the restaurant's — is also an OpenAPI 3.1 file, generated from the API's code and checked against it on every change. Every answer carries an X-Request-Id (quote it when you write to us), Cache-Control, and the RateLimit-* headers once your key is counted; they are described under Headers on the restaurant page, and are the same here.
Endpoints
| GET /v1/hotel | The hotel: room types with what they sleep and their lowest price, rate plans, extras, check-in and check-out times, the cancellation rule, the tourist tax, and whether it takes bookings. |
| GET /v1/hotel/availability | What is free for a check-in and a check-out, for a number of adults and children, and what each room type costs for those nights: the nights, the tourist tax, the total and the deposit. |
| POST /v1/stay-requests | Send a stay request. The hotel confirms it by email — or at once, when it has switched that on and a room is free. |
GET /v1/hotel
{
"bookings": { "enabled": true, "confirm_at_once": false, "min_lead_days": 0, "horizon_days": 365, "max_nights": 30, "children_allowed": true, "pets_allowed": false },
"check_in_from": "15:00", "check_out_until": "11:00", "currency": "CHF",
"deposit_percent": 30,
"cancellation": { "free_until_days_before": 2, "then_nights_charged": 1 },
"tourist_tax": { "per_person_per_night": 350, "children_free_under": 6, "children_half_under": 16 },
"room_types": [
{ "id": "…", "name": "Double lake view", "slug": "double-lake-view", "description": "…", "beds": "1 king bed", "size_m2": 26,
"max_adults": 2, "max_children": 1, "from_amount": 21000, "photos": ["https://…"], "amenities": ["Wi-Fi", "Balcony"],
"names": { "de": "Doppelzimmer Seesicht" }, "descriptions": { "de": "…" } }
],
"rate_plans": [ { "id": "…", "name": "Flexible", "description": null, "refundable": true, "breakfast_included": false, "pay_when": "arrival", "min_stay": 1,
"long_stay_discounts": null } ],
"long_stay_discounts": [ { "nights": 7, "percent": 10 }, { "nights": 28, "percent": 25 } ],
"extras": [ { "id": "…", "name": "Parking", "amount": 1500, "per": "night", "mandatory": false, "room_type_ids": [], "daily_limit": 2, "offered_on": "every" } ],
"arrival_time": { "asked": "optional", "earliest": "15:00", "latest": "22:00" },
"booking_questions": [ { "id": "q1", "kind": "tick", "required": false, "labels": { "en": "Do you need a cot?", "de": "Brauchen Sie ein Kinderbett?" } } ]
}A hidden room type or rate plan is not offered anywhere, so not here either. from_amount is the type's base price per night; the price for real dates comes from availability. names and descriptions hold the type's name and line in other languages where the hotel wrote them; name and description are the main ones. An extra that is mandatory is always included in the price; room_type_ids empty means every room type; daily_limit is how many there are a day (null for no limit); offered_on is every night, or once on the arrival or departure day. long_stay_discounts is a percentage off the room from so many nights; a rate plan's own list (not null) replaces the hotel's. arrival_time and booking_questions are what the hotel's own form asks besides the dates: asked is off, optional or required; a question is a line of text or a tick box, labelled in the languages the hotel wrote.
GET /v1/hotel/availability
Query: check_in, check_out (the morning of departure — the night of check-out is not slept), adults (default 2), children (default 0), children_ages (their ages, comma-separated, so the tourist tax's age bands apply; a child with no age given is not taxed), optionally rate_plan_id, extras (id:quantity, comma-separated, to price them in), coupon (a code the hotel gave out) and lang for the reasons.
GET /v1/hotel/availability?check_in=2026-09-25&check_out=2026-09-28&adults=2
{
"check_in": "2026-09-25", "check_out": "2026-09-28", "nights": 3, "adults": 2, "children": 0, "currency": "CHF",
"rate_plan": { "id": "…", "name": "Flexible", "breakfast_included": false, "refundable": true },
"confirm_at_once": false,
"room_types": [
{ "id": "…", "name": "Double lake view", "free": 2, "reason": null,
"nightly": [ { "date": "2026-09-25", "amount": 21000 }, { "date": "2026-09-26", "amount": 21000 }, { "date": "2026-09-27", "amount": 21000 } ],
"room_amount": 63000, "extras": [ { "id": "…", "name": "Parking", "quantity": 1, "amount": 4500 } ], "extras_amount": 4500,
"discounts": [ { "kind": "coupon", "label": "Coupon SUMMER (10%)", "amount": 6300, "percent": 10, "code": "SUMMER" } ], "discount_amount": 6300,
"tourist_tax_amount": 2100, "total_amount": 63300, "deposit_amount": 18990 },
{ "id": "…", "name": "Family suite", "free": 0, "reason": "Family suite is fully booked on those dates" }
]
}A type with free: 0 carries the reason — full, closed, a minimum stay, too many adults — in the guest's language. A coupon or an extra that does not apply is said in reason and left out of the price. Discounts — a longer stay first, then a coupon — come off the room only, never the extras or the tourist tax, and never below zero. Everything counts: a request the hotel has not answered yet holds a room, as does a night blocked from Booking.com's calendar.
POST /v1/stay-requests
POST /v1/stay-requests
Content-Type: application/json
Idempotency-Key: 5f1e…
{ "check_in": "2026-09-25", "check_out": "2026-09-28", "adults": 2, "children": 1, "children_ages": [9],
"room_type_id": "…", "rate_plan_id": "…",
"name": "Anna Roth", "email": "anna@example.com", "phone": "+49 170 000000", "notes": "Late arrival", "lang": "de",
"arrival_time": "21:30", "answers": { "q1": true }, "extras": [ { "id": "…", "quantity": 1 } ], "coupon": "SUMMER" }
201 { "status": "requested", "reference": "KW7F3Q", "message": "Danke — wir bestätigen per E-Mail.", "total_amount": 63300, "discount_amount": 6300 }
201 { "status": "confirmed", "reference": "KW7F3Q", "message": "…", "pay_url": "https://checkout.stripe.com/…" }Send an Idempotency-Key (any string up to 255 characters, one per request) and a request sent twice is answered with the first answer, marked Idempotent-Replayed: true, for 24 hours. The request goes through exactly what the hotel's own website form goes through: the same rules, the same spam judgement, the same emails (the hotel gets Confirm and Decline links; the guest gets an acknowledgement with their own page). status is requested unless the hotel has switched on confirming at once and a room was free. pay_url is present when the hotel takes a deposit by card (Pro) — send the guest there. The same request within 30 minutes is answered as the first was and carries "duplicate": true. arrival_time (HH:MM) and answers (the hotel's booking_questions by id: text, or true/false for a tick box) are optional here, even where the website form requires them; a time outside the hotel's earliest–latest is refused with the reason. extras are added by id (the ones always included are added anyway) and a coupon that does not apply refuses the request with the reason, so nothing is booked at a price the guest was not shown.
Money and dates
Prices include VAT; the tourist tax is added and shown apart, per person per night, children under the hotel's age free. deposit_amount is the hotel's deposit share of the total, or the whole total on a plan paid when booking. CHF amounts are rounded to 5 Rappen. A stay from the 25th to the 28th sleeps three nights; a departure and an arrival on the same day never collide.
Your server: stays
With a secret key. Each route is the console's own action, run as the hotel's owner for that one hotel, so every rule the desk has applies here too.
| GET /v1/stays | The stays touching a range of nights (?from=&to=), requests and cancellations included; ?status= narrows. |
| POST /v1/stays | Take a stay, confirmed at once — a phone booking, a walk-in, another system. The console's own rules apply: the room type must be free on every night. |
| GET /v1/stays/{id} | One stay, with its bill: the balance and what has been paid. |
| POST /v1/stays/{id}/confirm | Confirm a request. The guest is emailed, exactly as when the desk presses Confirm. |
| POST /v1/stays/{id}/decline | Decline a request. The guest is emailed. |
| POST /v1/stays/{id}/cancel | Cancel a stay; notify_guest emails them, with an optional message. The answer says what the cancellation rule allows charging. |
| POST /v1/stays/{id}/check-in | Check the guest in, into room_id (or the room already assigned). The room must be free for the whole stay. |
| POST /v1/stays/{id}/check-out | Check the guest out. The room is marked as needing cleaning. |
| POST /v1/stays/{id}/no-show | Mark a confirmed stay as a no-show. |
| PATCH /v1/stays/{id} | Change a stay: dates, guests, room type, rate plan, room, extras, or who it is for. The price is worked out again unless keep_price is true; a room somebody else holds is refused. |
POST /v1/stays
{ "check_in": "2026-10-02", "check_out": "2026-10-04", "adults": 2, "room_type_id": "…", "room_id": "…",
"name": "Hans Muster", "email": "hans@example.com", "phone": "+41 79 000 00 00", "country": "CH", "channel_ref": "BDC-1234", "notify_guest": true }
201 { "stay": { "id": "…", "reference": "MNP4Q7", "status": "confirmed", "check_in": "2026-10-02", "check_out": "2026-10-04", "nights": 2,
"adults": 2, "children": 0, "room_type": "Double", "room_id": "…", "rate_plan": "Flexible", "breakfast_included": false,
"name": "Hans Muster", "email": "hans@example.com", "country": "CH", "source": "api", "channel_ref": "BDC-1234",
"currency": "CHF", "room_amount": 36000, "extras_amount": 0, "tourist_tax_amount": 1400, "total_amount": 37400,
"discount_amount": 0, "coupon_code": null, "discounts": [],
"deposit_amount": 11220, "deposit_status": null, "paid_amount": 0, "invoice_number": null,
"arrival_time": "18:30", "answers": [ { "id": "q1", "question": "Do you need a cot?", "answer": true } ], "created_at": "…", "updated_at": "…" },
"guest_emailed": true }
POST /v1/stays/{id}/check-in { "room_id": "…" } → 200 { "status": "checked_in", "guest_emailed": false, "cancel_fee_amount": 0 }
POST /v1/stays/{id}/cancel { "notify_guest": true } → 200 { "status": "cancelled", "guest_emailed": true, "cancel_fee_amount": 18000 }
PATCH /v1/stays/{id} { "check_out": "2026-10-05", "notify_guest": true } → 200 { "stay": { … }, "repriced": true, "guest_emailed": true }Webhooks
The same webhooks as the restaurant API (GET/POST /v1/webhooks, signed with Kwipps-Signature, retried on the same schedule), with these events for stays. The payload's data.stay is the stay as GET /v1/stays/{id} returns it, without the bill. A test booking the hotel makes from its own console (“See it as a guest”) never sends a webhook.
- stay.requested
- stay.confirmed
- stay.declined
- stay.cancelled
- stay.checked_in
- stay.checked_out
- stay.no_show
- stay.changed
POST https://api.your-hotel.ch/kwipps
Content-Type: application/json
Kwipps-Event: stay.confirmed
Kwipps-Delivery: 9d2a…
Kwipps-Signature: t=1790500000,v1=8c4e…
{
"id": "9d2a…", "type": "stay.confirmed", "created": "2026-10-05T08:00:02Z", "api_version": "v1",
"data": { "stay": { "id": "…", "reference": "MNP4Q7", "status": "confirmed", "check_in": "2026-10-20", "check_out": "2026-10-23", … } }
}Check the signature
Every delivery is signed with the webhook's secret (whsec_…, shown once when it is added): Kwipps-Signature is t=unix seconds,v1=the hex HMAC-SHA256 of t + "." + the raw body. Check it against the raw body, before parsing; refuse a timestamp more than five minutes away; compare in constant time. The same function as on the restaurant page:
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));
}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. GET /v1/webhooks and the console show when each webhook last delivered or failed, and why. id is the delivery's: a retry has the same one, so a receiver can ignore a delivery it already handled.
Calendars (iCal)
Not part of the key-based API, but part of the same product: every room has a calendar address (under Channels in the console) that Booking.com, Airbnb and Expedia can read, and the hotel can paste each room's calendar from those sites into Kwipps. Kwipps reads them hourly; a night booked elsewhere is blocked here. Availability only — no prices, no guests — which is what those sites' own calendar exchange carries.
Errors
Every error is { "error": { "code": "…", "message": "…" } } with a status a program can branch on.
| 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 called with a publishable key. |
| 403 forbidden | The hotel's account doesn't allow that action. |
| 409 conflict | The request was already answered, the room isn't free, or the stay can't move to that state. The message says which. |
| 402 subscription_required | The hotel has no active Kwipps subscription. The API switches back on as soon as a plan is paid for. |
| 402 plan_excludes_api | The hotel's plan doesn't include the API (Website). Tools, Business and Pro do. |
| 409 bookings_off | The hotel has switched online bookings off. |
| 422 refused | The stay broke one of the hotel's rules — too many adults for the type, a closed night, a minimum stay, no room left. The message says which, in the guest'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 rate plan the hotel 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, no such stay — or the key's business isn't a hotel. |
| 500 server_error | Something went wrong on our side. A stay request that answers this was not stored; try again (with the same Idempotency-Key), or call the hotel. |
| 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 stay requests a minute per key; after that, 429 with Retry-After. A stay request from one address is limited by the hour, as the website form is. Availability is worked out on every call and never cached; the hotel's settings, rooms and plans are read at most a minute old.
Privacy
A stay request stores what the guest typed and a salted hash of the address it came from, never the address itself. The guest's document numbers (the registration form) are never returned by the API or sent in a webhook. Guests' personal data is deleted from a stay after the hotel's retention period (24 months unless the hotel sets another); the stay itself, its dates and its amounts stay for the hotel's books.