qub-API-Referenz
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 stored qub bytes
Auth: public (optional API key)
Returns the stored qub bytes plus minimal storage metadata. Private deliveries are OuterWrapper CBOR and require the URL-fragment key K for local unwrap; public deliveries are bare SealedQubCbor and need no K. The Worker does not decrypt this response.
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-private or bare-public 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 stored bytes: canonical OuterWrapper CBOR for private delivery or bare canonical SealedQubCbor for public delivery. |
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 stored qub artifact bytes
Auth: public
Returns the raw stored artifact for a qub: private OuterWrapper CBOR or public bare SealedQubCbor. Uses R2 cache-aside and immutable caching (Cache-Control: public, max-age=31536000, immutable). The in-browser embed and SPA resolve the delivery shape, then perform client-side tlock decryption 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 stored artifact bytes: private OuterWrapper CBOR or public bare SealedQubCbor. | 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. |
GET /api/v1/qub/{tx_id}/proof — Transparency-log inclusion proof
Auth: public (optional API key)
Returns the canonical-CBOR inclusion proof for an anchored qub (PROTOCOL.md §16.9): the exact leaf bytes, the leaf index, the anchored tree size, the RFC 6962 audit path, the committed Merkle root, and the AnchorRef (the Arweave anchor that committed that root). A standalone verifier (tools/qub-verify --anchor) recomputes the leaf hash, folds the audit path, and checks the result against the anchor — it never trusts a supplied hash. Proofs resolve from the permanent R2 substrate plus the anchor, so reclaiming LogDO storage never invalidates one. Cached public, max-age=3600.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction id (base64url, 1-64 chars, at least 8 distinct characters). |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Canonical-CBOR InclusionProof (leaf, index, size, audit path, committed root, AnchorRef). | string (binary) (application/cbor) |
400 |
Malformed tx_id. | Error (application/json) |
404 |
Unknown tx_id, insufficient entropy, or the qub is not yet anchored. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
503 |
Transparency-log store unbound, or the store advanced past the anchored size mid-window — retry. | Error (application/json) |
POST /api/v1/seal — Server-side seal (Builder)
Auth: API key
Seals a plaintext qub server-side using drand timelock encryption, applies the AES-256-GCM outer wrapper, and durably schedules the exact signed transaction for permanent storage. Requires an API key with the seal scope, a strong Idempotency-Key, and a caller-generated 32-byte wrapper key in wrapper_key_b64url; subject to the API key's IP allowlist if configured. Trust-model note: the plaintext body and K pass through the Worker in memory (neither is persisted), unlike the client-side /api/v1/upload path. Body cap 50 KB. Maximum unlock horizon 10 years from now. Generate and retain K before the request. The response returns the same K for convenience, but the replay store deliberately strips it; after a lost first response, combine the replayed tx URL with the caller-retained #K. Per-API-key seal rate limits and per-period quotas are enforced.
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).DAILY_ACCOUNT_LIMIT— per-account aggregate daily cap (default 500/day on prod).
Idempotency-Key is mandatory because sealing is billable and irreversible. Same-body retries replay the result for 24h instead of creating a second qub; incompatible key reuse returns IDEMPOTENCY_KEY_CONFLICT, missing strong authority returns IDEMPOTENCY_UNAVAILABLE, and a missing header returns IDEMPOTENCY_KEY_REQUIRED. The header MUST match ^[A-Za-z0-9._-]{1,255}$. Concurrent or ambiguity-fenced retries return IDEMPOTENCY_IN_FLIGHT.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key |
header | yes | string |
Required Stripe-compatible idempotency key. Retries with the same key replay the original response for 24 hours and can never create a second billed/permanent seal. MUST match ^[A-Za-z0-9._-]{1,255}$; missing or malformed values return 400. |
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. |
wrapper_key_b64url |
string |
yes | Required caller-generated base64url-no-pad encoding of a cryptographically random 32-byte AES-256-GCM outer-wrapper key K. Generate and retain K before calling /seal; the Worker uses it only in memory and never persists it. Idempotency replays deliberately redact K, so the retained caller copy is what makes a lost first response recoverable: append it as #<K> to the replayed fragment-less delivery URL. — minLength: 43, maxLength: 43, pattern: ^[A-Za-z0-9_-]{43}$ |
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 already-sealed qub bytes
POST /api/v1/upload — Upload already-sealed qub bytes
Auth: Turnstile or API key
Accepts already-sealed bytes: an OuterWrapper for private delivery or bare SealedQubCbor for public delivery. Before acknowledgement, the artifact and exact individual-transaction publication state are synchronously written to durable R2. The route then attempts a transparency-log append when LOG_DO is configured; only a successful append adds log_seq, receipt, and anchor_status to the response, while append failure is currently fail-soft. The pre-signed individual permanent-storage transaction is posted asynchronously. device_id remains the anonymous quota/entitlement locator. A valid matching browser session may select the linked-account quota/history branch; without a cookie the route stays anonymous and MUST NOT follow device_link. A presented mismatched cookie returns SESSION_DEVICE_MISMATCH; other presented invalid/expired/revoked/unavailable authority fails closed instead of silently downgrading. Browser clients also supply Turnstile; API-key callers use their separate Bearer authority. A valid Idempotency-Key is required. The 24-hour replay is semantic-operation-bound: retry-ephemeral wrapper bytes/nonces and Turnstile tokens do not create a second mutation, while incompatible reuse returns IDEMPOTENCY_KEY_CONFLICT. Missing keys return IDEMPOTENCY_KEY_REQUIRED; strong idempotency failure returns IDEMPOTENCY_UNAVAILABLE without uploading.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key |
header | yes | string |
Required stable operation key. Same-operation retries replay the original response and cannot consume quota or publish twice. MUST match ^[A-Za-z0-9._-]{1,255}$. |
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
wrapped_cbor_base64 |
string |
yes | Base64-encoded selected upload payload. Private delivery sends canonical OuterWrapper CBOR and keeps K in the client URL fragment; public delivery sends bare canonical SealedQubCbor and sets is_public: true. The field name is retained for API compatibility. The upload route treats either shape as opaque bytes. |
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 selected upload payload, not the plaintext body. For private delivery this is the OuterWrapper size, including AEAD and CBOR framing overhead; for public delivery it is the bare SealedQubCbor size. |
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. |
title |
string |
no | Optional display-only title for the creator's sealed-history folder lists (/messages, /history), persisted on the per-device sealed-history index so titles surface cross-device. NFC-normalised and capped at 100 code points (matching the compose-side SealedQub::title bound), validated at the edge via normaliseAndValidateText. A hostile or over-long title is silently dropped to absent rather than failing the upload — the qub is already sealed and the canonical title rides inside the selected upload payload. The Worker never renders this declaration to viewers; it is creator-private folder metadata. |
unlock_at |
integer |
yes | Required Unix-seconds reveal time declared by the client. The upload route deliberately treats both accepted byte shapes as opaque and uses this value only for metadata, sealed-history backup, lifecycle scheduling, and the asserted transparency-log leaf. The cryptographic artifact remains governed by the inner SealedQub and drand regardless of this declaration. |
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 selected upload payload — this surface is an out-of-band declaration so the byte-blind route can schedule the verdict-CTA email (V1.6). Persisted on the creator-lifecycle record (cl:<tx_id>). |
qub_id_hex |
string |
yes | Required 64-char lowercase hex declaration of the inner SealedQub's qub_id. The byte-blind upload route uses it as metadata and in the asserted transparency-log leaf; verifiers establish the actual qub_id from the decrypted protocol fields. For private delivery it is also the OuterWrapper AEAD AAD. — 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. Published only with a valid author_pubkey + author_pop_sig proof of possession (QUB-UPLOAD-001): the byte-blind Worker cannot verify the in-envelope signature, so without the detached proof the fingerprint is dropped — a direct API call cannot spoof another creator's pre-reveal byline. When omitted, no Author tag is written and the qub is unattributed. Invalid values are silently dropped. — pattern: ^[0-9a-f]{64}$ |
author_pubkey |
string |
no | Optional base64url-no-pad of the creator's 1952-byte ML-DSA-65 public key. Paired with author_pop_sig to prove possession of the key behind author_fingerprint (QUB-UPLOAD-001). The Worker requires SHA3-256(pubkey) to equal author_fingerprint and the signature to verify before publishing the Author tag. Send together with author_fingerprint. — pattern: ^[A-Za-z0-9_-]+$ |
author_pop_sig |
string |
no | Optional base64url-no-pad of the ML-DSA-65 signature over the upload-author proof-of-possession challenge "QUB_UPLOAD_AUTHOR_V1" || author_fingerprint(32) || chash(32), where chash = SHA3-256(selected upload payload bytes). Binds the proof to this specific upload so it can't be replayed onto another. — pattern: ^[A-Za-z0-9_-]+$ |
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 authoritative outcome rides inside the selected upload payload (opaque to this route), while this enum 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 decoding the verdict artifact. 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). |
verdict_proof_pubkey |
string |
no | Optional base64url-no-pad of the verdict author's 1952-byte ML-DSA-65 public key. Paired with verdict_proof_sig to prove the uploader is the PARENT author before the Worker writes the authoritative verdicts_for_parent index + subscriber fan-out (QUB-UPLOAD-002). The Worker requires SHA3-256(pubkey) to equal the parent's published Author fingerprint. Set ONLY when uploading a verdict qub. — pattern: ^[A-Za-z0-9_-]+$ |
verdict_proof_sig |
string |
no | Optional base64url-no-pad of the ML-DSA-65 signature over the verdict-parent proof-of-possession challenge "QUB_VERDICT_PARENT_V1" || parent_tx_id(43) || outcome(1) || verdict_qub_id(32). Binds the proof to this verdict + outcome so it can't be lifted onto another. Absent/invalid ⇒ the verdict is still durably accepted and scheduled for permanent storage but gets no authoritative parent-verdict state (fail-soft). — pattern: ^[A-Za-z0-9_-]+$ |
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>. For public delivery (is_public: true) this is the complete working URL. For private delivery the Worker never receives K on the default upload path, so callers MUST append #<base64url(K)> themselves; without that fragment the private qub is unreadable. Server-side /seal returns its complete fragment-bearing URL directly. |
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. |
log_seq |
integer |
no | Optional zero-based transparency-log leaf position. Present only as part of the all-or-nothing log tuple when the inline LogDO append succeeded. Absence means the publication is durable but makes no transparency-log acceptance claim. |
anchor_status |
enum ("pending") |
no | Present only with log_seq and receipt; means the accepted leaf is waiting for a covering anchor. It does not mean an Arweave anchor already exists. |
receipt |
object |
no | Receipt returned after a successful log append. The signature is deployment-gated: sig_b64url is the empty string when RECEIPT_SK is absent or invalid, and such a receipt is not signed/non-repudiable. The current compiled LogProfile.receipt_pubkey is also empty until a verifier release pins the provisioned public key. |
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
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 ownership indexes move forward; created_at is preserved. No scope gate — successfully presenting the current key already proves ownership. Concurrent lifecycle mutations are blocked via a two-minute owner-token lease backed by the QuotaDO. Idempotency-Key is mandatory. The newly minted secret appears only in the live first response and is never persisted in the replay store. A same-key replay returns 409 with the ROTATION_REPLAYED redacted sentinel; use the still-valid old key during its grace window to rotate again with a fresh idempotency key if the first response was lost.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key |
header | yes | string |
Required Stripe-compatible idempotency key. The first response carries the secret; a replay within 24 hours is deliberately redacted and returns 409 ROTATION_REPLAYED. MUST match ^[A-Za-z0-9._-]{1,255}$; missing or malformed values return 400. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Rotation succeeded; the response carries the new raw key. | ApiKeyRotateResponse (application/json) |
400 |
Missing or malformed Idempotency-Key. |
Error (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 in progress/committed, or an idempotent replay was redacted as ROTATION_REPLAYED. |
Error (application/json) |
429 |
Per-account rotation limit exceeded (10 per hour). | Error (application/json) |
503 |
Strong idempotency, quota, account authority, usage migration, or rotation backend unavailable; retry safely with the same idempotency key. | 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 through the one-hour grace mapping (or until a newer rotation advances it). |
Payment
Stripe subscriptions plus the account-bound, prepaid Lightning Builder adapter
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 | |
qubs_allowance |
integer |
no | Per-period allowance of the highest active Pro source (500 monthly / 6,000 annual). Absent for free tier or when no source is active. |
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 webhooks 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). Every delivery payload carries a stable delivery_id (deterministic per webhook + qub + event) — receivers SHOULD dedupe on it, because retries follow a backoff ladder (1m/5m/15m/1h/6h/24h then daily, up to 12 attempts across the 7-day retention window) and overlapping sends can occasionally duplicate.
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/log/consistency — Transparency-log consistency proof
Auth: public (optional API key)
Returns the canonical-CBOR RFC 9162 consistency proof showing the log at tree size first is an append-only prefix of the log at size second (PROTOCOL.md §16.9): the two committed roots, the consistency node set, and both AnchorRefs. v1 serves sizes up to 512 — the far-more-frequent inclusion proof is already O(log N), so coordinate-walk consistency serving is deferred per §16.7. Cached public, max-age=3600.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
first |
query | yes | integer |
First (smaller) anchored tree size. 1 <= first <= second <= 512. |
second |
query | yes | integer |
Second (larger) anchored tree size. first <= second <= 512. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Canonical-CBOR ConsistencyProof (first/second sizes, both roots, consistency node set, both AnchorRefs). | string (binary) (application/cbor) |
400 |
Missing or non-canonical sizes, first > second, or second > 512. | Error (application/json) |
404 |
One or both sizes have not been anchored yet. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
503 |
Transparency-log store unbound or audit substrate incomplete — retry. | Error (application/json) |
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 for public qubs. Returns a JSON oEmbed payload whose <qub-embed src=...> uses the fragment-less public viewer URL, so WordPress, Notion, Medium, and Substack can auto-embed without a manual snippet. Private delivery requires the complete secret-bearing URL and should use the explicit publisher snippet instead; this endpoint never reflects a wrapper key.
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 a representative linked fingerprint and public attestation
Auth: public
Public reverse lookup. Every email-verified user has a handle (auto-allocated or personalised, IDENTITY.md §3.2.5). The handle belongs to the email-scoped logical identity; this response selects one currently linked fingerprint as a projection locator, not as proof that one keypair owns the handle. 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 a representative linked fingerprint and the projected public 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) its logical identity has no linked attestation available for public projection (or a legacy fingerprint-owned entry is orphaned). |
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 | Representative linked pubkey fingerprint selected to project the public attestation response. The handle belongs to the email-scoped logical identity, not to this individual key. — 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). Callers that model Layer 0 as a normal state may set allow_missing=true to receive a discriminated 200 result (found: true plus the record, or { found: false }). If allow_missing is present it must occur exactly once as lowercase true or false. The viewer uses this at reveal time to render the richest identity label per §5.3. Found records are cached at the edge for 5 minutes; explicit misses are not stored.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
fingerprint |
path | yes | string |
64-char lower-case hex SHA3-256 of the author public key. |
allow_missing |
query | no | boolean |
When true, return an explicit successful miss instead of 404 for an unattested fingerprint. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Attestation record, or a discriminated found/missing result when allow_missing=true. |
AttestationRecord or AttestationLookupMiss (application/json) |
400 |
Malformed fingerprint or invalid/duplicated allow_missing selector. |
Error (application/json) |
404 |
No attestations for this fingerprint when allow_missing is omitted or false (Layer 0 / not attested). |
Error (application/json) |
200 response body — AttestationRecord
| Field | Type | Required | Description |
|---|---|---|---|
found |
const true |
no | Present as true on a found public lookup when allow_missing=true; omitted from the legacy lookup and mutation responses. |
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 |
200 response body — AttestationLookupMiss
| Field | Type | Required | Description |
|---|---|---|---|
found |
const false |
yes |
Pact
POST /api/v1/pact/cosign/{staging_id} — Co-sign and publish a staged pact
Auth: Turnstile
Browser ceremony in which 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, durably stores the artifact, and schedules permanent-storage publication. Pact publication does not currently append to the transparency log. 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. Turnstile is required; API-key bearer authentication is not accepted.
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 |
Durable publication failed before acknowledgement. | 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
Consumes the single-use emailed pact capability and writes a short-lived, pact-scoped cosign marker. Invite acceptance does not sign the browser into the invite address, issue a browser session, mutate an identity, or create an attestation. An optional cosigner fingerprint preserves PC-3 key binding.
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}$ |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Pact-scoped cosign marker committed. No account session or identity mutation is performed. | 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/token authority unavailable (AUTH_DISABLED or service_unavailable). |
Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
ok |
const true |
yes |
POST /api/v1/pact/stage — Stage a signed pact envelope for co-signing
Auth: Turnstile
Browser ceremony that stages a signed pact envelope. device_id remains a quota locator. A valid matching browser session may select the account quota/index branch; no cookie stays anonymous and never follows device_link. Presented bad/unavailable session authority fails closed and a mismatch returns SESSION_DEVICE_MISMATCH. A valid Idempotency-Key is mandatory so an ambiguous response cannot charge or create a second staging artifact; missing keys return IDEMPOTENCY_KEY_REQUIRED. Turnstile, signature proof, recipient send budget, and the seven-day stage lifetime remain independent controls. API-key bearer authentication is not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
Idempotency-Key |
header | yes | string |
Required stable operation key for the signed staging mutation. MUST match ^[A-Za-z0-9._-]{1,255}$. |
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 | Whether the invite email to Party B was accepted for delivery. |
email_skipped_reason |
string or null |
yes | Why no invite was sent when email_sent is false; null when it was sent. unverified_author: the signing key carries no email attestation matching the browser session — attest it (attestEmailViaSession) and call sendPactInvite. |
DELETE /api/v1/pact/stage/{staging_id} — Retract a staged pact before co-signing
Auth: Turnstile
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. While awaiting co-signature the payload also carries invite_emailed (boolean): whether the invite email to Party B has been accepted for delivery. The sealed payload carries unlock_at (Unix seconds, or null once the staging object has aged out): a pact co-signed inside its unlock buffer is sealed but not yet openable, and clients should hold until then rather than forward to the viewer countdown.
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) |
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.