1. 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.

bookings.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 9Tue 10Wed 11Thu 12Fri 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

{
  "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 Scheduling API reference

bookings.sh
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
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 Event reference

booking.created
{
  "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
StatusReasonWhen
400invalid_rangeSlots: to is not after from, or the range is longer than the link may look ahead.
400invalid_time_zoneCreate or update: timeZone is not a known IANA zone.
400invalid_working_hoursCreate or update: a window in hours does not end after it starts.
400multiple_meeting_optionsBoth onlineMeeting and onlineMeetingService are set.
403plan_module_unavailableThe plan has no calendar module, or a booking would create a Zoom or Webex meeting without the meetings module.
404scheduling_link_not_foundThe link does not exist.
404booking_not_foundCancel: the booking does not exist.
404calendar_not_foundBook: the host account has no calendar an event can be created in.
409slot_unavailableBook: start is not a slot the link offers now, or no account is free for it.
409slot_takenBook: the slot was booked meanwhile.
409calendar_disabledBook or cancel: the host account has calendar sync off.
409booking_cancelledCancel: the booking is already cancelled.
422unknown_accountCreate 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, 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.

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.
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.