Base URL
https://schedule-bit-api-dev.khaled-mailhub.workers.dev
Every path starts with /v1/. Dev deployment; a production hostname is Coming soon
Keys are issued by request during the developer beta; self-serve sign-up is Coming soon
Status
| Stage | Developer beta (dev). No production hostname yet. |
| Base URL (dev) | https://schedule-bit-api-dev.khaled-mailhub.workers.dev |
| API version | v1: every path starts with /v1/ |
| Runs on | Cloudflare Workers + Durable Objects (one per World), D1, Queues |
| Verified | 2026-09-26: the CI smoke suite passes against dev (World, door, WebSocket, relay across Durable Objects) |
| Keys | Issued by the operator (cloud/scripts/issue-key.ts). There is no self-serve sign-up yet. |
The product in one picture
╭─────────────╮ Bearer sbk_… ┌──────────────────────────────────┐
│ your app / │ ────────────────▶ │ schedule-bit API (v1) │
│ SDK / curl │ ◀──────────────── │ ▓ one World = one Durable Object│
╰─────────────╯ JSON / 409 └──────────────────────────────────┘
▲ │ after every commit
│ WebSocket (live) ▼
└──────────────────────── ┄┄ events ┄┄▶ signed webhooksWhat it is, in a sentence: a scheduling engine as an API. Any resource (a room, a machine, a person's hours) is a World whose time is stored as bits. Claims go through a guarded door that answers with a receipt or a reason. Every act is sealed in a tamper-evident journal. One job can be carried through several resources as a relay: each resource is asked, answers on its own record, and is tracked leg by leg.
The engine has three layers, and each maps to part of the API:
LAYER WHAT IT IS API SURFACE
───────────── ─────────────────────────────── ────────────────────────
schedule-bit binary planes: slots as bits presets, offered, positions
planes occupiers, door, record, watch occupiers, claims, journal
relay one job through many Worlds hands, relays, asksCapability map
R W L what it gives you
─────────────────────────────────────────────────────────────────────
WORLDS
create a World (a resource) ● create a resource with a preset
list / describe ● preset · seats · head hash
PRESETS
temporal-hour (day → 24 h) ● positions by date
temporal-minute (hour → 60 m) ● positions by date + hour
OCCUPIERS
holders · events · series ● ● who can hold, with rank/pinned
capacity (seats) ● N may hold one slot at once
THE DOOR
claim ● receipt or refusal, never both
claim:explain ● judge without writing
soft hold (until …) ● intent that lapses by the clock
confirm · give-up · queue ● firm up, let go, wait in line
release · handover ● give back, pass on in one act
displace (by rank) ● loser told, offered elsewhere
sweep ● lapse overdue soft holds
READING
offered ● free slots at a position
holdings ● held + soft slots of one party
journal ?since= ● sealed commits, hash-chained
RELAYS
take a hand in ● copy another World's busy-ness
lay a relay (legs, gaps, after) ● one job, many hands, ordered
windows ● every start where all legs fit
ask · sync · withdraw · delegate ● the four acts on a relay
standing + possible ● where it stands, what's next
ASKS (a hand's inbox)
list asks ● what planners want of you
answer: accept / reject / ● your yes, no, take-back,
takeBack / moveTo or counter-slot
EVENTS
watch (WebSocket) ● every commit, as it lands
webhooks (signed, retried) ● ● ask.received, ask.answered…R read (GET), W write (a command that changes the record), L live (pushed as it happens).
Conventions
Authentication. Send Authorization: Bearer sbk_…. Only /v1/health
and /v1/presets are open without a key. A key belongs to one workspace
and has the scopes read and/or write; GET needs read, anything else
needs write. Keys are stored only as SHA-256 hashes, and a key is shown
once, when it is issued.
Workspaces are walls. A key sees only its workspace's Worlds. The same World name in two workspaces means two different Worlds.
Names.
- World and relay names: 1–63 characters of
a-z 0-9 . _ -, starting with a letter or digit. - Globally, a World is
workspace/world. Other Worlds address it by this name, and it is also the name of the World's keeper, the holder whose busy time is the World's own.
Positions. A position is where slots live. Send either
date: "2026-10-01"(UTC), plushour: 0–23on atemporal-minuteWorld, or- raw
parametric(the year) +coordinates([month 0–11, day 1–31], plus the hour on minute Worlds).
Slots. Send either a list of indices, [9, 10], or a hex mask,
"0x600". temporal-hour has 24 slots (hours); temporal-minute has 60
(minutes). Responses give both the mask and the list.
Moments (relay starts) are a position plus slot:
{ "date": "2026-05-15", "slot": 9 }.
Errors. Every error is RFC 9457 application/problem+json, with type,
title, status and detail (a sentence a person can read).
| Status | Meaning |
|---|---|
| 400 | The request can't be read (bad date, unknown occupier, malformed body) |
| 401 / 403 | Missing or unknown key / missing scope, or a World in another workspace |
| 404 | No such World, relay, or webhook |
| 409 | Refused. The body carries refusal, the engine's reason (see below) |
| 413 | Body over 64 KiB |
| 422 | Idempotency key reused with a different body, or a relay that cannot be laid out |
| 503 | The World can't serve: its log failed its integrity check, or it predates the current log format |
Refusals (409) always name why. refusal.by is one of:
held: someone holds those slotscapacity: no seats leftrule,rest,horizon: the party's own limitsforeign: someone else's planestopped: a stopped seriesstale: the hand moved since it was taken inopen: an ask of yours still standsuntaken: that hand was never taken inunknown: no ask by that tokenunasked: nothing stands for that legorder,past: ordering and timing refusals
{ "type": "https://schedule-bit.dev/problems/refused/held", "title": "Refused", "status": 409,
"detail": "slots 10 are held by alice",
"refusal": { "by": "held", "conflictsWith": ["alice"], "slots": "0x400", "outranked": true, "pinned": false } }Idempotency. Every write accepts Idempotency-Key (1–255 characters).
For 24 hours the same key with the same body returns the first answer, and
the same key with a different body gets 422.
Journal headers. Writes that commit return x-journal-head, the hash of
the World's record after the act.
Endpoints
Open
| Verb | Path | Returns |
|---|---|---|
| GET | /v1/health | {"ok":true} |
| GET | /v1/presets | {"presets":[{"id":"temporal-hour","position":"day","leafSlotBits":24}, …]} |
Worlds
| Verb | Path | Body / query | Returns |
|---|---|---|---|
| GET | /v1/worlds | {"worlds":[{name, preset, createdAt}]} | |
| POST | /v1/worlds | {name, preset?, allowDisplace?, tieBreak?} | 201: the World described |
| GET | /v1/worlds/{w} | {name, globalName, preset, leafSlotBits, allowDisplace, tieBreak, seats, occupiers, relays, seq, head} |
presetistemporal-hour(the default) ortemporal-minute.allowDisplace: truelets a higher rank displace a lower one.tieBreaksettles equal ranks:incumbent(the default),first-comeorid.
Occupiers and capacity
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | /v1/worlds/{w}/occupiers | {"occupiers":[{id, kind, name, rank, pinned, holder, keeper?}]} | |
| POST | /v1/worlds/{w}/occupiers | {name, kind?: holder|event|series, holder?, rank?, pinned?} | 201 {occupier} |
| POST | /v1/worlds/{w}/capacity | {seats} (1–1024, only grows) | {seats} |
- A holder is a party that holds in its own name.
- An event or series is held in a holder's name, so it can be moved, ranked or stopped on its own.
- Wherever a request names an occupier (
who,from,to), you may send its id or its unique name.
The door (claims and other acts)
| Verb | Path | Body | Success |
|---|---|---|---|
| POST | /v1/worlds/{w}/claims | {who, position, slots, rank?, soft?, until?} | 201 receipt |
| POST | /v1/worlds/{w}/claims:explain | same as a claim | 200 {admissible:true} or 409, without writing |
| POST | /v1/worlds/{w}/releases | {who, position, slots} | 200 receipt |
| POST | /v1/worlds/{w}/handovers | {from, to, position, slots} | 200 receipt |
| POST | /v1/worlds/{w}/confirmations | {who, position, slots} | 200: a soft hold made firm |
| POST | /v1/worlds/{w}/give-ups | {who, position, slots} | 200: a soft hold let go |
| POST | /v1/worlds/{w}/queue | {who, position, slots} | 200 {place} in line for held slots |
| POST | /v1/worlds/{w}/sweeps | {} | 200: soft holds past until lapsed |
soft: true holds as intent: it takes nothing from others until it is
confirmed, and lapses at until (milliseconds since the epoch).
The claim path, in order:
POST /claims {who, date, slots}
│
▼
╱ stopped series? ╲──yes──▶ 409 stopped
╲ ╱
│no
╱ held by others? ╲──yes──▶ outranks? ──no──▶ 409 held (by whom)
╲ ╱ │yes
│no ▼
╱ capacity left? ╲──no──▶ 409 displace + offer loser a slot
╲ ╱ │
│yes │
╱ own rules pass? ╲──no──▶ 409 rule / rest / horizon
╲ ╱ │
│yes ◀─────────────────────────────┘
▼
201 receipt {slots, displaced[], commitSeq, head}
└──▶ one sealed commit in the journalA receipt:
{ "wrote": "0x600", "slots": [9, 10],
"displaced": [{ "occupier": "bob", "slots": [9], "offered": { "parametric": 2026, "coordinates": [9, 1], "slots": [11] } }],
"commitSeq": 4, "head": "20fc4842bffb6b9b0341187cd5c99583" }Reading
| Verb | Path | Query | Returns |
|---|---|---|---|
| GET | /v1/worlds/{w}/offered | position | {parametric, coordinates, offered:"0xfff9ff", slots:[…]}: free slots |
| GET | /v1/worlds/{w}/holdings | who + position | {occupier, held, heldSlots, soft, softSlots} |
| GET | /v1/worlds/{w}/journal | since (seq), limit (≤1000, default 100) | {head, seq, commits:[…]} |
Each journal commit is {seq, tick, actor, kind, ref, prevHash, hash, entries:[{occupier, role, key, set, clear}]}:
- Every
hashseals the previous one, so changing any past commit breaks every hash after it. setandclearare exact diffs of the bits.
Relays: one job through many Worlds
A planner World runs a relay. Its legs fall on hand Worlds. In the
current release, hands must be in the planner's workspace; a bare name like
maker means yourworkspace/maker.
| Verb | Path | Body / query | Returns |
|---|---|---|---|
| GET | /v1/worlds/{w}/hands | {"hands":[{hand, root}]} | |
| POST | /v1/worlds/{w}/hands | {hand} | takes the hand's busy time in (again = refresh) |
| GET | /v1/worlds/{w}/relays | every relay with its standing | |
| POST | /v1/worlds/{w}/relays | {name, legs:[{hand, length, gapAfter?, after?}]} | 201: the relay |
| GET | /v1/worlds/{w}/relays/{r} | standing, legs, possible acts | |
| GET | /v1/worlds/{w}/relays/{r}/windows | position + positions (1–512) | {starts:[moment…], blockedAt} |
| POST | /v1/worlds/{w}/relays/{r}/acts | see below | the relay + told (what changed) |
Leg fields:
lengthis in slots.gapAfteris how many slots must pass before the next leg.afterlists earlier legs this one waits for, counting from 0. Legs withafter: []run side by side from the start.- A relay has at most 32 legs.
The acts are ask, sync, withdraw and delegate:
{act:"ask", start:{date, slot}}sends one ask per leg. Nothing is held until each hand says yes.{act:"sync"}fetches the hands' answers.{act:"withdraw"}takes every ask and every held leg back, on every record.{act:"delegate", leg, to}asks another hand for that leg at the same slots.
A relay's standing is read off the records, never stored. Leg standings are
unasked, asked, held, refused, withdrawn and released. The
relay's own standing is one of those, or mixed. possible lists the acts
allowed now.
planner World maker printer courier
────────────── ───── ─────── ───────
take in hands ◀── busy-ness ── ● ● ●
lay relay: 4h ─2─▶ 2h ─3─▶ 1h
windows? → starts 9,10,11,12 (one AND over every hand's free bits)
ask @ 9 ─────── ask ────────────▶ ● ───────▶ ● ─────────▶ ●
accept reject accept
sync ◀────── answers ───────── held refused held
delegate leg 1 ── ask ──────────────────────▶ printer2 → accept
sync → ▓ all held ▓
withdraw → every hand's slots come free againAsks: a hand's inbox
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | /v1/worlds/{w}/asks | {"asks":[{token, from, parametric, coordinates, slots, state, held, because}]} | |
| POST | /v1/worlds/{w}/asks/{token}/answer | {verdict: accept|reject|takeBack|moveTo, slots?} | {answer} |
- An ask's
stateisopen,accepted,rejected,withdrawnorreleased. acceptclaims through this World's own door, so it can be refused too.moveToanswers with different slots.- An ask's token is a content hash that both sides compute the same way, so a repeated ask is found rather than made twice.
Events: WebSocket and webhooks
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | /v1/worlds/{w}/watch | WebSocket upgrade | first {type:"hello", world}, then every event |
| GET | /v1/webhooks | {"webhooks":[{id, url, events, createdAt}]} (no secrets) | |
| POST | /v1/webhooks | {url (https), events?: [...] | ["*"]} | 201 {id, url, events, secret, createdAt}; the secret is shown once |
| DELETE | /v1/webhooks/{id} | {deleted} |
A workspace may have up to 10 webhooks.
EVENT HAPPENS WHEN WATCH (WS) WEBHOOK
─────────────── ─────────────────────────────── ────────── ───────
commit any act wrote to the record ● ─
claim.made a claim went through the door ● ●
claim.released slots given back ● ●
ask.received a planner's ask reached you ● ●
ask.withdrawn a planner took its ask back ● ●
ask.answered you answered (planner may sync) ● ●
relay.acted a relay asked/synced/withdrew… ● ●An event is {id, type, world, at, commitSeq, head, data}. The id is
stable (world:commitSeq:type), so receivers can deduplicate. Events are
sent at most once; the journal is the record, and a missed event can be
found there.
Webhook deliveries are POST with a JSON body and these headers:
x-schedule-bit-event: the event typex-schedule-bit-delivery: the event idx-schedule-bit-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>
A non-2xx response is retried with backoff (10 s, 20 s, 40 s … up to 1 h, 10
tries), then moved to a dead-letter queue. 410 Gone stops deliveries to
that receiver. Verifying a delivery:
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;
}Guarantees across every endpoint
worlds door relays asks events
auth (API keys) ● ● ● ● ● scoped read/write
workspace walls ● ● ● ● ● no cross-tenant
idempotency ● ● ● ● ─ Idempotency-Key
audit (journal) ─ ● ● ● ─ hash-chained
tamper check ● ● ● ● ─ replay must match head
metering ● ● ● ● ─ per workspace/route- One writer per World. Each World lives in its own Durable Object and handles one request at a time, so two claims can never both win the same slot.
- Tamper-evident rebuild. A World rebuilds itself by replaying its log. If the rebuilt journal head doesn't match the one it recorded, the World refuses to serve (503) rather than serve something altered.
- Asks survive outages. Asks to a hand that is unreachable wait, in order, and are retried every 30 s.
Limits
| Limit | Value |
|---|---|
| Request body | 64 KiB |
| Idempotency memory | 24 h |
| Journal page | ≤ 1000 commits |
| Seats per World | 1–1024 |
| Legs per relay | ≤ 32 |
| Relay window horizon | ≤ 512 positions |
| Webhooks per workspace | 10 |
| Webhook retries | 10, then the dead-letter queue |
| Edge rate limit | prod only, 1200 requests / min / IP (none on dev) |
A complete session (curl)
U=https://schedule-bit-api-dev.khaled-mailhub.workers.dev; K=sbk_…
H=(-H "authorization: Bearer $K" -H 'content-type: application/json')
curl "${H[@]}" -X POST $U/v1/worlds -d '{"name":"room-1","allowDisplace":true}'
curl "${H[@]}" -X POST $U/v1/worlds/room-1/occupiers -d '{"name":"alice","rank":1}'
curl "${H[@]}" -H 'idempotency-key: c-1' -X POST $U/v1/worlds/room-1/claims \
-d '{"who":"alice","date":"2026-10-01","slots":[9,10]}'
curl "${H[@]}" "$U/v1/worlds/room-1/offered?date=2026-10-01"
curl "${H[@]}" "$U/v1/worlds/room-1/journal"Not live yet Coming soon
Do not present these as available. They are planned, not built:
- Cross-organisation relays. Today a relay's hands must be in the planner's workspace (phase 3).
- Self-serve sign-up, a dashboard or console (hosted Relay Studio), billing and plans (phase 3).
- A production hostname and an SLA. Dev only, today.
- An official SDK package. Use plain HTTP; the examples above are the reference.
- Presets beyond
temporal-hourandtemporal-minute. The library has spatial and custom configurations; the API doesn't expose them yet. - WebSocket authentication for browsers. The watch needs an
Authorizationheader, which browsers can't send on a WebSocket, so it's server-side only for now. - Federation with other clouds, and data residency (phase 4).
- Idempotency keys expire after 24 h, and webhook secrets are stored in the directory as given (encryption at rest is planned).