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.
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.
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 }); 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.
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.
| 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 |
{
"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
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
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"
}
} 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.
Create a link once, then list its slots and book against it whenever you need.
accountIds lists 1 to 20 accounts, each once. Mode all offers a slot when every account is free; any when at least one is.
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.
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).
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.
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.
onlineMeeting adds Teams (Microsoft 365) or Meet (Google). onlineMeetingService of zoom or webex has Workwae create the meeting. Not both.
Two invitees can pick the same slot at the same moment. The database decides who gets it, before any calendar is written.
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.
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.
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.
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.
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.
from and to narrow the range; limit defaults to 200 and goes up to 1000.start and the invitee. Notes of up to 2000 characters are added to the event's description.read for listing and slots, write for everything else.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" 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;
} 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.{
"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"
}
} Branch on the reason in code, not the message. A slot another invitee took is slot_taken; list the slots again.
| 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, such as meeting_service_not_connected.
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.
slot_taken: list the slots again and let the invitee pick another.403 plan_module_unavailable.all, an account that cannot be read is left out of the check rather than blocking every slot.Workwae is in early access; tell us what you are building, and read the docs in the meantime.