> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ruber.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Booking

> Publish a booking page, read the times it is offering, take bookings and cancel them.

**Required permission:** Booking → Read to list links, slots and bookings;
Booking → Write to publish a link, take a booking or cancel one.

<Warning>
  **Booking is its own permission, and not part of Calendar.**

  Creating a booking link **publishes a page on the open internet** that lets
  strangers put meetings on your calendar. That is a bigger decision than reading
  what is on it, so it is a separate box to tick — the same reasoning that keeps
  Sending separate from Messages.

  Booking links a key creates belong to the organization. A key never sees a
  member's personal booking links or the bookings taken on them.
</Warning>

## How the pieces fit

|               |                                                                                                |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **A link**    | The rules: how long, which days, which hours, in whose time zone. Published at `/book/{slug}`. |
| **Its slots** | Computed. The times those rules leave free once the calendar is taken into account.            |
| **A booking** | One slot taken. It creates a calendar event and a record of who took it.                       |

You never write a slot. Slots are what is left after the rules meet the
calendar, so they are always derived and never stored.

## List and publish links

```
GET  https://app.ruber.me/api/v1/booking-links
POST https://app.ruber.me/api/v1/booking-links
```

```bash theme={null}
curl -X POST https://app.ruber.me/api/v1/booking-links \
  -H "Authorization: Bearer $RUBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "intro-chat",
    "title": "Intro chat",
    "duration_minutes": 30,
    "start_time": "10:00",
    "end_time": "17:00",
    "days": [1, 2, 3, 4, 5],
    "time_zone": "Europe/London",
    "location_kind": "online",
    "location": "https://meet.example.com/intro"
  }'
```

```json theme={null}
{
  "id": "f4241897-6610-4f7c-97fa-49c4003bc4f6",
  "slug": "intro-chat",
  "url": "https://app.ruber.me/book/intro-chat",
  "title": "Intro chat",
  "duration_minutes": 30,
  "buffer_minutes": 0,
  "days": [1, 2, 3, 4, 5],
  "start_time": "10:00",
  "end_time": "17:00",
  "time_zone": "Europe/London",
  "lead_minutes": 120,
  "horizon_days": 30,
  "active": true,
  "location": "https://meet.example.com/intro",
  "location_kind": "online",
  "calendar_id": "126aad38-…",
  "created_at": "2026-09-09T16:31:02.118Z",
  "updated_at": "2026-09-09T16:31:02.118Z"
}
```

| Field                               |              |                                                                           |
| ----------------------------------- | ------------ | ------------------------------------------------------------------------- |
| `slug`                              | **required** | 3–62 characters: lowercase letters, numbers, hyphens. Its public address. |
| `duration_minutes`                  | **required** | 5–480. Also the step between slots.                                       |
| `title`, `description`, `host_name` |              | What the page says.                                                       |
| `days`                              |              | Days open, **0 = Sunday**. Defaults to Monday–Friday.                     |
| `start_time`, `end_time`            |              | 24-hour clock, in `time_zone`. Default 09:00–17:00.                       |
| `time_zone`                         |              | IANA. Defaults to the calendar's.                                         |
| `buffer_minutes`                    |              | 0–240. Padding kept clear either side of a meeting.                       |
| `lead_minutes`                      |              | 0–20160. How soon from now a slot may be taken. Default 120.              |
| `horizon_days`                      |              | 1–365. How far ahead the page opens. Default 30.                          |
| `location_kind`                     |              | `none`, `in_person`, `online`, `phone`.                                   |
| `location`                          |              | The room, the address or the meeting URL.                                 |
| `calendar_id`                       |              | Where bookings land. Defaults to the oldest calendar.                     |
| `active`                            |              | `false` closes the page without deleting anything.                        |

<Note>
  **Times are a clock, days are numbers.** `"10:00"` rather than `600` minutes past
  midnight, because nobody writes an integration in minutes since midnight. Days
  stay numeric with Sunday at `0` — that is what `EXTRACT(DOW)` and JavaScript's
  `getDay()` both use, and inventing a third vocabulary would mean translating
  twice.

  `end_time` may be `"24:00"`, meaning midnight at the far end of the day. `00:00`
  would read as the start of the same day and close the window before it opened.
</Note>

Slugs are unique across **all of Ruber**, not just your organization — they are
paths on one shared domain. A taken slug answers `400` and says nothing about
who holds it.

A window too short to hold one meeting is refused with a sentence naming the
problem, not a constraint name: *"The window from 09:00 to 09:30 is shorter than
the 60-minute meeting it is meant to hold."*

## Read, change, close

```
GET    /v1/booking-links/{id}
PATCH  /v1/booking-links/{id}
DELETE /v1/booking-links/{id}
```

**`{id}` may be the slug.** It is the half you already have — it is in the URL
you published — so a request whose only purpose is translating one identifier
into another is not needed.

`PATCH` changes only the fields you name, and the three rule fields are checked
together against the result: shortening the window and shortening the meeting
are each fine alone and impossible in the wrong order.

<Warning>
  **To take a page down, set `active: false` — do not delete it.**

  Deleting a link that has bookings would delete the record of every meeting taken
  through it **while leaving the meetings themselves on the calendar**. So it is
  refused while any booking stands, and the answer says how many there are.

  `active: false` closes the page immediately and keeps everything.
</Warning>

## The slots a link is offering

```
GET https://app.ruber.me/api/v1/booking-links/{id}/slots
```

The endpoint that makes booking usable from a program. Everything else about a
link can be derived from its record; which times are actually free cannot,
because it depends on the whole calendar.

| Parameter    |                                                 |
| ------------ | ----------------------------------------------- |
| `from`, `to` | ISO 8601 instants. Default: now to 30 days out. |

