API reference v1 Developer beta

Everything below is implemented and verified against the live dev deployment, unless it is listed under Not live yet.

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

StageDeveloper beta (dev). No production hostname yet.
Base URL (dev)https://schedule-bit-api-dev.khaled-mailhub.workers.dev
API versionv1: every path starts with /v1/
Runs onCloudflare Workers + Durable Objects (one per World), D1, Queues
Verified2026-09-26: the CI smoke suite passes against dev (World, door, WebSocket, relay across Durable Objects)
KeysIssued by the operator (cloud/scripts/issue-key.ts). There is no self-serve sign-up yet.

The product in one picture

diagram
╭─────────────╮   Bearer sbk_…    ┌──────────────────────────────────┐
│ your app /  │ ────────────────▶ │  schedule-bit API  (v1)          │
│ SDK / curl  │ ◀──────────────── │  ▓ one World = one Durable Object│
╰─────────────╯   JSON / 409      └──────────────────────────────────┘
       ▲                                  │ after every commit
       │ WebSocket (live)                 ▼
       └──────────────────────── ┄┄ events ┄┄▶ signed webhooks

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

diagram
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, asks

Capability map

diagram
                                 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), plus hour: 0–23 on a temporal-minute World, 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).

StatusMeaning
400The request can't be read (bad date, unknown occupier, malformed body)
401 / 403Missing or unknown key / missing scope, or a World in another workspace
404No such World, relay, or webhook
409Refused. The body carries refusal, the engine's reason (see below)
413Body over 64 KiB
422Idempotency key reused with a different body, or a relay that cannot be laid out
503The 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 slots
  • capacity: no seats left
  • rule, rest, horizon: the party's own limits
  • foreign: someone else's plane
  • stopped: a stopped series
  • stale: the hand moved since it was taken in
  • open: an ask of yours still stands
  • untaken: that hand was never taken in
  • unknown: no ask by that token
  • unasked: nothing stands for that leg
  • order, past: ordering and timing refusals
json
{ "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

VerbPathReturns
GET/v1/health{"ok":true}
GET/v1/presets{"presets":[{"id":"temporal-hour","position":"day","leafSlotBits":24}, …]}

Worlds

VerbPathBody / queryReturns
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}
  • preset is temporal-hour (the default) or temporal-minute.
  • allowDisplace: true lets a higher rank displace a lower one.
  • tieBreak settles equal ranks: incumbent (the default), first-come or id.

Occupiers and capacity

VerbPathBodyReturns
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)

VerbPathBodySuccess
POST/v1/worlds/{w}/claims{who, position, slots, rank?, soft?, until?}201 receipt
POST/v1/worlds/{w}/claims:explainsame as a claim200 {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:

diagram
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 journal

A receipt:

json
{ "wrote": "0x600", "slots": [9, 10],
  "displaced": [{ "occupier": "bob", "slots": [9], "offered": { "parametric": 2026, "coordinates": [9, 1], "slots": [11] } }],
  "commitSeq": 4, "head": "20fc4842bffb6b9b0341187cd5c99583" }

Reading

VerbPathQueryReturns
GET/v1/worlds/{w}/offeredposition{parametric, coordinates, offered:"0xfff9ff", slots:[…]}: free slots
GET/v1/worlds/{w}/holdingswho + position{occupier, held, heldSlots, soft, softSlots}
GET/v1/worlds/{w}/journalsince (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 hash seals the previous one, so changing any past commit breaks every hash after it.
  • set and clear are 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.

VerbPathBody / queryReturns
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}/relaysevery 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}/windowsposition + positions (1–512){starts:[moment…], blockedAt}
POST/v1/worlds/{w}/relays/{r}/actssee belowthe relay + told (what changed)

Leg fields:

  • length is in slots.
  • gapAfter is how many slots must pass before the next leg.
  • after lists earlier legs this one waits for, counting from 0. Legs with after: [] 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.

diagram
 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 again

Asks: a hand's inbox

VerbPathBodyReturns
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 state is open, accepted, rejected, withdrawn or released.
  • accept claims through this World's own door, so it can be refused too.
  • moveTo answers 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

VerbPathBodyReturns
GET/v1/worlds/{w}/watchWebSocket upgradefirst {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.

diagram
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 type
  • x-schedule-bit-delivery: the event id
  • x-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:

ts
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

diagram
                  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

LimitValue
Request body64 KiB
Idempotency memory24 h
Journal page≤ 1000 commits
Seats per World1–1024
Legs per relay≤ 32
Relay window horizon≤ 512 positions
Webhooks per workspace10
Webhook retries10, then the dead-letter queue
Edge rate limitprod only, 1200 requests / min / IP (none on dev)

A complete session (curl)

sh
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-hour and temporal-minute. The library has spatial and custom configurations; the API doesn't expose them yet.
  • WebSocket authentication for browsers. The watch needs an Authorization header, 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).