---
title: "Bookings · Workwae"
description: "Scheduling links and bookings: Workwae lists open slots across one or more calendars and books one without a double booking, and you render the booking page."
url: https://workwae.com/features/bookings
---

1. [Features](https://workwae.com/features/)
2. Bookings

Bookings

# Bookable time without double bookings.

A scheduling link stores the rules. Workwae lists its open slots across one or more calendars, books the one your invitee picks, and creates the event on the account that hosts it.

[Request early access](https://workwae.com/early-access/) Try the simulator

bookings.ts

```ts
import { createClient } from "@workwae/sdk";

const workwae = createClient({
  apiKey: process.env.WORKWAE_API_KEY,
});

// The rules: two accounts, round-robin, 30 minutes.
// Hours default to Monday to Friday, 09:00 to 17:00.
const link = await workwae.schedulingLinks.create({
  name: "Sales intro",
  accountIds: [aliceAccountId, bobAccountId],
  mode: "any",
  durationMinutes: 30,
  timeZone: "America/New_York",
  bufferAfterMinutes: 10,
  minimumNoticeMinutes: 60,
  title: "Intro call with {{invitee}}",
  onlineMeeting: true,
});

// For your booking page: open slots and who is free for each
const { items: slots } = await workwae.schedulingLinks.slots({
  linkId: link.id,
  limit: 50,
});

// Re-checks the slot, claims it, creates the event
const booking = await workwae.bookings.create({
  linkId: link.id,
  start: slots[0]!.start,
  invitee: { name: "Ada Lovelace", address: "ada@example.com" },
  notes: "Interested in the team plan",
});
booking.accountId; // the account that hosts it
booking.eventId; // the event it created

// Deletes the event, tells its attendees
await workwae.bookings.cancel({ bookingId: booking.id });
```

Try it

## See the rules decide which slots exist.

An example booking page for a link over two calendars. Change the rules, book a slot, then send a second request for the same one. It is an illustration with canned data: nothing here calls Workwae.

Example: Sales intro, Alice and Bob, America/New_York. Canned data, no network.

Now: Monday Nov 9, 08:00. Slots for the week of Nov 9. Without JavaScript this shows the default link; the controls and booking need it.

Open slot starts, Monday November 9 to Friday November 13, 2026, with the accounts free for each

| Start time | Mon 9 | Tue 10 | Wed 11 | Thu 12 | Fri 13 |
| --- | --- | --- | --- | --- | --- |
| 09:00 | B | A | B | A | A B |
| 09:30 | busy | A | B | A | A |
| 10:00 | busy | A B | B | A | A |
| 10:30 | A | B | B | A | A |
| 11:00 | A B | B | B | A B | A |
| 11:30 | A B | B | B | A B | A B |
| 12:00 | – | – | – | – | – |
| 12:30 | – | – | – | – | – |
| 13:00 | A B | A B | A B | A B | B |
| 13:30 | B | A | A | A B | B |
| 14:00 | B | A | A | A B | B |
| 14:30 | B | A | A | B | B |
| 15:00 | A B | A | A B | B | B |
| 15:30 | A B | A B | A B | B | B |
| 16:00 | A B | A B | A B | B | B |
| 16:30 | A B | A B | A B | A B | B |

- A B open, with the accounts free for it (A is Alice, B is Bob)
- busy a calendar is busy, or buffer a meeting falls inside the buffer
- soon inside the minimum notice, full daily cap reached, – outside the hours or off the step

### The stored link

```
{
  "mode": "any",
  "durationMinutes": 30,
  "stepMinutes": 30,
  "bufferBeforeMinutes": 0,
  "bufferAfterMinutes": 10,
  "minimumNoticeMinutes": 60,
  "maxPerDay": null
  // plus accountIds [alice, bob],
  // timeZone America/New_York,
  // hours Mon-Fri 09:00-12:00, 13:00-17:00
}
```

Bookings of this link, last 30 days: Alice 3 · Bob 3

### The booking

**201 Created** Static example for Mon 09:00: Alice's calendar is busy once the 10-minute buffer is added, so Bob hosts. Bob was the only account free for this slot.

Intro call with Ada Lovelace

Mon Nov 9, 09:00–09:30 (America/New_York)

On Bob's primary calendar (bob@example.com)

Attendees: Ada Lovelace <ada@example.com>

Online meeting link added (Teams or Meet, by the account's provider)

booking.created

```json
{
  "id": "1001",
  "type": "booking.created",
  "accountId": "9d2e4b6c-1a3f-4c5e-8b7d-2f4a6c8e0b1d",
  "createdAt": "2026-11-09T13:00:00.000Z",
  "data": {
    "bookingId": "4c6e8a0b-2d4f-4a6c-8e0b-1d3f5a7c0001",
    "linkId": "b0d2f4a6-8c0e-4a2c-9e1b-3d5f7a9c1e3b",
    "eventId": "e1f3a5c7-9b2d-4f6e-a8c0-2e4f6a8b0001",
    "start": "2026-11-09T14:00:00.000Z",
    "end": "2026-11-09T14:30:00.000Z",
    "invitee": "ada@example.com"
  }
}
```

**The two calendars behind the grid**

### Alice

- Mon 09:30–10:30
- Mon 14:00–15:00
- Tue 11:00–12:00
- Wed 09:00–13:00
- Thu 15:00–16:30
- Fri 13:00–17:00

### Bob

- Mon 10:00–11:00
- Tue 09:00–10:00
- Tue 14:00–15:30
- Wed 14:00–15:00
- Thu 09:00–11:00
- Fri 10:00–11:30

Hours are Monday to Friday, 09:00 to 12:00 and 13:00 to 17:00. Each account starts with 3 bookings of this link in the last 30 days.

What a link stores

## The rules live in the link, not in your code.

Create a link once, then list its slots and book against it whenever you need.

### One account or up to 20

`accountIds` lists 1 to 20 accounts, each once. Mode `all` offers a slot when every account is free; `any` when at least one is.

### Round-robin

In `any` mode the booking goes to the free account with the fewest bookings of this link in the last 30 days, any status. Ties go to the earliest in `accountIds`.

### When slots exist

Hours per weekday in an IANA time zone, up to 8 windows a day (default Monday to Friday, 09:00 to 17:00). Slots are 5 to 1440 minutes and start on multiples of the step after local midnight, up to 365 days ahead (14 by default).

### Room to breathe

Buffers before and after widen each account's busy time, up to 1440 minutes each. Minimum notice goes up to 60 days, and a daily cap of 1 to 1000 counts booked bookings per local day.

### The event it creates

A title and description where `{{invitee}}` and `{{inviteeEmail}}` are filled in at booking time, and a location. The invitee is an attendee, and the booking's notes go into the description.

### A meeting link

`onlineMeeting` adds Teams (Microsoft 365) or Meet (Google). `onlineMeetingService` of `zoom` or `webex` has Workwae create the meeting. Not both.

How a booking works

## Claimed before the event exists.

Two invitees can pick the same slot at the same moment. The database decides who gets it, before any calendar is written.

1.

   ### Re-check

   Workwae reads each account's calendar again and checks that `start` is still one of the link's slots. If not, it answers `409 slot_unavailable`.

2.

   ### Claim

   It picks the host and claims that account and start in the database. Two bookings of the same account and start cannot both succeed; the other answers `409 slot_taken`.

3.

   ### Create the event

   The event goes on the host's primary calendar, or the first calendar it can write, with the invitee as an attendee. If the event cannot be created, the claim is released.

4.

   ### Announce

   The booking is returned with its `accountId` and `eventId`, and `booking.created` is sent to your webhooks.

Cancelling deletes the event, telling its attendees, marks the booking `cancelled` and sends `booking.cancelled`. An event that is already gone still cancels successfully. Mode `any` tries the free accounts in turn, so a second request for a slot two accounts can take lands on the other one; the simulator shows both outcomes.

Build it

## Four calls, one booking page.

Create a link, list its slots for your page, book the one the invitee picks, and cancel when they ask. The TypeScript flow is at the top of the page; this is the same flow over HTTP.

- **Slots** come back with the accounts free for each, in the link's order. `from` and `to` narrow the range; `limit` defaults to 200 and goes up to 1000.
- **Booking** takes a slot's `start` and the invitee. Notes of up to 2000 characters are added to the event's description.
- **Links** can be listed, read, changed (only the fields given) and deleted. Deleting a link deletes its bookings and leaves their events.
- **Scopes:** `read` for listing and slots, `write` for everything else.

[Bookings guide](https://docs.workwae.com/guides/scheduling) [Scheduling API reference](https://docs.workwae.com/reference/scheduling)

bookings.sh

```text
API=https://api.workwae.com/v1

# Create the link
curl -X POST "$API/scheduling-links" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "name": "Sales intro",
  "accountIds": ["'"$ALICE_ACCOUNT_ID"'", "'"$BOB_ACCOUNT_ID"'"],
  "mode": "any",
  "durationMinutes": 30,
  "timeZone": "America/New_York",
  "bufferAfterMinutes": 10,
  "minimumNoticeMinutes": 60,
  "title": "Intro call with {{invitee}}",
  "onlineMeeting": true
}'

# List its open slots
curl "$API/scheduling-links/$LINK_ID/slots?limit=50" \
  -H "Authorization: Bearer $WORKWAE_API_KEY"

# Book one
curl -X POST "$API/scheduling-links/$LINK_ID/bookings" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "start": "2026-11-02T14:00:00Z",
  "invitee": { "name": "Ada Lovelace", "address": "ada@example.com" },
  "notes": "Interested in the team plan"
}'

# Cancel it
curl -X DELETE "$API/bookings/$BOOKING_ID" \
  -H "Authorization: Bearer $WORKWAE_API_KEY"
```

handle-conflicts.ts

```ts
import { isApiError } from "@workwae/sdk";

try {
  await workwae.bookings.create({ linkId, start, invitee });
} catch (error) {
  // Someone got there first: list the slots again
  if (
    isApiError(error, "slot_taken") ||
    isApiError(error, "slot_unavailable")
  ) {
    return showSlotsAgain();
  }
  throw error;
}
```

Events

## Two webhook events, one payload.

`booking.created` and `booking.cancelled` carry the same fields. The envelope's `accountId` is the account that hosts the event.

- `bookingId`: the booking.
- `linkId`: the scheduling link that was booked.
- `eventId`: the calendar event on the account, or `null` once it is gone.
- `start` and `end`: the booked slot.
- `invitee`: the invitee's address.

[Webhooks and the change feed](https://workwae.com/features/webhooks) [Event reference](https://docs.workwae.com/guides/events/calendar-and-contacts#booking)

booking.created

```json
{
  "id": "1001",
  "type": "booking.created",
  "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "createdAt": "2026-05-04T09:30:00.000Z",
  "data": {
    "bookingId": "4c6e8a0b-2d4f-4a6c-8e0b-1d3f5a7c9e1b",
    "linkId": "b0d2f4a6-8c0e-4a2c-9e1b-3d5f7a9c1e3b",
    "eventId": "e1f3a5c7-9b2d-4f6e-a8c0-2e4f6a8b0d1c",
    "start": "2026-05-05T13:00:00.000Z",
    "end": "2026-05-05T13:30:00.000Z",
    "invitee": "grace@example.org"
  }
}
```

Errors

## Every refusal has a reason.

Branch on the reason in `code`, not the message. A slot another invitee took is `slot_taken`; list the slots again.

Error reasons of scheduling links and bookings

| Status | Reason | When |
| --- | --- | --- |
| 400 | `invalid_range` | Slots: to is not after from, or the range is longer than the link may look ahead. |
| 400 | `invalid_time_zone` | Create or update: timeZone is not a known IANA zone. |
| 400 | `invalid_working_hours` | Create or update: a window in hours does not end after it starts. |
| 400 | `multiple_meeting_options` | Both onlineMeeting and onlineMeetingService are set. |
| 403 | `plan_module_unavailable` | The plan has no calendar module, or a booking would create a Zoom or Webex meeting without the meetings module. |
| 404 | `scheduling_link_not_found` | The link does not exist. |
| 404 | `booking_not_found` | Cancel: the booking does not exist. |
| 404 | `calendar_not_found` | Book: the host account has no calendar an event can be created in. |
| 409 | `slot_unavailable` | Book: start is not a slot the link offers now, or no account is free for it. |
| 409 | `slot_taken` | Book: the slot was booked meanwhile. |
| 409 | `calendar_disabled` | Book or cancel: the host account has calendar sync off. |
| 409 | `booking_cancelled` | Cancel: the booking is already cancelled. |
| 422 | `unknown_account` | Create or update: accountIds names an account the tenant does not have; data.ids lists them. |

Booking a Zoom or Webex link can also answer the meeting errors in the [meetings guide](https://docs.workwae.com/guides/meetings#errors), such as `meeting_service_not_connected`.

Where Workwae stops

## You render the booking page.

There is no hosted booking page. Workwae serves a link's slots and books them through authenticated API calls; your product shows the slots, collects the invitee's name and address, and calls the booking API.

### Workwae does

- Reads each account's free/busy from its provider, and lists slots from the rules. Tentative, out-of-office and unknown time block a slot; working elsewhere does not.
- Re-checks, claims and books, creates the event, asks the provider to notify attendees, and sends the events.
- Creates the Teams, Meet, Zoom or Webex link when the link asks for one.
- Lists slots from the accounts it can read; an account it cannot, such as one with calendar sync off, is left out.

### You build

- The page that shows slots, in your own design and in the invitee's time zone.
- Collecting the invitee and, if you want, notes.
- What happens on `slot_taken`: list the slots again and let the invitee pick another.
- Reminders, rescheduling and anything else around the booking; there is no reschedule call, so cancel and book again.

### Plan requirements

- Every scheduling call needs the calendar module; without it, `403 plan_module_unavailable`.
- A link that creates a Zoom or Webex meeting also needs the meetings module when it is booked; its meeting errors are in the [meetings guide](https://docs.workwae.com/guides/meetings#errors).

### Good to know

- Slots and bookings are instants; hours, step and the daily cap are read in the link's time zone.
- Changing a link leaves existing bookings and their events as they are.
- With mode `all`, an account that cannot be read is left out of the check rather than blocking every slot.

Related

## Where bookings fit.

### [Calendar](https://workwae.com/features/calendar)

Every calendar, recurring series, RSVP and free/busy across providers.

### [Meetings](https://workwae.com/features/meetings)

Zoom and Webex meetings created with the event.

### [Webhooks](https://workwae.com/features/webhooks)

Signed events and an ordered change feed.

### [Scheduling](https://workwae.com/use-cases/scheduling)

Find free time, book the event and attach the meeting in one flow.

Early access

## Add bookable time to your product without double bookings.

Workwae is in early access; tell us what you are building, and read the docs in the meantime.

[Request early access](https://workwae.com/early-access/) [Read the bookings guide](https://docs.workwae.com/guides/scheduling)
