5 мин. четене

# 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](#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](https://www.bookingmood.com/en-US/docs/developers) in browser code: those are secret.

## [Requests](#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](#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 as `YYYY-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 in `organization.capacity_groups`.
- `form_values`: the answers to the [booking form](https://www.bookingmood.com/en-US/docs/booking-form), by the ID in the unit's `services`.

### [1. Validate the cart](#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](#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](#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](#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](#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](#sync-imported-calendars)

If your units [import calendars](https://www.bookingmood.com/en-US/docs/synchronization/ical), 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](#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-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.

Последна промяна 6 ноември 2026 г.
