Custom widgets and the channel API
A Custom widget lets you build your own booking experience. You set the rules in Bookingmood, and your own code uses the channel API to load availability and accept bookings.
Pre-release: the channel API can still change, including in ways that break existing code, and this page may not cover everything yet. JavaScript packages for the API aren't available yet. Test your integration carefully before you rely on it.
Create a Custom widget
Custom widgets are available from the Booking plan.
Go to Channels and click New channel.
Choose Custom widget, enter a title and select the units it includes.
To accept bookings, set Interaction to Book.
Copy the Publishable key.
A Custom widget has no display settings. It only sets what its API returns and allows: the units, how booked, closed and pending dates show, whether guests can book, what happens after booking, whether reviews are available and, optionally, a password.
The Publishable key is the widget's ID. It's public, so you can use it in a browser. Never put a Bookingmood API key in browser code: those are secret.
Requests
The base URL is https://www.bookingmood.com/api/public/v1. Requests are allowed from any website.
const channelId = "YOUR_PUBLISHABLE_KEY";
const baseUrl = "https://www.bookingmood.com/api/public/v1";
const response = await fetch(`${baseUrl}/channels/${channelId}`);
const { availability, channel, organization, products } = await response.json();| Method and path | What it does |
|---|---|
GET /channels/:id | The channel, its organization and units, and their availability. Counts as a view of the channel. |
GET /channels/:id/availability | Availability only. Add product_ids once per unit to limit it to those units. |
POST /channels/:id/calendars/sync | Syncs the imported calendars of units. |
GET /channels/:id/reviews | Reviews of a unit, if the channel shows reviews. |
GET /channels/:id/coupons | Coupons by code. Add codes once per code. |
POST /channels/:id/coupons/verify | Checks whether a coupon code can be used. |
POST /channels/:id/cart/validate | Checks whether a cart can be booked, and what it costs. |
POST /channels/:id/cart/checkout | Books a cart. |
POST /channels/:id/access | Exchanges the channel's password for a token. |
GET /bookings/:id | The status and payments of a booking. |
POST /bookings/:id/pay | Gets a payment link for a pending booking. |
POST /bookings/:id/cancel | Cancels a pending booking. |
Book a stay
Bookings go through a cart, which holds one or more stays:
const cart = {
coupon_codes: [],
items: [
{
id: "stay-1", // Your own ID, to match results to items
product_id: "UNIT_ID",
interval: { start: "2027-03-01", end: "2027-03-08" },
occupancy: { CAPACITY_GROUP_ID: 2 },
form_values: { SERVICE_ID: "Arriving around 16:00" },
},
],
};interval: dates asYYYY-MM-DD. A nightly stay ends on the day guests leave. A daily stay ends the day after the last day.occupancy: the number of guests per capacity group, by the ID inorganization.capacity_groups.form_values: the answers to the booking form, by the ID in the unit'sservices.
1. Validate the cart
Validate the cart before you ask a guest for their details or payment. The result says whether each stay can be booked and what it costs. Amounts are in cents.
const response = await fetch(`${baseUrl}/channels/${channelId}/cart/validate`, {
body: JSON.stringify(cart),
headers: { "Content-Type": "application/json" },
method: "POST",
});{
"items": [
{
"availabilityMatch": true,
"id": "stay-1",
"occupancyMatch": true,
"price": {
"currency": "EUR",
"deposit": 0,
"lines": [],
"subtotal": 70000,
"tax": 6300,
"total": 76300
},
"valid": true
}
],
"valid": true
}2. Check out
Send the cart with an optional display currency and language. Checking out creates a real booking and returns a 201 response. The cart can still be refused, because availability can change after you validate it.
const response = await fetch(`${baseUrl}/channels/${channelId}/cart/checkout`, {
body: JSON.stringify({ cart, currency: null, language: "en-US" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const { booking_id, payment_expiry_timestamp, payment_url, reference, secret } =
await response.json();If payment_url isn't null, send the guest there to pay. After paying, guests go where the widget's After booking setting says.
3. Pay or cancel later
Use the booking's secret as bearer token for the /bookings/:id routes. It gives access to the booking's status and payments, without guest details. Share it only with the guest who made the booking.
const response = await fetch(`${baseUrl}/bookings/${booking_id}/pay`, {
body: JSON.stringify({ language: "en-US" }),
headers: {
Authorization: `Bearer ${secret}`,
"Content-Type": "application/json",
},
method: "POST",
});
const { payment_url } = await response.json();Paying and cancelling only work while a booking is waiting for its payment. Otherwise they return 409 with the code booking_not_pending. A successful cancellation returns 204 without a body.
Coupons
POST /channels/:id/coupons/verify takes the code, the product_ids in the cart and the current_codes already entered. Its result is one of:
| Result | Meaning |
|---|---|
VALID | The code can be used. |
INVALID | The code doesn't exist or doesn't apply to these units. |
STACK_NOT_ALLOWED | A code that's already entered can't be combined with others. |
LIMIT_REACHED | The cart already has 5 codes, the maximum. |
Reviews
GET /channels/:id/reviews needs a product_id. You can add a limit from 1 to 1000, which is the default, and an order of most-recent (the default), highest-rating or lowest-rating.
Sync imported calendars
If your units import calendars, send { "product_ids": [...] } to POST /channels/:id/calendars/sync to bring them up to date. The response holds the totals. With Accept: application/x-ndjson, you get a line of JSON as each calendar finishes instead.
A request stops starting new calendars after 40 seconds. If remaining in the result is above 0, send another request.
Password-protected channels
If the widget has a password, every channel request returns 401 with the code password_required until the visitor signs in:
const response = await fetch(`${baseUrl}/channels/${channelId}/access`, {
body: JSON.stringify({ password }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const { expiresAt, token } = await response.json();Send the token as Authorization: Bearer <token> with later requests. It's valid for a week, and expiresAt gives the time in milliseconds.
Errors and limits
Errors have a code and a message:
{
"code": "channel_not_found",
"details": null,
"hint": null,
"message": "No channel found with this ID"
}| Status | Code | Meaning |
|---|---|---|
400 | BM400 | The request doesn't match the schema of the route. details says what's wrong. |
400 | product_not_listed | A unit in the request isn't part of this channel. |
400 | checkout_failed | The cart can't be booked. message says why. |
400 | password_not_set | The channel doesn't have a password. |
401 | password_required | The channel needs an access token. |
401 | invalid_password | The password is incorrect. |
403 | channel_inactive | The organization's plan or subscription doesn't allow this channel. |
403 | channel_not_bookable | The channel's Interaction isn't Book. |
403 | reviews_disabled | The channel doesn't show reviews. |
404 | channel_not_found | No channel has this ID. |
404 | booking_not_found | The booking doesn't exist, or the secret is wrong. |
409 | booking_not_pending | The booking isn't waiting for a payment. |
429 | rate_limited | Too many requests. |
Per IP address, the limits are 300 requests a minute per channel, 60 a minute to the booking routes and 10 a minute to verify coupons. Password attempts are limited too.
Ultima modificare 6 noiembrie 2026