qub API reference

qub API — OpenAPI 1.0.0

API for qub, a timed commitment and timed publication system backed by drand timelock encryption. Seal qubs now, reveal them later.

Base URL(s):

Authentication

turnstile — Cloudflare Turnstile verification token. Despite this being listed as a bearer scheme, the token is passed in the request body as turnstile_token, not in the Authorization header.

apiKey — API key with prefix qub_sk_. Pass in the Authorization header as Bearer qub_sk_....

Endpoints

qubs

Create, seal, and read qubs

GET /api/v1/qub/{tx_id} — Read wrapped qub bytes

Auth: API key

Returns the OuterWrapper CBOR bytes (PROTOCOL.md §13) for a qub plus minimal storage metadata. The Worker holds no decryption capability — the wrapper key K lives only in the URL fragment on the client. Consumers unwrap and decrypt locally.

Rate limiting. Anonymous (no API key) and free-tier API key callers share a 30-requests-per-minute per-IP bucket (over-quota returns 429 with code: RATE_LIMIT_IP). API-key callers whose key record carries rate_limit > 30 get a per-key bucket sized to that value (Builder default 60/min; over-quota returns 429 with code: RATE_LIMIT_KEY). Per-key buckets prevent a single noisy IP from exhausting a paid customer's budget.

Read quota (Builder). Builder API-key callers are NOT subject to a hard read cap — reads past the in-base allowance (100,000/month) accrue against the metered qub_builder_read_overage Stripe price ($0.50 per 100,000 reads). Enterprise / legacy keys still hit the per-day max_reads_per_day cap and receive 429 READS_EXHAUSTED on exhaustion.

Optional charge cap. Builder customers can opt into a monthly charge ceiling via PATCH /api/v1/api-keys/{keyHash} (max_monthly_charge_usd field). When set, reads (and seals) reject with 402 MONTHLY_CAP_REACHED once the projected end-of-period overage charge would exceed the cap.

Parameters

Name In Required Type Description
tx_id path yes string Storage transaction ID.

Responses

Status Description Body
200 Wrapped qub bytes plus minimal metadata. QubReadResponse (application/json)
404 qub not found. Error (application/json)
429 Rate limited. Error (application/json)
451 Content denied by moderation. Error (application/json)
502 Upstream failure fetching from permanent storage or drand. Error (application/json)

200 response body — QubReadResponse

Field Type Required Description
tx_id string yes
wrapped_cbor_base64 string yes Base64-encoded canonical CBOR bytes of the OuterWrapper (PROTOCOL.md §13). Opaque to the Worker.
arweave_block_timestamp number no Storage block timestamp (Unix seconds UTC). Best-effort — absent for unconfirmed transactions.
intent string no Compose intent that the creator selected (if any), read from the Intent storage tag. Used by the viewer's 'Seal your own qub' CTA.

GET /api/v1/qub/{tx_id}/bytes — Raw sealed CBOR bytes

Auth: public

Returns the raw sealed CBOR payload for a qub with R2 cache-aside and immutable caching (Cache-Control: public, max-age=31536000, immutable). Used by the in-browser embed and SPA client to perform client-side tlock decrypt without redundant storage gateway hits. Rate limited to 120 requests per minute per IP — higher than the JSON /api/v1/qub/{tx_id} endpoint because each viewer/embed page-load typically issues one bytes request to refresh the cache.

Parameters

Name In Required Type Description
tx_id path yes string

Responses

Status Description Body
200 Raw sealed CBOR bytes. string (binary) (application/octet-stream)
404 qub not found in permanent storage. Error (application/json)
429 Rate limited (120 / IP / minute). Error (application/json)

GET /api/v1/qub/{tx_id}/meta — Lightweight qub metadata (no decrypt)

Auth: public

Returns the storage block timestamp and intent tag for a sealed qub. No tlock decrypt, no stored-bytes fetch — just a GraphQL lookup with R2 cache. Used by the viewer countdown screen, the ?from={intent} viral-loop CTA, and the <qub-embed> iframe. The qub.social viewer and the embed iframe both re-poll this endpoint every 30 seconds during countdown so the watching count climbs live; polling halts in the last minute before unlock. Rate limited 10 requests per minute per IP.

Parameters

Name In Required Type Description
tx_id path yes string Storage transaction ID.

Responses

Status Description Body
200 Metadata. Both fields are optional — empty {} means the GraphQL fetch failed or the transaction isn't yet confirmed in a block. Always 200, never 404. QubMetaResponse (application/json)
400 Invalid tx_id. Error (application/json)

200 response body — QubMetaResponse

Field Type Required Description
arweave_block_timestamp integer no Unix seconds when the qub's storage transaction was first included in a block.
intent enum ("announcement" / "thesis" / "prediction" / "letter" / "secret" / "commitment" / "proof" / "verdict") no Intent tag set at compose time. One of eight canonical framings — seven user-selectable from the compose pill row plus verdict, the system-emitted intent for a chained creator self-grading qub.
parent_tx_id string no Parent qub's storage tx_id (43-char base64url) when this qub was sealed as a reply via ?reply_to=<parent_tx>. Read from the Parent-Tx-Id storage tag. Absent for non-reply qubs. Used by the viewer reveal page to render a 'Replied to {parent}' back-link without a server-side reverse index.
author_fingerprint string no Lowercase 64-char hex fingerprint of the creator's signing pubkey. Read from the Author storage tag. Absent by default — under privacy-by-default, the Author tag is opt-in per qub at seal time. Present only on qubs the creator chose to attribute publicly; the viewer countdown then renders 'Sealed by @handle' after attestation lookup, and falls back to 'Sealed anonymously' (or no author line) when this field is absent. — pattern: ^[0-9a-f]{64}$
watching integer no Current watching counter for the qub. Read from the watch:<tx_id> KV counter. Both the qub.social viewer and the embed iframe re-poll this endpoint every 30s during countdown so the figure climbs live; polling halts in the final minute before unlock to avoid racing the reveal transition. Absent if the counter read failed transiently.
reactions object no Aggregate reaction tallies for a revealed prediction qub. Absent for unrevealed qubs and for qubs with no reactions yet.
denylisted boolean no True if the qub has been removed from public viewing via the moderation denylist. The F4 embed iframe checks this before fetching stored bytes.

