Queue webhooks
When the fleet is full, POST /api/v1/sessions answers 202 with a queue_ticket and your
client re-POSTs to poll — see Sessions. A server-side caller (a BFF holding a
secret key) can additionally register a webhook for its place in line, cut the polling cadence,
and keep its spot for minutes rather than seconds of silence.
One rule before anything else: webhooks reduce polling, they do not replace the claim; on
queue.claimable, immediately re-POST the mint.
Registering
Add a notify block to the mint body. It rides the same request that enrolls you in the queue:
POST /api/v1/sessions{ "persona": "mei", "notify": { "url": "https://your-bff.example.com/hooks/queue", "secret": "whsec_9f2c…" }}- Secret keys only. Any other credential (publishable, device, issuer, browser session)
gets
400 notify_not_allowed. The URL receives signed platform traffic and buys a longer liveness window — that is a server capability, never a browser one. - https only. Anything else is
400 invalid_notify_url. secretis optional. When present, every delivery to your URL is signed with it (see below).- Re-POSTing the same body on each poll keeps the subscription fresh; changing the
notifyblock mid-wait takes effect on the next poll.
Liveness: the notify lane
A plain polling waiter is dropped after ~12 seconds of silence. A notify-armed waiter lives for
QUEUE_NOTIFY_IDLE_S (300 seconds) without polling — the webhook replaces the heartbeat. Two
things bound the cost of an absent subscriber:
- a reserved seat you never claim lapses after the claim window (45s), and two lapses drop
your ticket (
queue.dropped, reasonlapsed); - when your user leaves, cancel. The cancel route is part of the notify contract — a subscriber that abandons tickets without cancelling is holding seats other people are waiting for.
Events
Every delivery is a POST with this envelope:
{ "event": "queue.claimable", "workspace_id": "ws_abc123", "data": { "ticket": "…", "position": 1, "claim_deadline": 1755600045, "pod_host": "…" }, "timestamp": 1755600000123}| Event | When | data |
|---|---|---|
queue.claimable | A seat was reserved for your ticket | ticket, position, claim_deadline (epoch seconds the reservation lapses), pod_host |
queue.position | Your position changed — throttled to at most one delivery per 30s per ticket, and only when the value moved | ticket, position, eta_seconds (may be null) |
queue.dropped | Your ticket left the line without seating | ticket, reason: lapsed (unclaimed reservations), idle (liveness window expired), or drained (operator drained the queue) |
On queue.claimable, re-POST the mint with your queue_ticket right away — the re-POST is
the claim, and the reservation is only held until claim_deadline. After queue.dropped, a
re-POST with the dead ticket transparently re-admits you at the back of the line.
Delivery is best-effort: a short in-process retry (immediately, then 2s, then 5s for the per-intent URL), no delivery log, no replay. A missed webhook costs you nothing but latency, because the poll loop remains the source of truth — keep a slow poll running even when subscribed.
Verifying signatures
Deliveries signed with a secret carry x-signature-256: sha256=<hex> — an HMAC-SHA256 of the
raw request body. Per-intent deliveries use your notify.secret; workspace endpoint deliveries
use that endpoint’s signing_secret. A notify block without a secret is delivered unsigned.
async function verify(rawBody: string, header: string, secret: string): Promise<boolean> { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody)); const hex = Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); return header === `sha256=${hex}`;}Compute the HMAC over the body bytes exactly as received — re-serializing the parsed JSON can reorder keys and break the comparison.
Workspace-level endpoints
Independent of any single mint, a webhook endpoint subscribed to queue.*
(or to an individual event name) receives every queue event for the workspace, signed with the
endpoint’s own secret. Use this for ops-style visibility; use the per-intent notify block for
the seat-claiming loop.