Guides
Guide: your own booking website
This guide builds the server side of a booking website on the Partner API: from the room list to the confirmation page and later changes. The browser talks to your backend; your backend talks to STRYK.
Before you start
- A key with
property:read,availability:read,rates:read,bookings:readandbookings:write. Addpayments:readif the confirmation page shows the payment status. - A backend (Node.js 18+ or Python 3.10+). The key stays there, see Authentication.
- Agreement with the hotel on test dates. There is no sandbox; cancel test bookings straight away.
The examples use a small helper that adds the key, sends JSON and turns problem details into exceptions:
// stryk.js, server-side helper (Node.js 18+). The key never reaches the browser.
const BASE_URL = 'https://app.staystryk.com/api/partner/v1';
export class StrykError extends Error {
constructor(status, problem) {
super(problem.detail ?? problem.title ?? `HTTP ${status}`);
this.status = status;
this.code = problem.code;
this.problem = problem; // RFC 9457 problem details, incl. requestId
}
}
export async function stryk(path, { method = 'GET', body, headers = {} } = {}) {
const response = await fetch(BASE_URL + path, {
method,
headers: {
Authorization: `Bearer ${process.env.STRYK_API_KEY}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = response.status === 204 ? null : await response.json();
if (!response.ok) throw new StrykError(response.status, data ?? {});
return { data, etag: response.headers.get('ETag'), location: response.headers.get('Location') };
}# stryk.py, server-side helper. The key never reaches the browser.
import os
import requests
BASE_URL = "https://app.staystryk.com/api/partner/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['STRYK_API_KEY']}"
class StrykError(Exception):
def __init__(self, status, problem):
super().__init__(problem.get("detail") or problem.get("title") or f"HTTP {status}")
self.status = status
self.code = problem.get("code")
self.problem = problem # RFC 9457 problem details, incl. requestId
def stryk(path, method="GET", json=None, headers=None, params=None):
response = session.request(method, BASE_URL + path, json=json, headers=headers or {}, params=params, timeout=30)
data = None if response.status_code == 204 else response.json()
if not response.ok:
raise StrykError(response.status_code, data or {})
return data, response.headers1. Load room types and images
GET /room-types returns names, descriptions per language, occupancy limits and images sorted by position. Room types change rarely: cache them and refresh on a schedule or when your sync job sees a change.
import { stryk } from './stryk.js';
const { data: page } = await stryk('/room-types?limit=200');
const roomTypes = page.data
.filter((roomType) => roomType.active)
.map((roomType) => ({
id: roomType.id,
name: roomType.name,
text: roomType.description?.[locale] ?? roomType.description?.en ?? '',
maxOccupancy: roomType.maxOccupancy,
images: [...roomType.images].sort((a, b) => a.position, b.position),
}));
// Cache this list (e.g. 15 minutes) and refresh it on your sync job, not per visitor.from stryk import stryk
page, _ = stryk("/room-types", params={"limit": 200})
room_types = [
{
"id": room_type["id"],
"name": room_type["name"],
"text": (room_type.get("description") or {}).get(locale, ""),
"max_occupancy": room_type["maxOccupancy"],
"images": sorted(room_type["images"], key=lambda image: image["position"]),
}
for room_type in page["data"]
if room_type["active"]
]2. Check availability
GET /availability returns free units and restrictions (closed, minStay, closedToArrival …) per room type and day, for up to 366 days. Use it for calendars and search results. The quote in the next step is the binding check for one concrete stay.
// Calendar view: free units per night of the requested stay.
const params = new URLSearchParams({ from: checkIn, to: checkOut, roomTypeId });
const { data: availability } = await stryk(`/availability?${params}`);
const nights = availability.data.filter((day) => day.date < checkOut); // nights are checkIn … checkOut − 1
const bookable = nights.length > 0 && nights.every((day) => day.available > 0 && !day.closed);
const arrivalClosed = nights[0]?.closedToArrival === true;availability, _ = stryk("/availability", params={"from": check_in, "to": check_out, "roomTypeId": room_type_id})
nights = [day for day in availability["data"] if day["date"] < check_out]
bookable = bool(nights) and all(day["available"] > 0 and not day.get("closed") for day in nights)
arrival_closed = bool(nights) and nights[0].get("closedToArrival") is True3. Get a quote
POST /quotes calculates the price with the hotel's own rules: nightly prices, taxes and fees, cancellation policy. It does not reserve anything. If the stay cannot be booked as requested, the response has available: false and reasons, that is not an error.
const { data: quote } = await stryk('/quotes', {
method: 'POST',
body: { checkIn, checkOut, roomTypeId, ratePlanId, adults: 2, childrenAges: [6] },
});
if (!quote.available) {
// Not an error: the stay is not bookable as requested (e.g. minimum stay).
return renderUnavailable(quote.reasons);
}
renderOffer({
total: quote.total, // { amount: "486.00", currency: "EUR" }
nightly: quote.nightly, // [{ date, price }]
taxesAndFees: quote.taxesAndFees, // e.g. tourist tax, included or not
cancellationPolicy: quote.cancellationPolicy,
});quote, _ = stryk("/quotes", method="POST", json={
"checkIn": check_in,
"checkOut": check_out,
"roomTypeId": room_type_id,
"ratePlanId": rate_plan_id,
"adults": 2,
"childrenAges": [6],
})
if not quote["available"]:
return render_unavailable(quote["reasons"]) # not an error
return render_offer(quote["total"], quote["nightly"], quote["taxesAndFees"], quote.get("cancellationPolicy"))Show the guest exactly quote.total and keep it with the checkout. You will send it back as expectedTotal.
4. Create the booking
POST /bookings needs an Idempotency-Key. Generate one per checkout attempt and store it with the checkout: if the connection drops, you send the same request with the same key and receive the original result instead of a second booking.
STRYK prices the booking again on the server. If expectedTotal differs from the current price, STRYK creates nothing and answers 409 conflict with currentTotal. STRYK assigns a room of the requested type automatically.
import { randomUUID } from 'node:crypto';
import { stryk, StrykError } from './stryk.js';
export async function createBooking(checkout) {
// One key per checkout attempt. Persist it with the checkout so a retry reuses it.
checkout.idempotencyKey ??= randomUUID();
try {
const { data: booking } = await stryk('/bookings', {
method: 'POST',
headers: { 'Idempotency-Key': checkout.idempotencyKey },
body: {
roomTypeId: checkout.roomTypeId,
ratePlanId: checkout.ratePlanId,
checkIn: checkout.checkIn,
checkOut: checkout.checkOut,
adults: checkout.adults,
childrenAges: checkout.childrenAges,
guest: {
firstName: checkout.guest.firstName,
lastName: checkout.guest.lastName,
email: checkout.guest.email,
phone: checkout.guest.phone,
},
notes: checkout.notes,
externalReference: `WEB-${checkout.id}`,
expectedTotal: checkout.quote.total, // the price the guest saw
},
});
return { status: 'booked', booking };
} catch (error) {
if (!(error instanceof StrykError)) throw error; // network error: retry with the same key
switch (error.code) {
case 'not_available':
return { status: 'sold_out' }; // reload availability, offer alternatives
case 'conflict':
if (error.problem.currentTotal) {
checkout.idempotencyKey = undefined; // the next attempt is a new request
return { status: 'price_changed', currentTotal: error.problem.currentTotal };
}
throw error;
case 'idempotency_in_progress':
return { status: 'pending' }; // wait a moment, then retry with the same key
default:
throw error; // log error.problem.requestId
}
}
}import uuid
from stryk import stryk, StrykError
def create_booking(checkout):
# One key per checkout attempt. Persist it so a retry reuses it.
checkout.setdefault("idempotency_key", str(uuid.uuid4()))
try:
booking, _ = stryk("/bookings", method="POST", headers={"Idempotency-Key": checkout["idempotency_key"]}, json={
"roomTypeId": checkout["room_type_id"],
"ratePlanId": checkout["rate_plan_id"],
"checkIn": checkout["check_in"],
"checkOut": checkout["check_out"],
"adults": checkout["adults"],
"childrenAges": checkout["children_ages"],
"guest": checkout["guest"], # firstName, lastName, email, phone
"notes": checkout.get("notes"),
"externalReference": f"WEB-{checkout['id']}",
"expectedTotal": checkout["quote"]["total"], # the price the guest saw
})
return {"status": "booked", "booking": booking}
except StrykError as error:
if error.code == "not_available":
return {"status": "sold_out"}
if error.code == "conflict" and "currentTotal" in error.problem:
checkout.pop("idempotency_key") # the next attempt is a new request
return {"status": "price_changed", "current_total": error.problem["currentTotal"]}
if error.code == "idempotency_in_progress":
return {"status": "pending"} # wait, then retry with the same key
raise # log error.problem["requestId"]{
"id": "9d3c1b2a-7e6f-4a5b-8c9d-0e1f2a3b4c5d",
"reference": "B-2026-01482",
"status": "confirmed",
"channel": "api",
"externalReference": "WEB-1234",
"checkIn": "2026-12-01",
"checkOut": "2026-12-04",
"nights": 3,
"adults": 2,
"children": 1,
"childrenAges": [6],
"rooms": [{ "roomTypeId": "0b7e2c1d-5f4a-4c3b-9e8d-7a6b5c4d3e2f", "adults": 2, "children": 1 }],
"guest": { "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c", "firstName": "Anna", "lastName": "Berger", "email": "anna.berger@example.com" },
"total": { "amount": "486.00", "currency": "EUR" },
"paid": { "amount": "0.00", "currency": "EUR" },
"outstanding": { "amount": "486.00", "currency": "EUR" },
"createdAt": "2026-11-04T08:15:00.000Z",
"updatedAt": "2026-11-04T08:15:00.000Z",
"version": "1a2b3c4d"
}5. Handle sold-out rooms and price changes
| Response | Meaning | What your website does |
|---|---|---|
409 not_available | The last free room was booked in the meantime, or a restriction applies. | Tell the guest, reload availability, offer other dates or room types. |
409 conflict with currentTotal | The price has changed since the quote. | Show the new price and ask for confirmation. The confirmed attempt is a new request with a new Idempotency-Key. |
409 idempotency_in_progress | The first request with this key is still running. | Wait a second and retry with the same key. |
422 validation_failed | A field is invalid; errors[] names it. | Show the message next to the field. |
Timeout, 5xx | Unknown whether the booking exists. | Retry with the same key. You get the stored result of the first request. |
{
"type": "https://staystryk.com/developers/errors#conflict",
"title": "Conflict",
"status": 409,
"detail": "The price of this stay has changed.",
"instance": "/api/partner/v1/bookings",
"code": "conflict",
"requestId": "4c3b2a19-0f8e-4d7c-b6a5-948372615049",
"currentTotal": { "amount": "512.00", "currency": "EUR" }
}6. Show the confirmation
Read the booking back and show what STRYK stored: the booking number in reference, the status, the stay and the open balance in outstanding.
// Confirmation page: show what STRYK stored, not what the form contained.
const { data: booking } = await stryk(`/bookings/${bookingId}`);
renderConfirmation({
reference: booking.reference, // the hotel's booking number
status: booking.status, // confirmed | tentative | pending …
stay: [booking.checkIn, booking.checkOut, booking.nights],
total: booking.total,
outstanding: booking.outstanding, // paid at the hotel / via the hotel's payment path
});7. Follow later changes with webhooks
The front desk may change dates, add a note or cancel. Register a webhook endpoint in STRYK for booking.updated and booking.cancelled and fetch the booking when an event arrives. Events are thin by design: they say what changed, the API says how it looks now.
// Worker: idempotent by event id, fetches the current state instead of trusting the event.
export async function handleEvent(event, db) {
if (await db.processedEvents.has(event.id)) return; // duplicate delivery
switch (event.type) {
case 'booking.created':
case 'booking.updated':
case 'booking.cancelled': {
const { data: booking } = await stryk(`/bookings/${event.data.id}`);
await db.bookings.upsertIfNewer(booking); // compare updatedAt
break;
}
case 'availability.changed':
await refreshAvailability(event.data.roomTypeIds, event.data.from, event.data.to);
break;
case 'webhook.test':
break; // sent from STRYK on request
}
await db.processedEvents.add(event.id);
}Signature check, retries and delivery log: Webhooks.
8. Cancel a booking
POST /bookings/{id}/cancel cancels through STRYK's normal cancellation process. The hotel's cancellation policy and any fees apply exactly as for a cancellation at the front desk. An Idempotency-Key is required; a key derived from the booking ID makes repeated clicks harmless.
const { data: cancelled } = await stryk(`/bookings/${bookingId}/cancel`, {
method: 'POST',
headers: { 'Idempotency-Key': `cancel-${bookingId}` },
body: { reason: 'Cancelled by guest on the hotel website' },
});
// cancelled.status === 'cancelled'; cancelled.cancellation.cancelledAt
// The hotel's cancellation policy and fees apply, exactly as in STRYK.cancelled, _ = stryk(
f"/bookings/{booking_id}/cancel",
method="POST",
headers={"Idempotency-Key": f"cancel-{booking_id}"},
json={"reason": "Cancelled by guest on the hotel website"},
)
assert cancelled["status"] == "cancelled"Bookings that are checked in, checked out or already cancelled return 409 booking_not_modifiable.
Checklist before go-live
- The key is only on the server, with exactly the scopes listed above.
- Every booking attempt has its own stored
Idempotency-Key; retries reuse it. - The guest sees the quoted price, and
expectedTotalis sent. not_availableandconflictlead to a clear message, not an error page.- Webhook signatures are verified on the raw body; events are processed once per
id. - The website states how the open balance is paid.
- All test bookings are cancelled.