```json theme={null}
{
  "data": [
    { "starts_at": "2026-09-10T09:00:00.000Z", "ends_at": "2026-09-10T09:30:00.000Z" },
    { "starts_at": "2026-09-10T09:30:00.000Z", "ends_at": "2026-09-10T10:00:00.000Z" }
  ],
  "duration_minutes": 30,
  "link": { "…": "…" },
  "window": { "from": "…", "to": "…" },
  "bookable_between": {
    "from": "2026-09-09T18:31:16.745Z",
    "to": "2026-09-23T16:31:16.745Z"
  }
}
```

<Note>
  **`bookable_between` is why an empty list is not always "fully booked".**

  A link's own `lead_minutes` and `horizon_days` bound what it will ever offer.
  Ask for next year and you get nothing — not because the day is busy, but because
  the page is not open that far ahead. Those two edges are reported so you can
  tell the difference.
</Note>

These are **the same slots the public page shows, from the same code**. Opening
hours, the buffer, the lead time, the horizon and every event already on the
calendar — including repeating ones, expanded — all apply.

## Take a booking

```
POST https://app.ruber.me/api/v1/bookings
```

```bash theme={null}
curl -X POST https://app.ruber.me/api/v1/bookings \
  -H "Authorization: Bearer $RUBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "booking_link": "intro-chat",
    "starts_at": "2026-09-10T09:00:00Z",
    "guest_name": "Alice Chen",
    "guest_email": "alice@acme.com",
    "guest_note": "Happy to go over the migration."
  }'
```

```json theme={null}
{
  "id": "fbc5f6b4-0c3d-49a8-989f-0abf5e8685cc",
  "booking_link_id": "f4241897-…",
  "event_id": "8a5a33be-7bf2-4db3-bdc8-61aee5da4ebd",
  "starts_at": "2026-09-10T09:00:00.000Z",
  "ends_at": "2026-09-10T09:30:00.000Z",
  "status": "confirmed",
  "guest": {
    "name": "Alice Chen",
    "email": "alice@acme.com",
    "note": "Happy to go over the migration."
  },
  "location": "https://meet.example.com/intro",
  "location_kind": "online",
  "created_at": "2026-09-09T16:32:41.882Z",
  "cancelled_at": null
}
```

`starts_at` must be **exactly** one of the offered slots — take it from
`/slots` rather than constructing it. A time that is not on offer is refused
with the next one that is:

```json theme={null}
{
  "error": {
    "code": "invalid_request",
    "message": "That time is not one this link is offering. The next free slot is 2026-09-10T09:30:00.000Z; GET /v1/booking-links/intro-chat/slots lists them."
  }
}
```

Everyone in the organization is notified, exactly as they are for a booking
taken through the public page.

## Being told about bookings

Subscribe to [`booking.created` and `booking.cancelled`](/api/webhooks#events)
rather than polling `GET /v1/bookings` on a timer.

Both routes raise them — a guest on `/book/{slug}` and a call to this API —
because the event is raised when the booking row appears rather than by either
caller. Since nearly every booking comes from the public page, an integration
that only watched its own writes would miss almost all of them.

Personal booking links raise nothing, for the same reason a key cannot read
them. See the warning on the [Webhooks](/api/webhooks) page.

## List and cancel

```
GET    /v1/bookings
GET    /v1/bookings/{id}
DELETE /v1/bookings/{id}
```

| Parameter           |                                   |
| ------------------- | --------------------------------- |
| `link`              | Only this link's, by id or slug.  |
| `from`, `to`        | Bookings starting in that range.  |
| `include_cancelled` | `true` to include cancelled ones. |
| `limit`, `page`     | 1–200, 0-based.                   |

Cancelled bookings are hidden by default: a cancelled meeting is not on anybody's
calendar, so including it would answer "what is booked" with things that are not.

<Note>
  **Cancelling gives the time back.**

  `DELETE /v1/bookings/{id}` stamps `cancelled_at`, marks the calendar event
  cancelled, and **puts the slot back on offer** — the same time can be booked
  again by somebody else.

  The record is kept rather than deleted, because "who booked this and then
  cancelled" is a real question. The event is marked cancelled rather than
  removed, so the owner can still see what was in their day and who called it
  off; deleting it would make the meeting vanish with no trace, which is how
  somebody turns up anyway.

  ```json theme={null}
  { "status": "cancelled", "cancelled_at": "…", "slot_released": true }
  ```

  Cancelling something already cancelled succeeds and changes nothing. You asked
  for it to be cancelled, and it is.
</Note>

## Not available yet

**The public booking page itself is not an API.** `/book/{slug}` is a page for
people. A guest booking through it does not need a key, and this API is the
owner's side of the same data.

**Confirmation and reminder emails** are sent by the app on the schedule it
already uses. There is no way to trigger, suppress or re-send one here.

**Rescheduling** is a cancel and a new booking. There is no single call that
moves one, because the new time has to be checked against the slots exactly as
a first booking would be.

## Errors

| Code              | HTTP  | Cause                                                                                                                                                                     |
| ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | `400` | A slug that is taken or malformed, a window too short for the meeting, a time not on offer, a time already taken, an inactive link, or deleting a link that has bookings. |
| `unauthorized`    | `401` | Missing, malformed, unknown or revoked key.                                                                                                                               |
| `forbidden`       | `403` | The key lacks `booking:read` or `booking:write`.                                                                                                                          |
| `not_found`       | `404` | No organization booking link or booking with that id or slug. A personal one answers the same way.                                                                        |
| `rate_limited`    | `429` | Over 600 requests in a minute.                                                                                                                                            |

See [Errors](/api/introduction#errors) for the full response shape.