POST /api/v1/seal — Server-side seal (agent-only)

Auth: API key

Seals a plaintext qub server-side using drand timelock encryption, applies the AES-256-GCM outer wrapper, and uploads the wrapped bytes to permanent storage. Requires an API key with the seal scope; subject to the API key's IP allowlist if configured. Trust-model note: the plaintext body passes through the Worker (encrypted in-process, never persisted), unlike the client-side /api/v1/upload path. Body cap 50 KB. Maximum unlock horizon 10 years from now. The wrapper key K is generated server-side and returned in the response as wrapper_key_b64url; it is NOT persisted server-side. Per-API-key seal rate limits and per-period quotas are enforced (qubs_remaining is decremented on success and refunded on failed upload).

Rate-limit / ceiling response codes (429):

Circuit-breaker response codes (503):

Supports the optional Idempotency-Key request header (Stripe-style): retries with the same key replay the original response (with Idempotency-Replayed: true) for 24h instead of running the handler again. The header MUST match ^[A-Za-z0-9._-]{1,255}$ (PR S4, Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 45) — malformed values return 400 IDEMPOTENCY_KEY_INVALID. Concurrent retries against an in-flight key return 409 IDEMPOTENCY_IN_FLIGHT. Use this for any retry-on-5xx logic so a network blip doesn't double-charge metered overage.

Parameters

Name In Required Type Description
Idempotency-Key header no string Optional Stripe-compatible idempotency key. Retries with the same key replay the original response (24h window). MUST match ^[A-Za-z0-9._-]{1,255}$; malformed values return 400 IDEMPOTENCY_KEY_INVALID (PR S4, Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 45).

Request body (application/json, required)

Field Type Required Description
body string yes Plaintext qub body to seal. Capped at 51_200 bytes (50 KB) at the route layer to match the Arweave chunk-economic sweet spot. The CBOR-envelope layer carries a separate 102_400 (100 KB) cap that accommodates pact bodies arriving via /upload; the route-level cap is text-qub-specific.
unlock_at integer yes Unix timestamp (seconds) for when the qub should unlock. MUST be a finite integer strictly greater than now and no more than 10 years in the future. NaN, Infinity, and non-integer floats are rejected at the API edge (Andre 2026-05-22 Finding 1) — sending one returns 400 invalid_unlock_at.
sender_label string no Optional display name for the sender. Subject to the same Unicode hygiene as title — NFC-normalised; hostile codepoints (bidi-override, ZWSP, tag-block, BOM, C0/C1) are rejected. Returns 400 invalid_sender_label on violation. — maxLength: 80
title string no Optional plaintext title surfaced on the viewer countdown before reveal (PROTOCOL.md §3.2 v1.0). 1..=100 NFC code points. Bound to qub_id via title_hash (PROTOCOL.md §4.1) so a gateway cannot swap the displayed title without invalidating qub identity. Empty strings are rejected — the canonical encoding of an absent title is field omission. Unicode hygiene (PR 0c, Andre Finding 15): rejects bidi overrides (U+202A-U+202E, U+2066-U+2069), ZWSP (U+200B), tag-block (U+E0000-U+E007F), BOM (U+FEFF), C0+DEL, and C1 controls. ZWJ / ZWNJ / variation selectors / LRM / RLM are KEPT because they're load-bearing for Devanagari / Arabic / emoji / RTL text. Returns 400 invalid_title. Content policy (PR 1d, Andre Finding 12): titles that combine urgency wording (verify, confirm, suspended, urgent, ...) with a reserved brand name (Chase, PayPal, Apple, ...) reject as invalid_title to defang brand-impersonation phishing through the share-preview countdown. — maxLength: 100

Responses

Status Description Body
200 qub sealed and uploaded. SealResponse (application/json)
400 Bad request. Error (application/json)
401 API key required. Error (application/json)
402 Payment required. code: QUOTA_EXHAUSTED when the per-key qubs_remaining is at 0 on a tier without metered overage; code: MONTHLY_CAP_REACHED when a Builder customer has opted into max_monthly_charge_usd and the projected overage would breach it. Error (application/json)
429 Rate limit or Arweave-cap reached. See the operation description for the canonical codes: RATE_LIMIT_API_KEY (per-key in-window), DAILY_KEY_LIMIT (per-key daily Arweave seal cap), DAILY_ACCOUNT_LIMIT (per-account aggregate daily Arweave seal cap). Daily-cap codes added by PR 1c + PR S6 (Andre 2026-05-22 review). Error (application/json)
500 Server error during sealing. Error (application/json)
502 Upstream permanent-storage failure. Error (application/json)
503 Storage-spend circuit breaker tripped (code: CIRCUIT_BREAKER); seals are temporarily refused operator-wide until the cap is lifted. Error (application/json)

200 response body — SealResponse

