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):
https://qub.social
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):
RATE_LIMIT_API_KEY— per-API-key in-window rate limit (default 60/min on Builder).DAILY_KEY_LIMIT— per-API-key daily Arweave seal cap (default 100/day on prod). Added by PR 1c (Andre 2026-05-22 Finding 7) to stop a single Builder key exhausting the global ceiling in minutes.DAILY_ACCOUNT_LIMIT— per-account aggregate daily cap (default 500/day on prod). Added by PR S6 (Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 56) to stop a Builder with multiple API keys aggregating past the per-key cap.
Circuit-breaker response codes (503):
CIRCUIT_BREAKER— global Arweave daily ceiling (5000/day on prod). Seals refused operator-wide until the cap is lifted.
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.