Quickstart in five minutes
Make a room, book an hour in it, watch a second booking of the same hour be refused, then read what’s free.
Get a key
Sign up, free. The console shows your workspace’s first key once: it starts
with sbk_. Copy it into an environment variable on your server. It can’t be shown again; if you
lose it, make another in the console.
Base URL
https://api.schedule-bit.io
Every path starts with /v1/. The same address serves the interactive reference and the MCP server.
Authentication
Send the key on every call, in the Authorization header. Bodies and answers are JSON.
Authorization: Bearer sbk_… Your first requests
Set the base URL and key once. In JavaScript, a five-line helper does every call.
export SB=https://api.schedule-bit.io
export SB_KEY=sbk_… # your key const SB = 'https://api.schedule-bit.io';
const SB_KEY = process.env.SB_KEY; // sbk_…, kept on your server
async function sb(method, path, body) {
const res = await fetch(SB + path, {
method,
headers: { Authorization: `Bearer ${SB_KEY}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
} -
Make a World
A World is one resource with its own calendar: here, a meeting room. Its days have 24 one-hour slots.
curl -X POST "$SB/v1/worlds" \ -H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \ -d '{"name":"room-1"}'await sb('POST', '/v1/worlds', { name: 'room-1' });Answer 201 Created { "name": "room-1", "globalName": "acme/room-1", "preset": "temporal-hour", "seats": 1, … } -
Declare two people
Anyone who can hold time on a World is an occupier. Declare each one by name.
curl -X POST "$SB/v1/worlds/room-1/occupiers" \ -H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \ -d '{"name":"ada"}' curl -X POST "$SB/v1/worlds/room-1/occupiers" \ -H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \ -d '{"name":"grace"}'await sb('POST', '/v1/worlds/room-1/occupiers', { name: 'ada' }); await sb('POST', '/v1/worlds/room-1/occupiers', { name: 'grace' });Answer 201 Created { "occupier": { "id": 2, "kind": "holder", "name": "ada", "rank": 0, "pinned": false, "holder": null } } -
Book an hour for Ada
A claim names who, the day, and the hours as slot numbers: slot 9 is 09:00 to 10:00 UTC. The Idempotency-Key makes a retry safe: the same request with the same key gets the same receipt back, never a second booking.
curl -X POST "$SB/v1/worlds/room-1/claims" \ -H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: ada-2026-10-15-09" \ -d '{"who":"ada","date":"2026-10-15","slots":[9]}'const booked = await sb('POST', '/v1/worlds/room-1/claims', { who: 'ada', date: '2026-10-15', slots: [9], }); console.log(booked.status, booked.body.slots); // 201 [ 9 ]Answer 201 Created { "wrote": "0x200", "slots": [9], "displaced": [], … } -
Try the same hour for Grace
Grace can’t have it. The answer says so, with the reason in words (detail) and as a field your code can branch on (refusal.by).
curl -X POST "$SB/v1/worlds/room-1/claims" \ -H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \ -d '{"who":"grace","date":"2026-10-15","slots":[9]}'const refused = await sb('POST', '/v1/worlds/room-1/claims', { who: 'grace', date: '2026-10-15', slots: [9], }); console.log(refused.status, refused.body.refusal.by, refused.body.detail); // 409 held slots 9 are held by adaAnswer 409 Conflict { "type": "https://schedule-bit.dev/problems/refused/held", "title": "Refused", "status": 409, "detail": "slots 9 are held by ada", "refusal": { "by": "held", "conflictsWith": ["ada"], "slots": "0x200", … } } -
Read what’s free
Every hour of the day but 9 is still free.
curl "$SB/v1/worlds/room-1/offered?date=2026-10-15" \ -H "Authorization: Bearer $SB_KEY"const free = await sb('GET', '/v1/worlds/room-1/offered?date=2026-10-15'); console.log(free.body.slots); // [ 0, 1, …, 8, 10, …, 23 ]Answer 200 OK { "offered": "0xfffdff", "slots": [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, …, 23], … }
Dates and slots.
- A date is a UTC day,
YYYY-MM-DD. Slot numbers are hours, 0 to 23. - On a minute World, send
hourwith the date, and slots are minutes, 0 to 59. -
Answers give slots twice: as a list, and as a hex mask (
"0x200"is slot 9). Send either. -
World names are 1 to 63 characters of
a-z 0-9 . _ -. Wherever you name an occupier, its name or its id will do.
Concepts in one minute
- Workspace
- Your account. Its Worlds, keys and webhooks are its own; no other workspace sees them.
- World
- A resource with a calendar: a room, a machine, a person’s hours. Each day has 24 hourly slots, or make it with "preset": "temporal-minute" for 60 one-minute slots in each hour.
- Occupier
- Who or what can hold time on a World: a person, or an event or series held in a person’s name.
- Slot
- One hour (or minute) of a day, by number. You send slots as a list, like [9, 10].
- Claim
- A booking: who, which day, which slots. It answers with a receipt or a refusal.
- Release
- Giving booked slots back, so they are free again.
- Soft hold
- A claim sent with "soft": true and an until time. It marks intent without taking the slots, and lapses unless it is confirmed.
- Relay
- One job carried through several Worlds in order: cut, then print, then deliver. Each step is asked of its World, never taken.
- Ask
- What a relay sends a World for one step. The World’s owner accepts, rejects, or offers other slots.
- Grant
- Permission one workspace gives another to see its Worlds and send them asks.
- Webhook
- A URL of yours that schedule-bit posts signed events to.
- Key and scope
- A key (sbk_…) is how a call proves which workspace it is from. Its scopes, read, write and admin, say what it may do.
A receipt or a reason
Every booking gets an answer. Either a receipt (201): the slots that are now held, and
anyone a higher rank displaced. Or a refusal (409) that says why, in words in
detail and as a code in refusal.by. Never silence, and never half a booking. To
ask whether a booking would go through without making it, send the same body to
POST /v1/worlds/{w}/claims:explain.
A typed client (SDK)
An official SDK package isn’t published yet. Coming soon
Until it is, generate a fully typed client from the OpenAPI 3.2 document at
/v1/openapi.json. It is the API’s source of
truth: the API is checked against it on every release, so the types match what you get back. In TypeScript,
openapi-typescript writes the types and openapi-fetch makes the calls:
bun add openapi-fetch
bunx openapi-typescript https://api.schedule-bit.io/v1/openapi.json \
-o schedule-bit.d.ts --default-non-nullable false import createClient from 'openapi-fetch';
import type { paths } from './schedule-bit';
const sb = createClient<paths>({
baseUrl: 'https://api.schedule-bit.io',
headers: { Authorization: `Bearer ${process.env.SB_KEY}` },
});
const w = 'room-1';
const { data, error, response } = await sb.POST('/v1/worlds/{w}/claims', {
params: { path: { w } },
body: { who: 'ada', date: '2026-10-15', slots: [9, 10] },
});
if (data) console.log('booked', data.slots); // booked [ 9, 10 ]
else console.log(response.status, error.detail);
const free = await sb.GET('/v1/worlds/{w}/offered', {
params: { path: { w }, query: { date: '2026-10-15' } },
});
console.log(free.data?.slots.length, 'hours free'); // 22 hours free -
--default-non-nullable falsekeeps fields that have a default, like a World’spreset, optional in what you send. -
npxworks the same asbunx. The generator runs on TypeScript 5; in a project on a newer TypeScript, addtypescript@5as a dev dependency. - Regenerate the types when the API changes.
Other languages. Any OpenAPI generator can start from the same document, but check that yours reads OpenAPI 3.2: many read only 3.0 or 3.1 so far, and OpenAPI Generator 7.25 doesn’t read this one yet. Until then, plain HTTP is enough: every example on this page is a plain request.
AI assistants (MCP)
The API is also an MCP server, at /v1/mcp over Streamable HTTP. Connect Claude Code with your key:
claude mcp add --transport http schedule-bit \
https://api.schedule-bit.io/v1/mcp \
--header "Authorization: Bearer sbk_…" -
Every operation is a tool, named by its operation id:
createWorld,claim,offered,relayActand the rest, 46 in all. -
A tool call meets the same key, scopes, limits and answers as the HTTP call it stands for. It returns
{status, body}, and a refusal comes back with its reason. - Then ask in words: “make a World called room-2 and book Ada from 14:00 to 16:00 on 20 October”. The assistant reads what’s free, books, and tells you why when a booking is refused.
-
Give it only the scopes you want it to have. With a
readkey it can look but not book. - Any MCP client that speaks Streamable HTTP and can send a header connects the same way.
Live updates and webhooks
Watch a World over a WebSocket to keep a screen current, and give your servers a webhook. Both carry the same events:
| Event | Happens when | Watch | Webhook |
|---|---|---|---|
claim.made | a booking went through | ✓ | ✓ |
claim.released | slots were given back | ✓ | ✓ |
ask.received | a relay asked one of your Worlds for time | ✓ | ✓ |
ask.withdrawn | the relay took its ask back | ✓ | ✓ |
ask.answered | an ask was answered | ✓ | ✓ |
relay.acted | a relay asked, synced, withdrew or delegated | ✓ | ✓ |
commit | anything changed, with the details of the change | ✓ | – |
An event is {id, type, world, at, commitSeq, head, data}. The id is stable, so
drop one you have already seen. Events are sent at most once: if you miss one, read the World again.
Watch a World
A browser can’t put a header on a WebSocket, and your key should never be in a page anyway. So your server trades its key for a watch ticket, and the browser opens the watch with it. A ticket opens one World, once, within 60 seconds; get a new one for every reconnect. A server can skip the ticket and send its key as a header.
// On your server: trade the key for a ticket (a read key is enough).
const res = await fetch(`${SB}/v1/worlds/room-1/watch-tickets`, {
method: 'POST',
headers: { Authorization: `Bearer ${SB_KEY}`, 'Content-Type': 'application/json' },
body: '{}',
});
const { ticket } = await res.json(); // { ticket: "wt_…", expiresAt: … }
// In the browser: open the watch with the ticket. No key in the page.
const ws = new WebSocket(`wss://api.schedule-bit.io/v1/worlds/room-1/watch?ticket=${ticket}`);
ws.onmessage = (e) => {
const event = JSON.parse(e.data);
if (event.type === 'claim.made') console.log(event.data);
// { who: "grace", slots: [14, 15], displaced: [] }
}; // Bun: send the key as a header, no ticket needed.
const ws = new WebSocket('wss://api.schedule-bit.io/v1/worlds/room-1/watch', {
headers: { Authorization: `Bearer ${process.env.SB_KEY}` },
});
ws.onmessage = (e) => console.log(JSON.parse(e.data).type); // hello, claim.made, … { "type": "hello", "world": { "globalName": "acme/room-1", "preset": "temporal-hour", … } }
{ "id": "acme/room-1:2:claim.made", "type": "claim.made", "world": "acme/room-1",
"at": 1790953032758, "data": { "who": "grace", "slots": [14, 15], "displaced": [] }, … } Webhooks
Register an https URL and the events it wants (or ["*"] for all). The answer holds
the signing secret, shown this once. A workspace can have 10 webhooks.
curl -X POST "$SB/v1/webhooks" \
-H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/schedule-bit","events":["claim.made","claim.released"]}' {
"id": "wh_255e80b845354365",
"url": "https://example.com/hooks/schedule-bit",
"events": ["claim.made", "claim.released"],
"secret": "whsec_…",
"createdAt": 1790953921771
}
Each delivery is a POST of the event as JSON, with x-schedule-bit-event (its type),
x-schedule-bit-delivery (its id) and x-schedule-bit-signature:
t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with the secret>. Check it before you
act:
// Verify every delivery before you trust it.
async function verify(secret: string, body: string, header: string): Promise<boolean> {
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header) ?? [];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`));
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('') === v1;
}
Bun.serve({
port: 8787,
async fetch(req) {
const body = await req.text(); // the raw body: verify before you parse
const signature = req.headers.get('x-schedule-bit-signature') ?? '';
if (!(await verify(process.env.SB_WEBHOOK_SECRET!, body, signature))) {
return new Response('bad signature', { status: 401 });
}
const event = JSON.parse(body);
console.log(event.type, event.data); // claim.made { who: "grace", slots: [ 11 ], … }
return new Response(null, { status: 204 });
},
}); - Answer with any
2xxto accept a delivery. -
Anything else is retried with growing waits (10 s, 20 s, 40 s, up to an hour), 10 times in all.
410 Gonestops deliveries to that URL. - Remove a webhook with
DELETE /v1/webhooks/wh_….
Errors, retries and limits
Every error is application/problem+json with type, title,
status and detail, a sentence you can show a person:
{
"type": "https://schedule-bit.dev/problems/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "2026-13-40 is not a day of the calendar"
} | Status | Means |
|---|---|
| 400 | The request can’t be read: a bad date, an unknown occupier, a malformed body. |
| 401 | No key, or a key that is unknown, expired or revoked. |
| 403 | The key lacks the scope for this call; detail names it. |
| 404 | No such World, relay, webhook, key or grant. |
| 409 | With refusal: a booking refused, and why. Without: a conflict, such as a World name already taken. |
| 413 | The body is over 64 KiB. |
| 422 | An Idempotency-Key reused with a different body, or a relay that can’t be laid out. |
| 402 | Over the plan: see the limits below. |
| 429 | Too many requests: wait Retry-After seconds. |
Refusals
A refused booking is a 409 whose body carries refusal. Branch on
refusal.by:
| refusal.by | Means |
|---|---|
| held | someone already holds those slots (conflictsWith names them) |
| capacity | every seat on those slots is taken |
| rule, rest, horizon | the occupier’s own limits |
| stopped | a series that has been stopped |
| open, stale, untaken, unknown, unasked, order, past, foreign | relay and ask steps taken out of turn |
Safe retries
Send an Idempotency-Key header (1 to 255 characters) on any write. For 24 hours, the same key
with the same body gets the first answer again, so a retry after a dropped connection can’t book twice. The
same key with a different body gets 422 Idempotency Key Reused. Retry a 429 after
Retry-After seconds:
// Retry a 429 after the time the API asks for; return anything else.
async function call(url: string, init: RequestInit = {}, tries = 4): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429 || attempt === tries) return res;
const seconds = Number(res.headers.get('Retry-After') ?? '1');
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
} What you get at each limit
The numbers depend on your plan; they are on Pricing.
| Limit | Answer | What to do |
|---|---|---|
| Requests a minute, per key and per workspace | 429 Too Many Requests | Wait Retry-After seconds, then retry. Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, so you can slow down before you hit it. |
| Writes a month (any call that changes something) | 402 Quota Exceeded | Retry-After says when the month turns. Reads still work. A bigger plan lifts it within a minute. |
| Worlds per workspace | 402 Plan Limit | Waiting won’t help: move to a bigger plan. |
| Assistant runs a month (POST /v1/assist) | 402 at the monthly limit · 429 while one runs | One run at a time per key: a second one gets 429 with Retry-After. Runs on your own Anthropic key aren’t counted. |
A request over the per-minute limit, as it comes back:
HTTP/2 429
content-type: application/problem+json
ratelimit-limit: 300
ratelimit-remaining: 0
ratelimit-reset: 21
retry-after: 21
{
"type": "https://schedule-bit.dev/problems/too-many-requests",
"title": "Too Many Requests",
"status": 429,
"detail": "the Developer beta plan allows 300 requests a minute here; try again in 21 s"
} See where you stand this month:
curl "$SB/v1/usage" -H "Authorization: Bearer $SB_KEY" {
"period": { "id": "2026-10", "end": "2026-11-01T00:00:00.000Z", … },
"usage": { "requests": 3, "writes": 0, "worlds": 0, "assists": 0 },
"plan": {
"id": "beta",
"name": "Developer beta",
"limits": {
"requestsPerMinute": 600,
"keyRequestsPerMinute": 300,
"maxWorlds": 25,
"monthlyWrites": 100000,
"assistsPerMonth": 20
}
},
…
} Keys and scopes
A key holds some of three scopes:
| Scope | Allows |
|---|---|
| read | Every read on Worlds: what’s free, holdings, relays, asks. Watching. Listing webhooks. |
| write | Every other call on Worlds: make, declare, book, release, relay acts. Creating and deleting webhooks. |
| admin | Keys (list, create, rotate, revoke), webhooks and grants. Nothing on Worlds. |
Your first key has all three. GET /v1/me tells any key which workspace, scopes and plan it has.
Create, rotate and revoke keys in the console, or with
an admin key:
# a read-only key for a dashboard, good for 90 days
curl -X POST "$SB/v1/keys" \
-H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \
-d '{"label":"dashboard","scopes":["read"],"expiresInDays":90}'
# replace it, letting the old one work for one more hour
curl -X POST "$SB/v1/keys/${ID}:rotate" \
-H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \
-d '{"graceSeconds":3600}'
# revoke it now
curl -X DELETE "$SB/v1/keys/${ID}" -H "Authorization: Bearer $SB_KEY" {
"id": "key_016452cf3097437f",
"label": "dashboard",
"scopes": ["read"],
"hint": "sbk_…VcnP",
"state": "active",
"secret": "sbk_…",
…
} - The
secretis shown once, when a key is made or rotated. After that you see only its hint. -
Rotating stops the old key at once, unless
graceSeconds(up to a day) keeps both working while you roll out the new one. - A key can’t make a key with more scopes than it has.
Keep keys secret.
- Never put a key in a URL, a query string, or code that ships to a browser.
- Keep it in your server’s environment. Browsers watch with tickets, never keys.
- Give each app its own key, with only the scopes it needs, so you can revoke one without stopping the rest.
Full reference
/v1/docs OpenAPI document OpenAPI 3.2.0, for generators, tests and tools. /v1/openapi.json Every operation, by area:
Basics
| GET | /v1/health | Is the API up |
| GET | /v1/presets | The kinds of World that can be made |
| GET | /v1/openapi.json | This document, as JSON |
| GET | /v1/docs | This document, as a reference page |
| POST | /v1/mcp | This API as MCP tools (Model Context Protocol) |
| GET | /v1/me | Who this key is |
Worlds
| GET | /v1/worlds | The workspace's Worlds |
| POST | /v1/worlds | Make a World |
| GET | /v1/worlds/{w} | One World |
Occupiers
| GET | /v1/worlds/{w}/occupiers | Who may hold time here |
| POST | /v1/worlds/{w}/occupiers | Declare a holder, event or series |
| POST | /v1/worlds/{w}/capacity | How many may hold one slot at once |
Booking
| POST | /v1/worlds/{w}/claims | Book slots: a receipt, or a refusal with its reason |
| POST | /v1/worlds/{w}/claims:explain | Judge a claim without making it |
| POST | /v1/worlds/{w}/releases | Give slots back |
| POST | /v1/worlds/{w}/handovers | Pass slots from one party to another in one act |
| POST | /v1/worlds/{w}/confirmations | Make a soft hold firm |
| POST | /v1/worlds/{w}/give-ups | Let a soft hold go |
| POST | /v1/worlds/{w}/queue | Wait in line for held slots |
| POST | /v1/worlds/{w}/sweeps | Lapse soft holds past their until |
Reading
| GET | /v1/worlds/{w}/offered | Free slots at a position |
| GET | /v1/worlds/{w}/holdings | What one party holds at a position |
| GET | /v1/worlds/{w}/journal | Every change made to the World, oldest first, in pages |
| GET | /v1/worlds/{w}/planes | Who holds what, over a run of days (or hours) |
Live updates
| POST | /v1/worlds/{w}/watch-tickets | A ticket to watch from a browser |
| GET | /v1/worlds/{w}/watch | Every event as it happens (WebSocket) |
Relays
| GET | /v1/worlds/{w}/hands | Worlds this one has copied busy time from, for relays |
| POST | /v1/worlds/{w}/hands | Copy another World's busy time in, for a relay (again refreshes it) |
| GET | /v1/worlds/{w}/relays | Relays planned here |
| POST | /v1/worlds/{w}/relays | Lay a relay out |
| GET | /v1/worlds/{w}/relays/{r} | Where a relay stands, and what may be done next |
| GET | /v1/worlds/{w}/relays/{r}/windows | Every start where the whole relay fits |
| POST | /v1/worlds/{w}/relays/{r}/acts | Ask, sync, withdraw, or delegate a leg |
Asks
| GET | /v1/worlds/{w}/asks | What planners have asked of this World |
| POST | /v1/worlds/{w}/asks/{token}/answer | Answer an ask |
Grants
| GET | /v1/grants | Grants given and received |
| POST | /v1/grants | Let another workspace see or ask your Worlds |
| DELETE | /v1/grants/{id} | Revoke a grant you gave |
Keys
| GET | /v1/keys | The workspace's keys |
| POST | /v1/keys | Mint a key |
| DELETE | /v1/keys/{id} | Revoke a key |
| POST | /v1/keys/{id}:rotate | Replace a key with a successor |
Webhooks
| GET | /v1/webhooks | Where events are posted |
| POST | /v1/webhooks | Post events to a URL |
| DELETE | /v1/webhooks/{id} | Stop posting to a webhook |
Usage and billing
| GET | /v1/usage | This month's use against the plan |
| GET | /v1/billing | The plan, the subscription, and the plans on sale |
| POST | /v1/billing/checkout | Buy a plan |
| POST | /v1/billing/portal | Manage the subscription |
Assistant
| POST | /v1/assist | Let the Director read or act on your Worlds, step by step (server-sent events) |
| GET | /v1/assist/config | Whether the Director is offered here, to this key, on whose Anthropic key, and this month's use |
| POST | /v1/assist/key-check | Check an Anthropic key before a run spends it |