Developers Public beta

Build with schedule-bit

Add booking to your app that never double-books. One HTTP call books a slot, and the answer is either a receipt or the reason it can’t.

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.

http
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
  1. 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"}'
    Answer 201 Created
    {
      "name": "room-1",
      "globalName": "acme/room-1",
      "preset": "temporal-hour",
      "seats": 1,
      …
    }
  2. 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"}'
    Answer 201 Created
    {
      "occupier": { "id": 2, "kind": "holder", "name": "ada", "rank": 0, "pinned": false, "holder": null }
    }
  3. 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]}'
    Answer 201 Created
    { "wrote": "0x200", "slots": [9], "displaced": [], … }
  4. 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]}'
    Answer 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", … }
    }
  5. 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"
    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 hour with 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:

sh
bun add openapi-fetch
bunx openapi-typescript https://api.schedule-bit.io/v1/openapi.json \
  -o schedule-bit.d.ts --default-non-nullable false
ts
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 false keeps fields that have a default, like a World’s preset, optional in what you send.
  • npx works the same as bunx. The generator runs on TypeScript 5; in a project on a newer TypeScript, add typescript@5 as 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:

sh
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, relayAct and 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 read key 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:

EventHappens whenWatchWebhook
claim.madea booking went through✓✓
claim.releasedslots were given back✓✓
ask.receiveda relay asked one of your Worlds for time✓✓
ask.withdrawnthe relay took its ask back✓✓
ask.answeredan ask was answered✓✓
relay.acteda relay asked, synced, withdrew or delegated✓✓
commitanything 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: [] }
};
Frames hello, then events
{ "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.

sh
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"]}'
Answer 201 Created
{
  "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:

ts
// 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 2xx to 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 Gone stops 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:

Answer 400 Bad Request
{
  "type": "https://schedule-bit.dev/problems/bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "2026-13-40 is not a day of the calendar"
}
StatusMeans
400The request can’t be read: a bad date, an unknown occupier, a malformed body.
401No key, or a key that is unknown, expired or revoked.
403The key lacks the scope for this call; detail names it.
404No such World, relay, webhook, key or grant.
409With refusal: a booking refused, and why. Without: a conflict, such as a World name already taken.
413The body is over 64 KiB.
422An Idempotency-Key reused with a different body, or a relay that can’t be laid out.
402Over the plan: see the limits below.
429Too many requests: wait Retry-After seconds.

Refusals

A refused booking is a 409 whose body carries refusal. Branch on refusal.by:

refusal.byMeans
heldsomeone already holds those slots (conflictsWith names them)
capacityevery seat on those slots is taken
rule, rest, horizonthe occupier’s own limits
stoppeda series that has been stopped
open, stale, untaken, unknown, unasked, order, past, foreignrelay 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:

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

LimitAnswerWhat 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:

Answer 429 Too Many Requests
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:

sh
curl "$SB/v1/usage" -H "Authorization: Bearer $SB_KEY"
Answer 200 OK
{
  "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:

ScopeAllows
readEvery read on Worlds: what’s free, holdings, relays, asks. Watching. Listing webhooks.
writeEvery other call on Worlds: make, declare, book, release, relay acts. Creating and deleting webhooks.
adminKeys (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:

sh
# 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"
Answer 201 Created
{
  "id": "key_016452cf3097437f",
  "label": "dashboard",
  "scopes": ["read"],
  "hint": "sbk_…VcnP",
  "state": "active",
  "secret": "sbk_…",
  …
}
  • The secret is 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

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