Field Type Required Description
tx_id string yes
delivery_url string (uri) yes Delivery URL with the OuterWrapper key embedded as the URL fragment (#<base64url(K)>, PROTOCOL.md §13.6). Sharing this URL hands the receiver everything needed to read the qub; truncating the fragment makes the qub unreadable.
qub_id string yes
unlock_at number yes
drand_round integer yes
short_delivery_url string (uri) no Full short-URL form https://qub.social/s/<code>#<key> when a 7-character base62 short code was allocated at seal time. Prefer this for share surfaces. Absent if allocation failed transiently — delivery_url is always usable as a fallback.
wrapper_key_b64url string yes Base64url-no-pad encoding of the 32-byte AES-256-GCM wrapper key K (PROTOCOL.md §13.6). Returned so callers that build their own URLs (e.g. for offline distribution) can compose the fragment themselves. The Worker never persists K — it lives only in this response. Identical to the value embedded in delivery_url.

Upload

Upload sealed content to permanent storage

POST /api/v1/upload — Upload sealed CBOR to permanent storage

Auth: Turnstile or API key

Uploads an already-sealed and outer-wrapped CBOR payload to permanent storage via the Worker proxy. Returns the storage transaction ID and a delivery URL for the viewer. Three independent rate limits apply and any one tripping returns 429: per-IP (20 / 10 minutes), per-device (10 / 10 minutes), and — for API-key callers — per-key seal-rate from the key record. Browser clients must supply a Turnstile token; API-key callers may omit it. Body cap is 50 KB. The optional wrapper_key_b64url field opts the qub into the lifecycle-email recovery channel (Privacy Policy §2.3); when present and the supplied email matches the verified identity, the Worker stores the wrapper key on the identity's sealed-history record so the seal-confirmation email contains a working delivery link.

Request body (application/json, required)

Field Type Required Description
wrapped_cbor_base64 string yes Base64-encoded canonical CBOR bytes of the OuterWrapper (PROTOCOL.md §13). Opaque to the Worker — the wrapper key K lives only in the URL fragment on the client and never reaches the server.
device_id string yes Device identifier (32 lowercase hex chars). Used as the KV key, rate-limit bucket, and sealed-history shard. Pattern enforced since PR S4 (Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 43); previously any-string was accepted. — pattern: ^[a-f0-9]{32}$
turnstile_token string no Cloudflare Turnstile token. Optional when using API key auth.
content_size integer yes Size in bytes of the wrapped CBOR (the bytes uploaded to permanent storage), NOT the inner SealedQub. The fixed AEAD overhead (12-byte nonce + 16-byte tag + ~50 bytes of CBOR framing) is factored into the existing tier ceilings.
intent enum ("announcement" / "thesis" / "prediction" / "letter" / "secret" / "commitment" / "proof" / "verdict") no Optional compose intent. When present and on the allowlist, the Worker attaches it as the Intent storage tag for the viewer's ?from={intent} viral-loop CTA. Unknown values are silently dropped.
unlock_at integer yes Required. Unix-seconds reveal time, copied from the inner SealedQub by the client. The Worker can no longer extract it from the wrapped bytes (the wrapper is opaque), so the client states it. Used as metadata for the sealed-history backup and lifecycle-email scheduling. The qub itself remains time-locked by drand regardless of what the client claims here.
outcome_at integer no Optional outcome time (Unix seconds UTC) on a verdict-bearing qub (verdict-uplift-plan §3.1). Mirrors SealRequest.outcome_at; validated via the same shape rules (integer, positive, >= unlock_at, <= now + 10 years). The value also rides inside the wrapped CBOR — this surface is an out-of-band declaration so the Worker can schedule the verdict-CTA email (V1.6) at outcome time without unwrapping. Persisted on the creator-lifecycle record (cl:<tx_id>).
qub_id_hex string yes Required. 64-char lowercase hex of the inner SealedQub's qub_id. Same rationale as unlock_at — the client states what the Worker can no longer derive. Used as a metadata key only; integrity of the qub_id is protected at the protocol layer (it is the AEAD AAD inside the wrapper). — pattern: ^[0-9a-f]{64}$
author_fingerprint string no Optional 64-char lowercase hex of the creator's pubkey fingerprint (PROTOCOL.md §2.2). Privacy-by-default — opt-in per qub. When present and well-formed, attached as an Author storage tag so the viewer countdown can surface 'Sealed by @handle' after attestation lookup. The reference creator app sends this field only when the user explicitly enables public attribution at seal time; when omitted, no Author tag is written and the qub is unattributed in permanent storage. Invalid values are silently dropped. — pattern: ^[0-9a-f]{64}$
parent_tx_id string no Optional parent qub storage tx_id (43-char base64url) for reply-chain qubs (FUTURE.md §11.1). When present and well-formed, attached as a Parent-Tx-Id storage tag so the viewer reveal page can render a 'Replied to {parent}' back-link without a server-side reverse index. Invalid values are silently dropped. — pattern: ^[A-Za-z0-9_-]{43}$
verdict_outcome integer no Optional verdict outcome enum (1=Right · 2=Partial · 3=Wrong · 4=Unfalsifiable). Set ONLY when uploading a verdict qub (intent=verdict + valid parent_tx_id); ignored otherwise. The outcome itself rides inside the wrapped CBOR (opaque to the Worker), but the enum byte populates the verdicts_for_parent:<parent>:<tx> discovery index so the parent's reveal page can render the §5.3 Published-state inline label without unwrapping the verdict's own wrapper. Values outside 1..=4 are silently dropped (verdict-uplift-plan §5.3, V1.4b).
parent_intent enum ("prediction" / "commitment" / "announcement" / "thesis") no Optional parent qub intent — set ONLY when uploading a verdict qub. Lets the V1.7 subscriber-rendered fan-out cron pick the per-intent email template without a round-trip back to permanent storage for the parent's Intent tag. The Worker validates against the four verdict-bearing intents; unknown values are silently dropped and the cron skips the fan-out rather than guessing a template (verdict-uplift-plan §7.3 Path B, V1.7).
creator_email string (email) no Optional creator email for lifecycle emails (DISTRIBUTION-STRATEGY §12.1 / TODOS P0-10). Forwarded only when creator_lifecycle_opt_in is true. The Worker writes a cl:<tx_id> KV record and fires seal_confirmation immediately. Validated for email-like shape; invalid values cause the opt-in to be silently dropped (the upload still succeeds).
creator_locale string no Optional BCP 47 locale tag for lifecycle emails. Defaults to en when absent. Persisted with the creator-lifecycle record so deferred sends (pre-reveal reminder, reveal notification, watcher milestone, anniversary) land in the right language.
creator_lifecycle_opt_in boolean no Lifecycle opt-in flag. Must be true to enable — anything else (missing, false, truthy non-boolean) is treated as opt-out and the lifecycle email surface is skipped.
creator_reveal_date_short string no Optional pre-formatted short reveal date (matches the viewer's format_ts_short). Surfaced verbatim in the seal_confirmation email body when lifecycle opt-in is active.
wrapper_key_b64url string no Optional base64url-no-pad encoding of the 32-byte AES-256 wrapper key K (PROTOCOL.md §13.6). Engages the W13 recovery channel ONLY when the client also sets creator_lifecycle_opt_in: true AND creator_email matches the device's verified (sybil-linked) email. On the gated path the Worker composes ${origin}/c/${tx_id}#${K}, uses it as {link} in the seal_confirmation email, and stores it on the per-identity sealed-history entry as a recovery anchor. Outside the gate, K stays in the browser; pass it only when the user has explicitly opted into the recovery channel. Malformed values are silently dropped (the lifecycle email falls back to the legacy fragment-less URL). Privacy trade-off documented in locales/en/privacy.md §2.3. — pattern: ^[A-Za-z0-9_-]{43}$
is_public boolean no Optional public-qub flag (delivery-layer visibility, default false). When true the caller has uploaded the raw SealedQubCbor with NO AES-256-GCM outer wrapper (PROTOCOL.md §13.8) — tlock-only, like a pact — so the tx_id alone decrypts after unlock and wrapped_cbor_base64 carries no fragment-keyed wrapper. The Worker stamps a Visibility: public storage tag and emits fragment-less working delivery links (sealed-history delivery_url, seal_confirmation email, reveal notifications). Absent / false keeps the wrapped, fragment-gated model. Strict-true only; any other value reads as private.

Responses

Status Description Body
200 Upload successful. UploadResponse (application/json)
400 Bad request. Error (application/json)
402 Payment required — entitlement exhausted. Error (application/json)
403 Turnstile verification failed. Error (application/json)
413 Payload too large for the device's entitlement tier. Error (application/json)
429 Rate limited. Error (application/json)
502 Upstream permanent-storage failure. Error (application/json)

200 response body — UploadResponse

Field Type Required Description
tx_id string yes Storage transaction ID.
delivery_url string (uri) yes Fragment-less canonical URL <origin>/c/<tx_id>. The Worker only receives wrapped_cbor_base64 and never the wrapper key K, so it cannot construct the full delivery URL on this path. Callers MUST append the URL fragment #<base64url(K)> themselves to produce a complete shareable URL — see PROTOCOL.md §13.6. Without the fragment the qub is unreadable. (Server-side seal callers receive the fragment URL directly via POST /api/v1/seal's delivery_url.)
short_code string no 7-character base62 short code mapped to tx_id in KV. Resolves via /s/{code} to the canonical /c/{tx_id} viewer page. Absent when the allocator failed transiently — delivery_url is always usable as a fallback.
qubs_remaining integer no Post-decrement free-tier quota for the verified identity that just sealed, after this upload counted against the shared sybil-linked counter. Present only for free-tier browser uploads made by a verified identity. Absent for creator-tier (quota lives on the entitlement record), API-key, and anonymous uploads. Clients use it to keep their local identity cache in sync without an extra round-trip to GET /api/v1/identity.

POST /api/v1/upload-auth — Pre-check upload eligibility

Auth: Turnstile or API key

Validates the device's entitlement tier and rate limits before uploading. Rate limited to 20 requests per 10 minutes per IP and 10 per 10 minutes per device.

Request body (application/json, required)

Field Type Required Description
turnstile_token string no Cloudflare Turnstile token. Optional when using API key auth.
device_id string yes
content_size integer yes Size in bytes of the sealed content.

Responses

Status Description Body
200 Eligibility check result. UploadAuthResponse (application/json)
400 Bad request. Error (application/json)
403 Turnstile verification failed. Error (application/json)
429 Rate limited. Error (application/json)

200 response body — UploadAuthResponse

Field Type Required Description
allowed boolean yes
tier string yes
max_size integer yes
reason string no

Engagement

Watch / view counters and notify-me subscriptions

POST /api/v1/qub/{tx_id}/notify — Subscribe an email to a qub's reveal

Auth: public

Adds an email address to the notify-me list for a sealed qub. The reveal-time cron sends a one-shot email when the qub unlocks. Always returns { ok: true } on success — re-submitting the same address is idempotent and updates the stored locale. Hard-capped at 1000 subscribers per qub. Rate limited 5 requests per minute per IP.

Parameters

Name In Required Type Description
tx_id path yes string

Request body (application/json, required)

Field Type Required Description
email string (email) yes Email address to subscribe. Max 200 chars; the domain is lowercased on store.
unlock_at integer no Unix seconds when the qub unlocks. Strongly preferred — without it the cron has no way to know when to send the email.
locale string no BCP 47 locale tag (e.g. en, pt-BR). Picks the language for the reveal email. Falls back to en if missing or unrecognised.
intent enum ("prediction" / "letter" / "secret" / "announcement" / "thesis" / "commitment" / "proof" / "verdict") no Compose intent of the subscribed qub. Captured at subscribe time so the reveal email picks an intent-aware subject + body variant where one exists (The prediction reveals now. etc). Optional — omitted, unknown, or per-intent-template-less values fall back to the generic notify template. The server validates against the allowlist and silently drops anything else.

Responses

Status Description Body
200 Subscribed (or cap reached — the cap is silent so the user doesn't see a degraded experience). OkResponse (application/json)
400 Invalid tx_id, email, or unlock_at. Error (application/json)
429 Rate limited. Error (application/json)

200 response body — OkResponse

Field Type Required Description
ok boolean yes

POST /api/v1/qub/{tx_id}/react — Record a reaction on a revealed qub

Auth: public

Records a called_it or wrong reaction for a revealed prediction and returns the updated tallies. Client-side dedup via localStorage per (tx_id, device) is assumed; ~10% inflation is acceptable. Rate-limited 10/min/IP as a shallow abuse floor — over-limit requests return current tallies without mutating.

Parameters

Name In Required Type Description
tx_id path yes string

Request body (application/json, required)

Field Type Required Description
reaction enum ("called_it" / "wrong") yes

Responses

Status Description Body
200 Updated reaction tallies. object (application/json)
400 Invalid reaction or tx_id. Error (application/json)

200 response body — inline

Field Type Required Description
called_it integer yes
wrong integer yes
total integer yes

GET /api/v1/qub/{tx_id}/verdict-watch — Read the current verdict-watcher count

Auth: public

Returns the current verdict-watcher count without modifying the set. Used by the reveal-page 30-second poll (plan §5.1) so the counter live-updates as new committers tap the CTA.

Parameters

Name In Required Type Description
tx_id path yes string

Responses

Status Description Body
200 Current exact count (cosmetic rounding for display happens client-side). object (application/json)
400 Invalid tx_id. Error (application/json)

200 response body — inline

Field Type Required Description
count integer yes

POST /api/v1/qub/{tx_id}/verdict-watch — Commit to watching for a verdict (per-device dedupe)

Auth: public

Increments the per-qub verdict-watcher set with the caller's device_id. Unlike /watch, this counter is per-device-deduped (verdict-uplift-plan §5.1.1): each device counts at most once per qub; re-click is a no-op; unsubscribe does NOT decrement (decoupling subscribed-now from historically-committed defeats hit-and-run gaming). The notify flag that drives verdict-time email fan-out is independent and lives on the NotifySubscriber record. Returns the new exact count plus a watching flag the client uses to flip the CTA into 'you're already watching' state.

Parameters

Name In Required Type Description
tx_id path yes string

Request body (application/json, required)

Field Type Required Description
device_id string yes 32-char lowercase hex device identifier. Same device_id shape used by /upload and /checkout. — pattern: ^[a-f0-9]{32}$

Responses

Status Description Body
200 New count after this commit (rate-limited responses return { count: 0, watching: false }). object (application/json)
400 Invalid tx_id, body, or device_id. Error (application/json)

200 response body — inline

Field Type Required Description
count integer yes
watching boolean yes True iff this device's id is in the set after the call. Used by the client to flip the CTA between 'commit' and 'you're already watching'.

POST /api/v1/qub/{tx_id}/view — Increment the post-reveal view counter

Auth: public

Increments and returns the post-reveal view count. Same shape as the watch counter but tracked on a separate KV key. Displayed on the revealed screen as 'Seen by {N} people'. Rate limited 10 requests per minute per IP.

Parameters

Name In Required Type Description
tx_id path yes string

Responses

Status Description Body
200 New count after this increment (or 0 if rate-limited). EngagementCountResponse (application/json)
400 Invalid tx_id. Error (application/json)

200 response body — EngagementCountResponse

Field Type Required Description
count integer yes Current count after this increment. Returns 0 if rate-limited (so the client can render zero or its cached value rather than an error).

POST /api/v1/qub/{tx_id}/watch — Increment the pre-reveal watch counter

Auth: public

Increments and returns the 'watching' count for a sealed qub. Used by the viewer countdown screen as social proof. No per-fingerprint dedup — accepts ~10% inflation in exchange for a much simpler implementation. Rate limited 10 requests per minute per IP. On rate-limit returns { count: 0 } (200) so the client renders zero or its cached value.

Parameters

Name In Required Type Description
tx_id path yes string

Responses

Status Description Body
200 New count after this increment (or 0 if rate-limited). EngagementCountResponse (application/json)
400 Invalid tx_id. Error (application/json)

200 response body — EngagementCountResponse

Field Type Required Description
count integer yes Current count after this increment. Returns 0 if rate-limited (so the client can render zero or its cached value rather than an error).

API Keys

API key checkout, retrieval, and rotation

PATCH /api/v1/api-keys/{keyHash} — Update label / cap / IP allowlist / scope / disabled on a key the caller owns

Auth: public

Partial update — fields not in the body are unchanged. Auth: same magic-link → device_link → email model as GET /api/v1/api-keys/mine. The Worker requires the keys-by-email index to contain the (email, keyHash) pair AND re-confirms record.client_id === email before applying the patch. Cross-tenant patches return 404 (not 403) to avoid leaking which hashes exist for other tenants. Validation is strict — any malformed field rejects the whole patch.

Parameters

Name In Required Type Description
keyHash path yes string Full SHA-256 hash of the raw key (64 lowercase hex chars). Sourced from the id field of MyApiKeyEntry.
device_id query yes string The caller's local device ID. Same shape as for GET /api/v1/api-keys/mine.

Request body (application/json, required)

Field Type Required Description
label string or null no
max_monthly_charge_usd number or null no
ip_allowlist array or null no
scope array of enum ("upload" / "seal" / "read" / "webhooks") no
disabled boolean no

Responses

Status Description Body
200 Patch applied. Response carries the updated MyApiKeyEntry. MyApiKeyEntry (application/json)
400 Validation failure on one of the body fields, or a malformed key hash / device_id. Error (application/json)
401 The device hasn't completed magic-link sign-in yet. { ok: false, code: "NOT_LINKED" }. Error (application/json)
404 The caller doesn't own this key (cross-tenant access) or the key doesn't exist. Error (application/json)

200 response body — MyApiKeyEntry

Field Type Required Description
id string yes Full SHA-256 hash of the raw key. Used to route PATCH calls. Not a credential.
key_prefix string yes Last 8 hex chars of id. Cosmetic display identifier — the dashboard renders …<key_prefix>.
label string or null yes Customer-set human-readable name (≤64 NFC code points). Null until set.
tier enum ("free" / "builder" / "enterprise") yes
created_at integer yes
last_used_at integer or null yes
qubs_remaining integer yes Base allowance left this period. Allowed to go negative for Builder (overage indicator).
qubs_total integer yes
seals_used_this_period integer yes
reads_used_this_period integer yes
max_monthly_charge_usd number or null yes Customer-configurable monthly Stripe-charge cap (USD). New Builder keys ship with $100 by default. The cap covers metered overage above the $29 base — setting it below 0 has no effect.
ip_allowlist array of string yes Bare IPv4 / IPv6 literals or CIDR ranges. Empty = unrestricted. Up to 16 entries × 45 chars.
scope array of string yes Subset of ["upload","seal","read","webhooks"].
disabled boolean yes

GET /api/v1/api-keys/mine — List the API keys owned by the magic-link-verified caller

Auth: public

Self-serve dashboard endpoint. Resolves the device_link:<device_id> → email mapping written by the magic-link verify flow, then walks the keys-by-email:<sha256(email)>:<keyHash> KV index. Returns one MyApiKeyEntry per key. Customer-safe projection — never includes the raw key, the Stripe customer / subscription IDs, or any other operator-side state. An unlinked device returns { ok: false, code: "NOT_LINKED" } so the client can render the magic-link CTA.

Parameters

Name In Required Type Description
device_id query yes string The caller's local device ID (32-char lowercase hex), as written to IndexedDB by the WASM client.

Responses

Status Description Body
200 Either the linked key list or a NOT_LINKED probe response. MyApiKeysResponse (application/json)
400 Missing or malformed device_id. Error (application/json)

POST /api/v1/api-keys/rotate — Rotate the current API key

Auth: API key

Issues a new API key for the authenticated client and stores a grace-period mapping so the old key continues to work for one hour. Per-period quota state and the keys-by-email index move forward; created_at is preserved. No scope gate — successfully presenting the current key already proves ownership. Concurrent rotations are blocked via a 30-second per-key mutex backed by the QuotaDO (strongly consistent across colos; supersedes the pre-2026-05-03 KV check-and-set). Supports the optional Idempotency-Key request header (Stripe-style): retries with the same key replay the original response — including the SAME new secret — for 24h, so a network failure on the response leg doesn't strand the caller without a usable key.

Parameters

Name In Required Type Description
Idempotency-Key header no string Optional Stripe-compatible idempotency key. Retries with the same key replay the original response (24h window). MUST match ^[A-Za-z0-9._-]{1,255}$; malformed values return 400 IDEMPOTENCY_KEY_INVALID (PR S4, Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 45).

Responses

Status Description Body
200 Rotation succeeded; the response carries the new raw key. ApiKeyRotateResponse (application/json)
401 API key required or invalid. Error (application/json)
403 Disabled key or IP not allowed. Returns { code: 'API_KEY_DISABLED' | 'IP_NOT_ALLOWED' }. Error (application/json)
409 A rotation is already in progress for this key (30-second mutex held by another in-flight rotation). Error (application/json)

200 response body — ApiKeyRotateResponse

Field Type Required Description
key string yes The new raw qub_sk_... API key. The old key continues to work via the grace-period mapping until the next rotation.

Payment

Stripe checkout and entitlements

GET /api/v1/entitlements — Check device entitlement tier

Auth: public

Returns the current entitlement tier and remaining qub count for a device.

Parameters

Name In Required Type Description
device_id query yes string Device identifier.

Responses

Status Description Body
200 Entitlement info. EntitlementsResponse (application/json)
400 Missing device_id. Error (application/json)

200 response body — EntitlementsResponse

Field Type Required Description
tier enum ("free" / "pro") yes
qubs_remaining integer yes
purchased_at integer (int64) no Unix epoch seconds at which the entitlement was first purchased (sourced from the rail's checkout / receipt timestamp). Absent on the free-tier response.
current_period_end integer (int64) no Unix epoch seconds when the current paid period ends. Derived from max(period_end) across active sources (PAYMENTS.md v1.0 §4.4). Absent for Free customers and for Pro customers before the first renewal lands.
subscription_status enum ("active" / "past_due" / "cancelled") no Highest-level status across active payment sources (PAYMENTS.md v1.0 §4.4). Absent when the entitlement has no payment sources.
sources_summary array of object no Per-rail summary so the client can render the right cancellation surface (PAYMENTS.md §11.2 / §14). Provider-agnostic — never includes per-rail customer / subscription / transaction IDs.

Webhooks

Webhook registration for qub unlock notifications

GET /api/v1/webhooks — List webhooks

Auth: API key

List all webhooks registered by the authenticated API key.

Responses

Status Description Body
200 List of webhooks. array of Webhook (application/json)
401 API key required. Error (application/json)

POST /api/v1/webhooks — Register a webhook

Auth: API key

Register a webhook URL to be notified when a specific qub unlocks. Requires an API key with the webhook scope. The Worker performs a one-time URL ownership challenge (POSTs a verification token to the candidate URL and expects an echo) before the webhook is recorded; failed verification returns 422. Each API key has a webhook_limit (Builder default 25); attempting to register beyond the limit returns 403. Rate limited to 10 registrations per API key per minute. The shared secret is used to sign every delivery as Qub-Signature: t=<unix>,v1=<hex> where <hex> is HMAC_SHA256(secret, '<unix>.<rawBody>'). Receivers MUST verify the HMAC over the timestamp-prefixed payload and reject deliveries where now - t > 300 (5-minute replay window).

Request body (application/json, required)

Field Type Required Description
tx_id string yes Storage transaction ID to watch.
url string (uri) yes URL to receive the webhook POST.
secret string yes Shared secret used for HMAC-SHA256 signature verification. The Worker sends the signature as Qub-Signature: t=<unix>,v1=<hex> where <hex> is HMAC_SHA256(secret, '<unix>.<rawBody>'). Receivers MUST verify the HMAC over the timestamp-prefixed payload and reject deliveries where now - t > 300 (5-minute replay window). The v1= algorithm tag is forward-compatible — future rotations may add v2= alongside v1= during a deprecation window. Minimum 16 characters. — minLength: 16

Responses

Status Description Body
201 Webhook registered. WebhookCreateResponse (application/json)
400 Bad request. Error (application/json)
401 API key required. Error (application/json)
403 Per-key webhook limit reached (webhook_limit on the key record). Error (application/json)
422 Webhook URL ownership verification failed (no echo of the verification token). Error (application/json)
429 Per-key registration rate limit exceeded. Error (application/json)

201 response body — WebhookCreateResponse

Field Type Required Description
webhook_id string yes

DELETE /api/v1/webhooks/{id} — Remove a webhook

Auth: API key

Delete a previously registered webhook.

Parameters

Name In Required Type Description
id path yes string Webhook ID.

Responses

Status Description Body
204 Webhook deleted (no response body).
401 API key required. Error (application/json)
404 Webhook not found. Error (application/json)

Viewer

Public viewer page (browsers + bots)

GET /api/v1/og/{tx_id}.png — Dynamic Open Graph image

Auth: public

Dynamic OG card for a qub. Sealed state renders intent + reveal date + watching count; revealed state renders intent + called-it percentage as a results card. R2 cache-aside by state + watching/reaction bucket so unfurls hit cache for the typical viewer flow. Note: despite the .png URL suffix (kept for compatibility with OG cards already in the wild), the response body is SVG (Content-Type: image/svg+xml). Major social-media unfurlers (X, Discord, Slack, Reddit, Threads, Bluesky) accept SVG.

Parameters

Name In Required Type Description
tx_id path yes string

Responses

Status Description Body
200 SVG OG card (1200×630 viewport). string (binary) (image/svg+xml)
404 Unknown tx_id. Error (application/json)

Meta

API metadata

GET /api/v1/openapi.json — OpenAPI specification

Auth: public

Returns this OpenAPI 3.1.0 specification document.

Responses

Status Description Body
200 OpenAPI specification. object (application/json)

Embed

F4 embed loader + iframe shell + bundled iframe app for third-party page embedding

GET /oembed — oEmbed discovery

Auth: public

oEmbed 1.0 discovery endpoint. Returns the JSON oEmbed payload for a qub viewer URL so WordPress, Notion, Medium, and Substack can auto-embed <qub-embed> without the user pasting a manual snippet.

Parameters

Name In Required Type Description
url query yes string (uri) Canonical qub viewer URL (e.g. https://qub.social/c/<tx_id>).
format query no enum ("json") Response format. Only json is supported.
maxwidth query no integer
maxheight query no integer

Responses

Status Description Body
200 oEmbed JSON. object (application/json)
404 URL does not match a known qub. Error (application/json)

Identity

GET /api/v1/handle/{handle} — Resolve a qub handle to its owning fingerprint and attestation record

Auth: public

Public reverse lookup. Every email-verified user has a handle (auto-allocated or personalised, IDENTITY.md §3.2.5). Viewers can resolve @name to the backing keypair without any auth. Returns 410 with a released_shape discriminator while a recently-released handle is still in its cooldown window.

Parameters

Name In Required Type Description
handle path yes string Normalised handle (no leading @).

Responses

Status Description Body
200 Handle is claimed; body contains the owning fingerprint and the projected attestation record. HandleLookupResponse (application/json)
400 invalid_handle — the handle fails normalisation (charset, length, or reserved). Error (application/json)
404 Returned in two cases, both with { error: "not_found" }: (a) the handle has never been claimed; or (b) the forward map points to a fingerprint whose attestation record has been deleted or rolled back (orphaned forward entry — rare, treated as not-found from the viewer's perspective). Error (application/json)
410 Handle was recently released and is in cooldown. released_shape is "auto" (1h cooldown) or "user" (30d cooldown). object (application/json)

200 response body — HandleLookupResponse

Field Type Required Description
fingerprint string yes Owning pubkey fingerprint. — pattern: ^[0-9a-f]{64}$
attestations AttestationRecord yes

GET /api/v1/identity/attestation/{fingerprint} — Look up public attestations for a pubkey fingerprint

Auth: public

Unauthenticated public lookup. Returns the attestation record for a given author-key fingerprint (IDENTITY.md §2 + §4), or 404 if the keypair is unattested (Layer 0). The viewer uses this at reveal time to render the richest identity label per §5.3. Response is cached at the edge for 5 minutes.

Parameters

Name In Required Type Description
fingerprint path yes string 64-char lower-case hex SHA3-256 of the author public key.

Responses

Status Description Body
200 Attestation record. AttestationRecord (application/json)
400 Malformed fingerprint. Error (application/json)
404 No attestations for this fingerprint (Layer 0 / not attested). Error (application/json)

200 response body — AttestationRecord

Field Type Required Description
attestations array of AttestationEntry yes
pubkey_alg integer yes sig_alg registry value (PROTOCOL.md §9.2). Phase 2 always 1 (ML-DSA-65).
created_at integer yes
updated_at integer yes

Pact

POST /api/v1/pact/cosign/{staging_id} — Co-sign a staged pact and seal to permanent storage

Auth: public

Party B submits their ML-DSA-65 cosigner signature over the same sig_input as Party A. The worker merges both signatures into the envelope, seals with tlock, and uploads to permanent storage. If Party B's contact in the staged pact is an email, the cosigner is gated by a short-lived 15-minute email-verification marker produced by /api/v1/auth/verify (protocol spec §9.7). When the magic-link request that produced the marker carried a cosigner_fingerprint (PC-3 binding), the submitted cosigner_pubkey must hash (SHA3-256) to the same fingerprint — otherwise the cosign is rejected.

Parameters

Name In Required Type Description
staging_id path yes string

Request body (application/json, required)

Field Type Required Description
cosigner_pubkey string yes
cosigner_signature string yes
turnstile_token string yes

Responses

Status Description Body
200 Pact sealed. object (application/json)
400 Invalid signature, pubkey, or email-binding marker missing. Error (application/json)
404 Staging id not found or expired. Error (application/json)
409 Pact is already cosigned (already_cosigned) or another cosign is in progress (cosign_in_progress). Retry of the in-progress case is safe once the prior request completes. Error (application/json)
502 Upload to permanent storage failed. Error (application/json)

200 response body — inline

Field Type Required Description
tx_id string yes
delivery_url string yes

POST /api/v1/pact/invite/accept — One-click accept of a pact invite from the email link

Auth: public

Party B clicks the staged-pact email and lands on /p/<staging_id>?t=<token>. The token is HMAC-signed by the Worker over {staging_id, email, nonce, expires_at} so receipt of the URL proves email control — no separate magic-link round-trip is required. The Worker validates the token, consumes a single-use marker, and writes the existing pact-email-verified:<staging_id>:<email_hash> marker that the cosign endpoint reads. PC-3 fingerprint binding is preserved when the client supplies cosigner_fingerprint. 7-day TTL matches the staging record.

Request body (application/json, required)

Field Type Required Description
staging_id string yes Staging ID from the URL path. — pattern: ^[a-f0-9]{32}$
token string yes HMAC-signed invite token from the ?t= query param of the email link. Format: base64url(payload).base64url(sig).
cosigner_fingerprint string no Optional — SHA3-256 fingerprint of the keypair Party B will cosign with. When present, the Worker pins the marker to this fingerprint so a different keypair cannot later cosign (PC-3 binding). — pattern: ^[a-f0-9]{64}$
pubkey_b64url string no Optional — base64url-encoded ML-DSA-65 public key (1,952 bytes). When supplied together with signature_b64url, the Worker verifies the proof-of-possession over "QUB_IDENTITY_AUTH_V1" || token and creates an email attestation for the derived fingerprint, binding Party B's verified invite email to their signing key in the same round-trip.
signature_b64url string no Optional — base64url-encoded ML-DSA-65 signature (3,309 bytes) over "QUB_IDENTITY_AUTH_V1" || utf8(token). Verified server-side with pubkey_b64url.

Responses

Status Description Body
200 Marker written. The cosign endpoint will accept Party B's signature without a separate magic-link verify. object (application/json)
400 Invalid token (TOKEN_INVALID), expired (TOKEN_EXPIRED), staging_id mismatch, malformed body, or invalid fingerprint. Error (application/json)
404 The staging record was retracted or has expired. Error (application/json)
409 Pact has already been cosigned (already_sealed). Error (application/json)
410 Token already used (TOKEN_USED). Single-use enforcement. Error (application/json)
503 Auth not configured (AUTH_DISABLED). Error (application/json)

200 response body — inline

Field Type Required Description
ok const true yes
attestation_created boolean no true when the supplied pubkey_b64url + signature_b64url produced a valid proof-of-possession AND the cosigner's fingerprint was bound to the verified invite email. false for any soft failure (missing attachment, malformed base64, wrong key size, bad signature, or fingerprint already bound to a different email) — the invite is still accepted in all cases.
email string (email) no The verified invite email decoded from the HMAC-signed token. Returned so the client can push it into its attestation signal immediately when attestation_created is true.

POST /api/v1/pact/stage — Stage a signed pact envelope for co-signing

Auth: public

Party A stages a signed pact envelope (content type 0x03, ML-DSA-65 author signature). Returns a staging id and a human-readable staging URL (/p/<id>). If Party B's contact is a valid email, a review invite email is auto-dispatched and email_sent is true. Staged pacts expire after 7 days if not co-signed. Four rate limits apply, any one tripping returns 429 with a descriptive error value: per-IP 20 stages per minute; per-device 20 per UTC day for normal devices, 3 per UTC day for risk-classified devices, 0 (blocked) for hard-flagged devices; per-recipient-email 10 invitations per UTC day (the spam-relay protection — applies to the recipient address, irrespective of which sender). A 429 from any cap does not consume the sender's qubs_remaining quota.

Request body (application/json, required)

Field Type Required Description
envelope_cbor string yes Base64-encoded canonical CBOR QubEnvelope with content_type=0x03, sig_alg=0x01, author_pubkey and author_signature present, cosigner fields absent.
turnstile_token string yes
device_id string yes
locale string no Optional BCP 47 locale used for the review invite email.

Responses

Status Description Body
201 Pact staged. object (application/json)
400 Invalid request. Error (application/json)
403 Turnstile verification failed. Error (application/json)
429 Rate limited.

201 response body — inline

Field Type Required Description
staging_id string yes
staging_url string yes
email_sent boolean yes

DELETE /api/v1/pact/stage/{staging_id} — Retract a staged pact before co-signing

Auth: public

Party A proves possession of the author signing key and retracts a pending staged pact. Requires a fresh retract signature over SHA3-256("QUB_PACT_RETRACT_V1" || staging_id_bytes).

Parameters

Name In Required Type Description
staging_id path yes string

Request body (application/json, required)

Field Type Required Description
retract_signature string yes Base64 retract signature from Party A's signing key.
turnstile_token string yes

Responses

Status Description Body
200 Retracted.
400 Invalid request or signature. Error (application/json)
404 Staging id not found or already sealed. Error (application/json)

GET /api/v1/pact/stage/{staging_id} — Review a staged pact

Auth: public

Returns the staged pact's CBOR envelope fields, decoded PactTerms, and Party A's public key so Party B can review before co-signing. If the pact has already been co-signed, returns a sealed redirect payload with tx_id and delivery_url.

Parameters

Name In Required Type Description
staging_id path yes string

Responses

Status Description Body
200 Staged pact details (or sealed redirect). object (application/json)
404 Staging id not found or expired. Error (application/json)

GET /api/v1/pacts/mine — Cross-device list of staged pacts authored by the device's linked email

Auth: public

Resolves device_link:<device_id> to the linked email, then returns every entry in the pact-by-email:<sha256(email)>:* author index — Party A's staged, sealed, and retracted pacts authored within the last 7 days. Backs the Pacts → Issued / Completed / Pending folders in the client. Same auth shape as /api/v1/identity and /api/v1/creator/qubs — the device_id is the capability. Returns 404 NOT_LINKED when the device hasn't been verified to an email yet.

Parameters

Name In Required Type Description
device_id query yes string 32-hex device identifier.

Responses

Status Description Body
200 Most-recent-first list of staged pacts. object (application/json)
400 Missing or malformed device_id. Error (application/json)
404 Device not linked. Returns { code: 'NOT_LINKED' }. Error (application/json)

200 response body — inline

Field Type Required Description
pacts array of object yes

This page is generated from workers/api/openapi.json by workers/api/scripts/build-openapi-md.mjs. For the raw JSON, see /api/v1/openapi.json.