{"openapi":"3.1.0","info":{"title":"qub API","version":"1.0.0","description":"API for qub, a timed commitment and timed publication system backed by drand timelock encryption. Seal qubs now, reveal them later."},"servers":[{"url":"https://qub.social"}],"tags":[{"name":"qubs","description":"Create, seal, and read qubs"},{"name":"Upload","description":"Upload sealed content to permanent storage"},{"name":"Engagement","description":"Watch / view counters and notify-me subscriptions"},{"name":"API Keys","description":"API key checkout, retrieval, and rotation"},{"name":"Payment","description":"Stripe checkout and entitlements"},{"name":"Webhooks","description":"Webhook registration for qub unlock notifications"},{"name":"Viewer","description":"Public viewer page (browsers + bots)"},{"name":"Meta","description":"API metadata"},{"name":"Embed","description":"F4 embed loader + iframe shell + bundled iframe app for third-party page embedding"}],"components":{"securitySchemes":{"turnstile":{"type":"http","scheme":"bearer","description":"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":{"type":"http","scheme":"bearer","description":"API key with prefix `qub_sk_`. Pass in the Authorization header as `Bearer qub_sk_...`."}},"schemas":{"Error":{"type":"object","description":"Standard error envelope. Every 4xx/5xx response carries a stable, machine-readable `code` plus an advisory English `error` message. Branch on `code`; treat `error` as human-facing text that may change.\n\n**Canonical codes** clients are most likely to branch on (post-2026-05-22 security uplift):\n\n- `invalid_json`, `invalid_unlock_at`, `invalid_title`, `invalid_sender_label`, `invalid_text` — 400, malformed request body.\n- `invalid_device_id`, `invalid_email` — 400, body field shape violation (`device_id` must be 32 lowercase hex; `email` must be ASCII-only with no mixed-script IDN domain).\n- `IDEMPOTENCY_KEY_INVALID` — 400, `Idempotency-Key` header outside the `^[A-Za-z0-9._-]{1,255}$` Stripe-compatible shape.\n- `IDEMPOTENCY_IN_FLIGHT` — 409, concurrent retry against an in-flight idempotency claim.\n- `IDEMPOTENCY_REPLAYED` — surfaced via the `Idempotency-Replayed: true` response header on replays.\n- `RATE_LIMIT_IP`, `RATE_LIMIT_KEY`, `RATE_LIMIT_API_KEY`, `rate_limited` — 429.\n- `DAILY_KEY_LIMIT` — 429, per-API-key daily Arweave seal cap reached (Andre 2026-05-22 Finding 7).\n- `DAILY_ACCOUNT_LIMIT` — 429, per-account aggregate daily Arweave seal cap reached (Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 56).\n- `CIRCUIT_BREAKER` — 503, global Arweave daily ceiling tripped.\n- `READS_EXHAUSTED` — 429, Enterprise / legacy hard cap.\n- `MONTHLY_CAP_REACHED` — 402, Builder `max_monthly_charge_usd` ceiling.\n- `QUOTA_EXHAUSTED` — 402, per-key qubs_remaining at 0.\n- `TIER_INSUFFICIENT` — 403, route requires a paid plan.\n- `API_KEY_DISABLED`, `IP_NOT_ALLOWED`, `INSUFFICIENT_SCOPE`, `invalid_api_key` — 401/403, API-key middleware rejections.\n- `TOKEN_USED` — 410, magic-link single-use replay (cross-colo race closed by PR 0d).\n- `NOT_LINKED` — 404, used by `/identity` lookup paths.","required":["error","code"],"properties":{"code":{"type":"string","description":"Stable, machine-readable error code (e.g. `invalid_json`, `rate_limited`, `not_found`). This is the field clients should branch on. See the schema description for the canonical list."},"error":{"type":"string","description":"Advisory English message for humans. Not a stable interface — defaults to the `code` when no message is supplied."},"req_id":{"type":"string","description":"Per-request correlation ID, when available. Echoes the `X-Request-Id` response header."}}},"UploadAuthRequest":{"type":"object","required":["device_id","content_size"],"properties":{"turnstile_token":{"type":"string","description":"Cloudflare Turnstile token. Optional when using API key auth."},"device_id":{"type":"string"},"content_size":{"type":"integer","description":"Size in bytes of the sealed content."}}},"UploadAuthResponse":{"type":"object","required":["allowed","tier","max_size"],"properties":{"allowed":{"type":"boolean"},"tier":{"type":"string"},"max_size":{"type":"integer"},"reason":{"type":"string"}}},"UploadRequest":{"type":"object","required":["wrapped_cbor_base64","device_id","content_size","unlock_at","qub_id_hex"],"properties":{"wrapped_cbor_base64":{"type":"string","description":"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":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"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."},"turnstile_token":{"type":"string","description":"Cloudflare Turnstile token. Optional when using API key auth."},"content_size":{"type":"integer","description":"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":{"type":"string","enum":["announcement","thesis","prediction","letter","secret","commitment","proof","verdict"],"description":"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":{"type":"integer","description":"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":{"type":"integer","description":"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":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"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)."},"author_fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"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."},"parent_tx_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","description":"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."},"verdict_outcome":{"type":"integer","minimum":1,"maximum":4,"description":"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":{"type":"string","enum":["prediction","commitment","announcement","thesis"],"description":"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":{"type":"string","format":"email","description":"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":{"type":"string","description":"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":{"type":"boolean","description":"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":{"type":"string","description":"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":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","description":"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."},"is_public":{"type":"boolean","description":"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."}}},"UploadResponse":{"type":"object","required":["tx_id","delivery_url"],"properties":{"tx_id":{"type":"string","description":"Storage transaction ID."},"delivery_url":{"type":"string","format":"uri","description":"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":{"type":"string","description":"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":{"type":"integer","description":"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."}}},"SealRequest":{"type":"object","required":["body","unlock_at"],"properties":{"body":{"type":"string","description":"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":{"type":"integer","description":"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":{"type":"string","maxLength":80,"description":"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."},"title":{"type":"string","maxLength":100,"description":"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.\n\nUnicode 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`.\n\nContent 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."}}},"SealResponse":{"type":"object","required":["tx_id","delivery_url","qub_id","unlock_at","drand_round","wrapper_key_b64url"],"properties":{"tx_id":{"type":"string"},"delivery_url":{"type":"string","format":"uri","description":"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":{"type":"string"},"unlock_at":{"type":"number"},"drand_round":{"type":"integer"},"short_delivery_url":{"type":"string","format":"uri","description":"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":{"type":"string","description":"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`."}}},"QubReadResponse":{"type":"object","required":["tx_id","wrapped_cbor_base64"],"description":"Wrapped qub bytes plus minimal storage metadata (PROTOCOL.md §13). The Worker holds no decryption capability — the wrapper key K lives only in the URL fragment on the client. Consumers unwrap and decrypt locally using `qub-core` (Rust), the TypeScript mirror at `workers/api/src/crypto/wrapper.ts`, or the standalone reference viewer.","properties":{"tx_id":{"type":"string"},"wrapped_cbor_base64":{"type":"string","description":"Base64-encoded canonical CBOR bytes of the OuterWrapper (PROTOCOL.md §13). Opaque to the Worker."},"arweave_block_timestamp":{"type":"number","description":"Storage block timestamp (Unix seconds UTC). Best-effort — absent for unconfirmed transactions."},"intent":{"type":"string","description":"Compose intent that the creator selected (if any), read from the `Intent` storage tag. Used by the viewer's 'Seal your own qub' CTA."}}},"EntitlementsResponse":{"type":"object","required":["tier","qubs_remaining"],"properties":{"tier":{"type":"string","enum":["free","pro"]},"qubs_remaining":{"type":"integer"},"purchased_at":{"type":"integer","format":"int64","description":"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":{"type":"integer","format":"int64","description":"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":{"type":"string","enum":["active","past_due","cancelled"],"description":"Highest-level status across active payment sources (PAYMENTS.md v1.0 §4.4). Absent when the entitlement has no payment sources."},"sources_summary":{"type":"array","description":"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.","items":{"type":"object","required":["provider","sku","status","period_end"],"properties":{"provider":{"type":"string","enum":["stripe","apple","google","telegram","crypto","regional"]},"sku":{"type":"string","enum":["pro_monthly","pro_annual","builder_monthly"]},"status":{"type":"string"},"period_end":{"type":"integer","format":"int64"}}}}}},"CheckoutRequest":{"type":"object","required":["price_id","device_id"],"properties":{"price_id":{"type":"string","description":"Stripe Price ID for the chosen plan (Pro monthly, Pro annual, or Builder monthly). Configured server-side at deploy time; clients pass the value rendered by `/pricing`."},"device_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"Device identifier (32 lowercase hex chars). The Stripe webhook binds the resulting entitlement to this device on `checkout.session.completed`. Pattern enforced at the API edge AND re-validated on webhook receipt (PR S5 / Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 46) so a malformed value can't reach the entitlement-grant path."},"email":{"type":"string","format":"email","description":"Optional pre-fill for the Stripe checkout email field. Stripe collects the canonical buyer email regardless. When present, validated via `isValidEmail` — ASCII local part, no mixed-script IDN domain (PR S4 / PR 1b)."},"locale":{"type":"string","description":"BCP 47 locale tag captured at checkout and persisted onto the identity record so subsequent server-sent emails (receipt, lifecycle, anniversary) inherit the same language."}}},"CheckoutResponse":{"type":"object","required":["checkout_url"],"properties":{"checkout_url":{"type":"string","format":"uri"}}},"ApiKeyCheckoutResponse":{"type":"object","required":["checkout_url","session_id","retrieval_secret"],"properties":{"checkout_url":{"type":"string","format":"uri"},"session_id":{"type":"string","pattern":"^cs_","description":"Stripe `cs_...` session id. Used as the sessionStorage key under which the client persists `retrieval_secret` as a fragment-loss fallback."},"retrieval_secret":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","description":"F-01: 43-char base64url retrieval secret. Required body field on `POST /api/v1/api-keys/provisioned`. Also rides as the `#rs=` fragment on the success_url so the WASM client can recover it after Stripe's redirect."}}},"CryptoQuoteRequest":{"type":"object","required":["sku","device_id"],"properties":{"sku":{"type":"string","enum":["pro_monthly","pro_annual","builder_monthly"],"description":"Canonical SKU. PAYMENTS.md v1.1 §2.1 — Pro monthly $9, Pro annual $69, Builder monthly $29 (USDC at 1:1 with USD). Builder on crypto is hard-capped at 1000 seals/period (no metered overage)."},"device_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"Device identifier (32 lowercase hex chars). The resulting Pro entitlement binds to this device; for Builder this is informational — the API key is keyed on `invoice_id` and retrievable via `key_retrieval_url`. Pattern enforced since PR S4 (Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 43)."},"email":{"type":"string","format":"email","description":"Optional. Used for the 7d-pre-expiry renewal-reminder email (PAYMENTS.md v1.1 §8.6); also becomes the Builder API key's `client_id` so the dashboard can list it. Subject to `isValidEmail` shape + IDN reject (PR 1b) — mixed-script homoglyph domains (`chаse.com` with Cyrillic `а`) and non-ASCII local parts return 400."}}},"CryptoQuoteResponse":{"type":"object","required":["invoice_id","sku","usdc_amount","contract_address","chain_id","expires_at","pay_intent_url"],"properties":{"invoice_id":{"type":"string","description":"32-byte hex with `0x` prefix. Matches the `bytes32` invoice_id passed to `QubPayments.pay()` on-chain."},"sku":{"type":"string","enum":["pro_monthly","pro_annual","builder_monthly"]},"usdc_amount":{"type":"string","description":"Decimal string with 6 decimals, e.g. `\"9.000000\"`."},"contract_address":{"type":"string","description":"Deployed `QubPayments.sol` address on the target chain."},"chain_id":{"type":"integer","description":"Base mainnet `8453`; Sepolia `84532`."},"expires_at":{"type":"integer","format":"int64","description":"Unix-seconds UTC; the quote is valid for 5 minutes from issue. Payments arriving after expiry can still be reconciled via the sidecar but should be operator-confirmed."},"pay_intent_url":{"type":"string","description":"EIP-681 payment URI suitable for QR encoding. Wallets render this as a one-tap `Send` UI calling `QubPayments.pay(invoice_id, amount)`."},"key_retrieval_url":{"type":"string","description":"Builder-only. Path the modal POSTs to (with body `{ invoice_id, retrieval_secret }`) every 5s after payment until the wrapped raw API key is available (15-min TTL post-watcher-grant). Mirrors the Stripe Builder `apikey-provisioned:{session.id}` retrieval pattern, keyed on `invoice_id` instead."},"retrieval_secret":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","description":"Builder-only. F-01: 43-char base64url retrieval secret bound to this invoice. The modal holds it in memory and presents it on every poll of `key_retrieval_url`. Absent on Pro quotes."}}},"ReportRequest":{"type":"object","required":["tx_id","reason"],"properties":{"tx_id":{"type":"string"},"reason":{"type":"string","enum":["illegal","harassment","personal_info","other"]},"text":{"type":"string","maxLength":8000,"description":"Optional free-text description (up to 2000 Unicode code points; the byte-length cap above is generous). NFC-normalised and screened for hostile codepoints (bidi-override, ZWSP, tag-block, BOM, C0/C1) at the API edge so a malicious reporter can't smuggle a phishing payload through the admin-notification email body. Returns 400 `invalid_text` on violation. Added in PR S5 (Andre 2026-05-22 SYSTEMIC-THREAT-REVIEW Finding 51)."}}},"DenylistRequest":{"type":"object","required":["action","tx_id"],"properties":{"action":{"type":"string","enum":["add","remove"],"description":"`add` adds the qub to the denylist (viewer refuses to render); `remove` lifts the denylist."},"tx_id":{"type":"string"},"reason":{"type":"string"}}},"WebhookCreateRequest":{"type":"object","required":["tx_id","url","secret"],"properties":{"tx_id":{"type":"string","description":"Storage transaction ID to watch."},"url":{"type":"string","format":"uri","description":"URL to receive the webhook POST."},"secret":{"type":"string","minLength":16,"description":"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."}}},"WebhookCreateResponse":{"type":"object","required":["webhook_id"],"properties":{"webhook_id":{"type":"string"}}},"Webhook":{"type":"object","required":["webhook_id","tx_id","url"],"properties":{"webhook_id":{"type":"string"},"tx_id":{"type":"string"},"url":{"type":"string","format":"uri"}}},"QubMetaResponse":{"type":"object","description":"Lightweight metadata for a sealed qub. All fields are optional — absent fields mean the GraphQL fetch failed, the transaction is not yet confirmed in a block, or the corresponding storage tag is not present.","properties":{"arweave_block_timestamp":{"type":"integer","description":"Unix seconds when the qub's storage transaction was first included in a block."},"intent":{"type":"string","description":"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.","enum":["announcement","thesis","prediction","letter","secret","commitment","proof","verdict"]},"parent_tx_id":{"type":"string","description":"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":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"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."},"watching":{"type":"integer","description":"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":{"type":"object","description":"Aggregate reaction tallies for a revealed prediction qub. Absent for unrevealed qubs and for qubs with no reactions yet.","properties":{"called_it":{"type":"integer","description":"Count of `called_it` reactions."},"wrong":{"type":"integer","description":"Count of `wrong` reactions."},"total":{"type":"integer","description":"`called_it + wrong` — server-computed so the client never miscounts when hydrating."}}},"denylisted":{"type":"boolean","description":"True if the qub has been removed from public viewing via the moderation denylist. The F4 embed iframe checks this before fetching stored bytes."}}},"EngagementCountResponse":{"type":"object","required":["count"],"properties":{"count":{"type":"integer","description":"Current count after this increment. Returns 0 if rate-limited (so the client can render zero or its cached value rather than an error)."}}},"NotifySubscribeRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","description":"Email address to subscribe. Max 200 chars; the domain is lowercased on store."},"unlock_at":{"type":"integer","description":"Unix seconds when the qub unlocks. Strongly preferred — without it the cron has no way to know when to send the email."},"locale":{"type":"string","description":"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":{"type":"string","enum":["prediction","letter","secret","announcement","thesis","commitment","proof","verdict"],"description":"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."}}},"OkResponse":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}},"MagicLinkRequest":{"type":"object","required":["email","device_id"],"properties":{"email":{"type":"string","format":"email","description":"Email address to send the magic link to. Max 200 chars."},"device_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"Device identifier (32 hex chars). The verify path attaches this device to the resulting identity."},"locale":{"type":"string","description":"BCP 47 locale tag for the email body. Persisted onto the identity record so future server-sent emails use the same language."},"staging_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"Optional pact staging ID. When present, the verify handler looks up the staged pact, matches the counterparty email, and writes a short-lived `pact-email-verified` marker that the cosign endpoint requires."},"cosigner_fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Optional PC-3 fingerprint binding. Requires `staging_id` in the same request. When present, the verify handler stores the SHA3-256 fingerprint in the `pact-email-verified` marker; the cosign endpoint then rejects any submitted `cosigner_pubkey` whose hash doesn't match. Prevents a phished email click from being used to co-sign with an unrelated keypair."}}},"VerifyRequest":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"The signed token from the magic link URL. Format: `base64url(payload).base64url(sig)`. HMAC-signed, single-use, expires 15 minutes after issue."},"pubkey_b64url":{"type":"string","description":"Optional — base64url-encoded ML-DSA-65 public key (1952 bytes). When supplied together with `signature_b64url`, the Worker verifies a proof-of-possession signature over `\"QUB_IDENTITY_AUTH_V1\" || utf8(token)` and, on success, writes an email attestation record binding the key's SHA3-256 fingerprint to the verified email — a single round-trip that replaces the standalone attestation begin/verify flow for first-time signing-key setup. Soft-fails: an invalid signature, key-size mismatch, or conflict with an existing different-email attestation downgrades silently to a viewer-only verify (the attestation just isn't written). Absent = viewer-only sign-in, no attestation."},"signature_b64url":{"type":"string","description":"Optional — base64url-encoded ML-DSA-65 signature (3309 bytes) over `\"QUB_IDENTITY_AUTH_V1\" || utf8(token)`. Paired with `pubkey_b64url`; both must be supplied together."}}},"SignoutRequest":{"type":"object","required":["device_id"],"properties":{"device_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"32-hex device identifier to unbind."}}},"SignoutResponse":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}}},"EmailChangeBeginRequest":{"type":"object","required":["current_email","new_email"],"properties":{"current_email":{"type":"string","format":"email","description":"The current login email of the identity being changed. Must match an existing `IdentityRecord` keyed at `identity:<email>` in ENTITLEMENTS — `404 no_identity` otherwise."},"new_email":{"type":"string","format":"email","description":"Proposed new login email. Validated, normalised, risk-classified (when the email-risk flag is enabled), and checked for collision against an existing identity before any mail is sent."},"locale":{"type":"string","description":"BCP 47 locale tag for both outbound emails. Falls back to the OLD identity's persisted `locale` if absent."}}},"EmailChangeBeginResponse":{"type":"object","required":["ok","expires_at"],"properties":{"ok":{"type":"boolean","const":true},"expires_at":{"type":"integer","description":"Unix seconds UTC when the pending record (and both tokens) expire. 30 minutes after the request."}}},"EmailChangeTokenRequest":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"The signed token from the verify or cancel link. Format: `base64url(payload).base64url(sig)`. HMAC-signed, single-use, 30-minute TTL. Verify and cancel tokens are distinguished by the `kind` field on the payload — a verify endpoint refuses a cancel token (and vice versa) with `error: \"wrong_kind\"`."}}},"EmailChangeVerifyResponse":{"type":"object","required":["ok","email","tier","qubs_remaining","sealed_history"],"properties":{"ok":{"type":"boolean","const":true},"email":{"type":"string","format":"email","description":"The new login email — the swap completed atomically and this is now the canonical identifier."},"tier":{"type":"string","enum":["free","creator","builder"]},"qubs_remaining":{"type":"integer"},"handle":{"oneOf":[{"type":"string"},{"type":"null"}],"description":"Handle, preserved across the swap (handle is owned by the identity record, not the email)."},"sealed_history":{"type":"array","items":{"$ref":"#/components/schemas/SealedHistoryEntry"}}}},"SealedHistoryEntry":{"type":"object","required":["tx_id","intent","sealed_at","unlock_at"],"description":"A single qub in the per-identity sealed-history index. Deliberately omits any preview or content fields — the server-side index is just pointers.","properties":{"tx_id":{"type":"string","description":"Arweave transaction ID of the sealed qub."},"intent":{"type":["string","null"],"description":"Compose intent (e.g. \"prediction\"), or null when none was selected."},"sealed_at":{"type":"integer","description":"When the qub was sealed (Unix seconds UTC)."},"unlock_at":{"type":["integer","null"],"description":"Scheduled unlock time (Unix seconds UTC), or null when unknown."},"delivery_url":{"type":"string","description":"Full delivery URL including the wrapper-key fragment. Present only when the creator was email-verified, opted into lifecycle emails, and supplied the wrapper key at upload time."}}},"VerifyResponse":{"type":"object","required":["ok","email","tier","qubs_remaining","sealed_history"],"properties":{"ok":{"type":"boolean","const":true},"email":{"type":"string","format":"email"},"tier":{"type":"string","enum":["free","creator","builder"]},"qubs_remaining":{"type":"integer","description":"Free-tier qub credits remaining for this identity."},"sealed_history":{"type":"array","description":"Per-identity sealed-history index (capped server-side). The client merges this into its local IndexedDB cache.","items":{"$ref":"#/components/schemas/SealedHistoryEntry"}},"attestation_created":{"type":"boolean","description":"Combined verify+sign outcome. Only present when the request included `pubkey_b64url` + `signature_b64url`. `true` = attestation was written (fingerprint now bound to the verified email); `false` = signature was invalid, key size wrong, or the fingerprint was already bound to a different email (the client should surface the conflict and fall back to the standalone attestation flow). Absent = no attestation was attempted (viewer-only sign-in)."},"pact_binding":{"type":"boolean","description":"Pact email-binding outcome. Only present when the originating magic-link request carried a `staging_id`. `true` = a 15-minute `pact-email-verified` marker was written for the staged pact and the verified email matches the counterparty contact recorded on the staging record (the cosign endpoint will accept this Party B). `false` = staging id was unknown, expired, or the verified email did not match. Absent = no pact context was on the magic-link token."},"pact_staging_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"The staged pact's id, echoed back so the client can route the user to `/p/<staging_id>` to complete co-sign. Only present when `pact_binding` is true."},"pact_expected_email":{"type":"string","format":"email","description":"The counterparty email recorded on the staging record. Only present when the magic-link request carried a `staging_id` (returned even on `pact_binding: false` so the client can render an actionable mismatch message)."},"pact_initiator_name":{"type":"string","description":"Display name of the pact's initiator (Party A) — used by the staging UI to greet Party B with context. Only present when `pact_binding` is true."}}},"IdentityResponse":{"type":"object","required":["ok","email","tier","qubs_remaining","sealed_history","locale"],"properties":{"ok":{"type":"boolean","const":true},"email":{"type":"string","format":"email"},"tier":{"type":"string","enum":["free","creator","builder"]},"qubs_remaining":{"type":"integer"},"sealed_history":{"type":"array","items":{"$ref":"#/components/schemas/SealedHistoryEntry"}},"locale":{"type":"string","description":"BCP 47 locale stored on the identity record. Defaults to `en` for legacy identities without one."}}},"IdentityNotLinked":{"type":"object","required":["ok","code"],"description":"Returned when the device_id is well-formed but no identity is linked to it yet — the common cold-load case for unverified visitors. The endpoint returns 200 (not 404) so this expected, frequent state doesn't show up as a red error in the browser DevTools console.","properties":{"ok":{"type":"boolean","const":false},"code":{"type":"string","const":"NOT_LINKED"}}},"CreatorQubsResponse":{"type":"object","required":["oldest_sealed","next_reveal","aggregate_watchers","qub_count","truncated"],"description":"F12 — identity-scoped summary of a creator's sealed qubs.","properties":{"oldest_sealed":{"description":"The earliest entry in the creator's sealed_history, or null if they have sealed nothing.","oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CreatorQubsEntry"}]},"next_reveal":{"description":"The next qub whose unlock_at is in the future, or null when every qub has already revealed.","oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CreatorQubsNextReveal"}]},"aggregate_watchers":{"type":"integer","description":"Sum of `watch:<tx_id>` across every qub in the creator's history."},"qub_count":{"type":"integer"},"truncated":{"type":"boolean","description":"True when the history exceeded 200 entries and the aggregate was computed over the most-recent-200 slice."}}},"CreatorQubsEntry":{"type":"object","required":["tx_id","sealed_at","unlock_at","intent"],"properties":{"tx_id":{"type":"string"},"sealed_at":{"type":"integer","description":"Unix seconds UTC."},"unlock_at":{"type":["integer","null"]},"intent":{"type":["string","null"]}}},"CreatorQubsNextReveal":{"type":"object","required":["tx_id","sealed_at","unlock_at","intent","days_remaining"],"properties":{"tx_id":{"type":"string"},"sealed_at":{"type":"integer"},"unlock_at":{"type":"integer"},"intent":{"type":["string","null"]},"days_remaining":{"type":"integer"}}},"SharesMedianResponse":{"type":"object","required":["n","p50","p95","mean","max","truncated"],"description":"M2 — distribution-wide shares-per-qub percentiles. Caught by the openapi-coverage-smoke test: handler emits n/mean/max in addition to the percentile fields, and the count field on the prior schema was actually returned as `n`.","properties":{"n":{"type":"integer","description":"Sample size (number of qubs included in the distribution)."},"p50":{"type":"number"},"p95":{"type":"number"},"mean":{"type":"number"},"max":{"type":"number"},"truncated":{"type":"boolean","description":"True if the underlying KV list was truncated and the percentiles are computed over a sample, not the full distribution."}}},"ActivationCohortResponse":{"type":"object","required":["cohort_size","activated_count","activation_rate","lookback_days","activation_window_days","truncated"],"description":"M8 — first-qub → second-qub activation cohort metric. DISTRIBUTION-STRATEGY.md §10.3.","properties":{"cohort_size":{"type":"integer"},"activated_count":{"type":"integer"},"activation_rate":{"type":"number","description":"`activated_count / cohort_size`, rounded to 4 dp. Zero for an empty cohort."},"lookback_days":{"type":"integer"},"activation_window_days":{"type":"integer"},"truncated":{"type":"boolean"}}},"KillCriterionEntry":{"type":"object","required":["tx_id","sealed_at","unlock_at","watch_count","creator_email","intent"],"properties":{"tx_id":{"type":"string"},"sealed_at":{"type":"integer"},"unlock_at":{"type":["integer","null"]},"watch_count":{"type":"integer"},"creator_email":{"type":"string","format":"email"},"intent":{"type":["string","null"]}}},"KillCriterionResponse":{"type":"object","required":["results","params","truncated"],"description":"M10 — qubs past the seeding window still below the watcher floor. DISTRIBUTION-STRATEGY.md §5.4.","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/KillCriterionEntry"}},"params":{"type":"object","required":["sealed_after_hours","watching_threshold","max_age_days","now"],"properties":{"sealed_after_hours":{"type":"integer"},"watching_threshold":{"type":"integer"},"max_age_days":{"type":"integer"},"now":{"type":"integer"}}},"truncated":{"type":"boolean"}}},"ApiKeyProvisionedResponse":{"type":"object","required":["key"],"description":"One-time retrieval response for a freshly provisioned API key. The KV entry is deleted after a successful read.","properties":{"key":{"type":["string","null"],"description":"The raw `qub_sk_...` API key, or `null` if the key has not been provisioned yet (Stripe webhook still pending) or has already been retrieved."}}},"ApiKeyRotateResponse":{"type":"object","required":["key"],"properties":{"key":{"type":"string","description":"The new raw `qub_sk_...` API key. The old key continues to work via the grace-period mapping until the next rotation."}}},"MyApiKeyEntry":{"type":"object","description":"Customer-safe projection of an API key, surfaced by the `/developer/keys` dashboard. NEVER includes the raw key, the Stripe customer / subscription IDs, or any operator-side metadata. Exposes the full SHA-256 hash as `id` (used to address PATCH calls — knowing the hash without the raw secret doesn't help an attacker).","required":["id","key_prefix","label","tier","created_at","last_used_at","qubs_remaining","qubs_total","seals_used_this_period","reads_used_this_period","max_monthly_charge_usd","ip_allowlist","scope","disabled"],"properties":{"id":{"type":"string","description":"Full SHA-256 hash of the raw key. Used to route PATCH calls. Not a credential."},"key_prefix":{"type":"string","description":"Last 8 hex chars of `id`. Cosmetic display identifier — the dashboard renders `…<key_prefix>`."},"label":{"type":["string","null"],"description":"Customer-set human-readable name (≤64 NFC code points). Null until set."},"tier":{"type":"string","enum":["free","builder","enterprise"]},"created_at":{"type":"integer"},"last_used_at":{"type":["integer","null"]},"qubs_remaining":{"type":"integer","description":"Base allowance left this period. Allowed to go negative for Builder (overage indicator)."},"qubs_total":{"type":"integer"},"seals_used_this_period":{"type":"integer"},"reads_used_this_period":{"type":"integer"},"max_monthly_charge_usd":{"type":["number","null"],"description":"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":{"type":"array","items":{"type":"string"},"description":"Bare IPv4 / IPv6 literals or CIDR ranges. Empty = unrestricted. Up to 16 entries × 45 chars."},"scope":{"type":"array","items":{"type":"string"},"description":"Subset of `[\"upload\",\"seal\",\"read\",\"webhooks\"]`."},"disabled":{"type":"boolean"}}},"MyApiKeysResponse":{"type":"object","description":"Result of `GET /api/v1/api-keys/mine`. Either an `ok: true` envelope with the keys belonging to the verified email, or an `ok: false, code: \"NOT_LINKED\"` shape that prompts the dashboard to render the magic-link CTA.","oneOf":[{"type":"object","required":["ok","email","keys"],"properties":{"ok":{"type":"boolean","enum":[true]},"email":{"type":"string"},"keys":{"type":"array","items":{"$ref":"#/components/schemas/MyApiKeyEntry"}}}},{"type":"object","required":["ok","code"],"properties":{"ok":{"type":"boolean","enum":[false]},"code":{"type":"string","enum":["NOT_LINKED"]}}}]},"ApiKeyPatchRequest":{"type":"object","description":"Partial update to an API key. Field absence = no change. Null where allowed = explicit clear. Validated server-side: `label` is ≤64 NFC code points and must not contain control characters; `max_monthly_charge_usd` is a non-negative finite number or null; each `ip_allowlist` entry is a bare IPv4/IPv6 literal or a CIDR range (rejected if `parseAllowlistEntry` returns null); `scope` must be a non-empty subset of `[\"upload\",\"seal\",\"read\",\"webhooks\"]`.","properties":{"label":{"type":["string","null"]},"max_monthly_charge_usd":{"type":["number","null"]},"ip_allowlist":{"type":["array","null"],"items":{"type":"string"},"maxItems":16},"scope":{"type":"array","items":{"type":"string","enum":["upload","seal","read","webhooks"]},"minItems":1},"disabled":{"type":"boolean"}}},"AdminKeyEntry":{"type":"object","required":["key_hash","client_id","tier","qubs_remaining","qubs_total","reads_today","max_reads_per_day","webhook_limit","disabled","created_at"],"properties":{"key_hash":{"type":"string"},"client_id":{"type":"string"},"tier":{"type":"string","enum":["free","builder","enterprise"]},"qubs_remaining":{"type":"integer"},"qubs_total":{"type":"integer"},"reads_today":{"type":"integer"},"max_reads_per_day":{"type":"integer"},"webhook_limit":{"type":"integer"},"disabled":{"type":"boolean"},"created_at":{"type":"integer"},"last_used_at":{"type":"integer"}}},"AdminKeyActionResponse":{"type":"object","required":["key_hash","client_id"],"properties":{"key_hash":{"type":"string"},"disabled":{"type":"boolean","description":"Present on `disable` / `enable` responses. Reflects the new value of the `disabled` flag."},"client_id":{"type":"string"},"max_monthly_charge_usd":{"type":["number","null"],"description":"Present on `cap` responses. Reflects the new monthly charge ceiling in USD; `null` means no cap is set."}}},"AdminKeyCapRequest":{"type":"object","required":["max_monthly_charge_usd"],"properties":{"max_monthly_charge_usd":{"type":["number","null"],"description":"New monthly charge ceiling in USD. Must be a non-negative number, or `null` to clear an existing cap. When set, the seal and read paths reject with 402 `MONTHLY_CAP_REACHED` once projected end-of-period overage would exceed this value."}}},"AttestationEntry":{"type":"object","required":["type","verified_at"],"description":"One attestation bound to a keypair fingerprint. IDENTITY.md §3-4.","properties":{"type":{"type":"string","enum":["email","x","github","google","passkey","handle"],"description":"Attestation type identifier. Phase 2 writes `email` (user-supplied) and `handle` (auto-allocated alongside email). Other values are Phase 3 additions."},"value":{"type":"string","description":"Human-readable value: email address or handle. Absent for passkey entries."},"platform_id":{"type":"string","description":"Stable platform ID for social attestations (Phase 3 only)."},"verified_at":{"type":"integer","description":"Unix seconds UTC of the verification that produced this entry."}}},"AttestationRecord":{"type":"object","required":["attestations","pubkey_alg","created_at","updated_at"],"description":"Public attestation record keyed by pubkey fingerprint. Returned by the lookup and verify endpoints.","properties":{"attestations":{"type":"array","items":{"$ref":"#/components/schemas/AttestationEntry"}},"pubkey_alg":{"type":"integer","description":"`sig_alg` registry value (PROTOCOL.md §9.2). Phase 2 always `1` (ML-DSA-65)."},"created_at":{"type":"integer"},"updated_at":{"type":"integer"}}},"AttestationEmailBeginRequest":{"type":"object","required":["fingerprint","email","pubkey","pubkey_alg"],"properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"SHA3-256 of the author public key, lower-case hex."},"email":{"type":"string","format":"email"},"pubkey":{"type":"string","description":"Base64url-encoded ML-DSA-65 public key (1,952 bytes decoded)."},"pubkey_alg":{"type":"integer","enum":[1],"description":"`sig_alg` registry value. Phase 2 only supports `1` (ML-DSA-65)."},"locale":{"type":"string","description":"BCP 47 locale tag for the verification-code email body."}}},"AttestationEmailBeginResponse":{"type":"object","required":["expires_at"],"properties":{"expires_at":{"type":"integer","description":"Unix seconds UTC at which the pending verification code expires (10 minutes after issue)."}}},"AttestationEmailVerifyRequest":{"type":"object","required":["fingerprint","code","timestamp","signature"],"properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$"},"code":{"type":"string","pattern":"^[0-9]{6}$","description":"6-digit verification code delivered by email."},"timestamp":{"type":"integer","description":"Unix seconds UTC; must be within 120 seconds of Worker receipt."},"signature":{"type":"string","description":"Base64url-encoded ML-DSA-65 signature (3,309 bytes decoded) over the IDENTITY.md §3.2 challenge."}}},"AttestationEmailDeleteRequest":{"type":"object","required":["fingerprint","pubkey","timestamp","signature"],"properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$"},"pubkey":{"type":"string","description":"Base64url-encoded ML-DSA-65 public key (1,952 bytes decoded). Must hash to the claimed fingerprint."},"timestamp":{"type":"integer","description":"Unix seconds UTC; must be within 120 seconds of Worker receipt."},"signature":{"type":"string","description":"Base64url-encoded ML-DSA-65 signature over the IDENTITY.md §6.2 delete challenge."}}},"AttestationEmailDeleteResponse":{"type":"object","required":["deleted"],"properties":{"deleted":{"type":"boolean","const":true}}},"HandleLookupResponse":{"type":"object","required":["fingerprint","attestations"],"description":"Response from `GET /api/v1/handle/:handle`. The attestation record is the same public projection returned by the forward lookup endpoint.","properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Owning pubkey fingerprint."},"attestations":{"$ref":"#/components/schemas/AttestationRecord"}}},"HandleRenameRequest":{"type":"object","required":["fingerprint","current_handle","new_handle","pubkey","pubkey_alg","timestamp","signature"],"properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$"},"current_handle":{"type":"string","pattern":"^[a-z][a-z0-9_]{2,19}$","description":"Normalised current handle (must match the record's handle entry)."},"new_handle":{"type":"string","pattern":"^[a-z][a-z0-9_]{2,19}$","description":"Normalised target handle."},"pubkey":{"type":"string","description":"Base64url-encoded ML-DSA-65 public key (1,952 bytes decoded). Must hash to `fingerprint`."},"pubkey_alg":{"type":"integer","const":1,"description":"`sig_alg` registry value. Phase 2 always `1` (ML-DSA-65)."},"timestamp":{"type":"integer","description":"Unix seconds UTC; must be within 120 seconds of Worker receipt."},"signature":{"type":"string","description":"Base64url-encoded ML-DSA-65 signature over `QUB_IDENTITY_HANDLE_RENAME_V1 || fp || utf8(current_handle) || utf8(new_handle) || timestamp_be`."}}},"HandleDeleteRequest":{"type":"object","required":["fingerprint","pubkey","timestamp","signature"],"properties":{"fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$"},"pubkey":{"type":"string","description":"Base64url-encoded ML-DSA-65 public key. Must hash to `fingerprint`."},"timestamp":{"type":"integer","description":"Unix seconds UTC; must be within 120 seconds of Worker receipt."},"signature":{"type":"string","description":"Base64url-encoded ML-DSA-65 signature over `QUB_IDENTITY_HANDLE_DELETE_V1 || fp || timestamp_be` (69 bytes)."}}}}},"paths":{"/api/v1/upload-auth":{"post":{"x-audience":"external","operationId":"uploadAuth","summary":"Pre-check upload eligibility","description":"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.","tags":["Upload"],"security":[{"turnstile":[]},{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadAuthRequest"}}}},"responses":{"200":{"description":"Eligibility check result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadAuthResponse"}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/upload":{"post":{"x-audience":"external","operationId":"upload","summary":"Upload sealed CBOR to permanent storage","description":"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.","tags":["Upload"],"security":[{"turnstile":[]},{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}}},"responses":{"200":{"description":"Upload successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadResponse"}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required — entitlement exhausted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Payload too large for the device's entitlement tier.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream permanent-storage failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/seal":{"post":{"x-audience":"external","operationId":"seal","summary":"Server-side seal (agent-only)","description":"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).\n\n**Rate-limit / ceiling response codes** (429):\n\n- `RATE_LIMIT_API_KEY` — per-API-key in-window rate limit (default 60/min on Builder).\n- `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.\n- `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.\n\n**Circuit-breaker response codes** (503):\n\n- `CIRCUIT_BREAKER` — global Arweave daily ceiling (5000/day on prod). Seals refused operator-wide until the cap is lifted.\n\nSupports 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.","tags":["qubs"],"security":[{"apiKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"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).","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,255}$","maxLength":255}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SealRequest"}}}},"responses":{"200":{"description":"qub sealed and uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SealResponse"}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"API key required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"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).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error during sealing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream permanent-storage failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Storage-spend circuit breaker tripped (`code: CIRCUIT_BREAKER`); seals are temporarily refused operator-wide until the cap is lifted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}":{"get":{"x-audience":"external","operationId":"getQub","summary":"Read wrapped qub bytes","description":"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.\n\n**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.\n\n**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.\n\n**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.","tags":["qubs"],"security":[{},{"apiKey":[]}],"parameters":[{"name":"tx_id","in":"path","required":true,"description":"Storage transaction ID.","schema":{"type":"string"}}],"responses":{"200":{"description":"Wrapped qub bytes plus minimal metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QubReadResponse"}}}},"404":{"description":"qub not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"451":{"description":"Content denied by moderation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream failure fetching from permanent storage or drand.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/entitlements":{"get":{"x-audience":"external","operationId":"getEntitlements","summary":"Check device entitlement tier","description":"Returns the current entitlement tier and remaining qub count for a device.","tags":["Payment"],"parameters":[{"name":"device_id","in":"query","required":true,"description":"Device identifier.","schema":{"type":"string"}}],"responses":{"200":{"description":"Entitlement info.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitlementsResponse"}}}},"400":{"description":"Missing device_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/webhooks":{"post":{"x-audience":"external","operationId":"createWebhook","summary":"Register a webhook","description":"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).","tags":["Webhooks"],"security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreateRequest"}}}},"responses":{"201":{"description":"Webhook registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreateResponse"}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"API key required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Per-key webhook limit reached (`webhook_limit` on the key record).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Webhook URL ownership verification failed (no echo of the verification token).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key registration rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"x-audience":"external","operationId":"listWebhooks","summary":"List webhooks","description":"List all webhooks registered by the authenticated API key.","tags":["Webhooks"],"security":[{"apiKey":[]}],"responses":{"200":{"description":"List of webhooks.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}},"401":{"description":"API key required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/webhooks/{id}":{"delete":{"x-audience":"external","operationId":"deleteWebhook","summary":"Remove a webhook","description":"Delete a previously registered webhook.","tags":["Webhooks"],"security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook ID.","schema":{"type":"string"}}],"responses":{"204":{"description":"Webhook deleted (no response body)."},"401":{"description":"API key required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Webhook not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/openapi.json":{"get":{"x-audience":"external","operationId":"getOpenApiSpec","summary":"OpenAPI specification","description":"Returns this OpenAPI 3.1.0 specification document.","tags":["Meta"],"responses":{"200":{"description":"OpenAPI specification.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/v1/qub/{tx_id}/meta":{"get":{"x-audience":"external","operationId":"getQubMeta","summary":"Lightweight qub metadata (no decrypt)","description":"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.","tags":["qubs"],"parameters":[{"name":"tx_id","in":"path","required":true,"description":"Storage transaction ID.","schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QubMetaResponse"}}}},"400":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/watch":{"post":{"x-audience":"external","operationId":"watchQub","summary":"Increment the pre-reveal watch counter","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"New count after this increment (or 0 if rate-limited).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EngagementCountResponse"}}}},"400":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/verdict-watch":{"post":{"x-audience":"external","operationId":"verdictWatchQub","summary":"Commit to watching for a verdict (per-device dedupe)","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["device_id"],"properties":{"device_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"32-char lowercase hex device identifier. Same `device_id` shape used by /upload and /checkout."}}}}}},"responses":{"200":{"description":"New count after this commit (rate-limited responses return `{ count: 0, watching: false }`).","content":{"application/json":{"schema":{"type":"object","required":["count","watching"],"properties":{"count":{"type":"integer","minimum":0},"watching":{"type":"boolean","description":"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'."}}}}}},"400":{"description":"Invalid tx_id, body, or device_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"x-audience":"external","operationId":"getVerdictWatchCount","summary":"Read the current verdict-watcher count","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"Current exact count (cosmetic rounding for display happens client-side).","content":{"application/json":{"schema":{"type":"object","required":["count"],"properties":{"count":{"type":"integer","minimum":0}}}}}},"400":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/view":{"post":{"x-audience":"external","operationId":"viewQub","summary":"Increment the post-reveal view counter","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"New count after this increment (or 0 if rate-limited).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EngagementCountResponse"}}}},"400":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/notify":{"post":{"x-audience":"external","operationId":"notifySubscribe","summary":"Subscribe an email to a qub's reveal","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotifySubscribeRequest"}}}},"responses":{"200":{"description":"Subscribed (or cap reached — the cap is silent so the user doesn't see a degraded experience).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"Invalid tx_id, email, or unlock_at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/identity/attestation/{fingerprint}":{"get":{"x-audience":"external","operationId":"getAttestation","summary":"Look up public attestations for a pubkey fingerprint","description":"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.","tags":["Identity"],"parameters":[{"name":"fingerprint","in":"path","required":true,"description":"64-char lower-case hex SHA3-256 of the author public key.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}}],"responses":{"200":{"description":"Attestation record.","headers":{"Cache-Control":{"schema":{"type":"string","example":"public, max-age=300"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationRecord"}}}},"400":{"description":"Malformed fingerprint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No attestations for this fingerprint (Layer 0 / not attested).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/handle/{handle}":{"get":{"x-audience":"external","operationId":"lookupHandle","summary":"Resolve a qub handle to its owning fingerprint and attestation record","description":"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.","tags":["Identity"],"parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z][a-z0-9_]{2,19}$"},"description":"Normalised handle (no leading `@`)."}],"responses":{"200":{"description":"Handle is claimed; body contains the owning fingerprint and the projected attestation record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HandleLookupResponse"}}},"headers":{"Cache-Control":{"schema":{"type":"string","example":"public, max-age=60"}}}},"400":{"description":"`invalid_handle` — the handle fails normalisation (charset, length, or reserved).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"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).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Handle was recently released and is in cooldown. `released_shape` is `\"auto\"` (1h cooldown) or `\"user\"` (30d cooldown).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["handle_released"]},"released_shape":{"type":"string","enum":["auto","user"]},"released_at":{"type":"integer","nullable":true}}}}}}}}},"/api/v1/api-keys/rotate":{"post":{"x-audience":"external","operationId":"rotateApiKey","summary":"Rotate the current API key","description":"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.","tags":["API Keys"],"security":[{"apiKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"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).","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,255}$","maxLength":255}}],"responses":{"200":{"description":"Rotation succeeded; the response carries the new raw key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotateResponse"}}}},"401":{"description":"API key required or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Disabled key or IP not allowed. Returns `{ code: 'API_KEY_DISABLED' | 'IP_NOT_ALLOWED' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A rotation is already in progress for this key (30-second mutex held by another in-flight rotation).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/api-keys/mine":{"get":{"x-audience":"external","operationId":"listMyApiKeys","summary":"List the API keys owned by the magic-link-verified caller","description":"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.","tags":["API Keys"],"parameters":[{"name":"device_id","in":"query","required":true,"description":"The caller's local device ID (32-char lowercase hex), as written to IndexedDB by the WASM client.","schema":{"type":"string","pattern":"^[a-f0-9]{32}$"}}],"responses":{"200":{"description":"Either the linked key list or a NOT_LINKED probe response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyApiKeysResponse"}}}},"400":{"description":"Missing or malformed device_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/api-keys/{keyHash}":{"patch":{"x-audience":"external","operationId":"updateMyApiKey","summary":"Update label / cap / IP allowlist / scope / disabled on a key the caller owns","description":"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.","tags":["API Keys"],"parameters":[{"name":"keyHash","in":"path","required":true,"description":"Full SHA-256 hash of the raw key (64 lowercase hex chars). Sourced from the `id` field of `MyApiKeyEntry`.","schema":{"type":"string","pattern":"^[a-f0-9]{64}$"}},{"name":"device_id","in":"query","required":true,"description":"The caller's local device ID. Same shape as for `GET /api/v1/api-keys/mine`.","schema":{"type":"string","pattern":"^[a-f0-9]{32}$"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyPatchRequest"}}}},"responses":{"200":{"description":"Patch applied. Response carries the updated `MyApiKeyEntry`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyApiKeyEntry"}}}},"400":{"description":"Validation failure on one of the body fields, or a malformed key hash / device_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"The device hasn't completed magic-link sign-in yet. `{ ok: false, code: \"NOT_LINKED\" }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"The caller doesn't own this key (cross-tenant access) or the key doesn't exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/bytes":{"get":{"x-audience":"external","operationId":"getQubBytes","summary":"Raw sealed CBOR bytes","description":"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.","tags":["qubs"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"Raw sealed CBOR bytes.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"qub not found in permanent storage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (120 / IP / minute).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/react":{"post":{"x-audience":"external","operationId":"reactToQub","summary":"Record a reaction on a revealed qub","description":"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.","tags":["Engagement"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reaction"],"properties":{"reaction":{"type":"string","enum":["called_it","wrong"]}}}}}},"responses":{"200":{"description":"Updated reaction tallies.","content":{"application/json":{"schema":{"type":"object","required":["called_it","wrong","total"],"properties":{"called_it":{"type":"integer"},"wrong":{"type":"integer"},"total":{"type":"integer"}}}}}},"400":{"description":"Invalid reaction or tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/pact/stage":{"post":{"x-audience":"external","operationId":"stagePact","summary":"Stage a signed pact envelope for co-signing","description":"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.","tags":["Pact"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["envelope_cbor","turnstile_token","device_id"],"properties":{"envelope_cbor":{"type":"string","description":"Base64-encoded canonical CBOR QubEnvelope with content_type=0x03, sig_alg=0x01, author_pubkey and author_signature present, cosigner fields absent."},"turnstile_token":{"type":"string"},"device_id":{"type":"string"},"locale":{"type":"string","description":"Optional BCP 47 locale used for the review invite email."}}}}}},"responses":{"201":{"description":"Pact staged.","content":{"application/json":{"schema":{"type":"object","required":["staging_id","staging_url","email_sent"],"properties":{"staging_id":{"type":"string"},"staging_url":{"type":"string"},"email_sent":{"type":"boolean"}}}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited."}}}},"/api/v1/pact/stage/{staging_id}":{"get":{"x-audience":"external","operationId":"getPactStage","summary":"Review a staged pact","description":"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`.","tags":["Pact"],"parameters":[{"name":"staging_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Staged pact details (or sealed redirect).","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Staging id not found or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"x-audience":"external","operationId":"retractPactStage","summary":"Retract a staged pact before co-signing","description":"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)`.","tags":["Pact"],"parameters":[{"name":"staging_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["retract_signature","turnstile_token"],"properties":{"retract_signature":{"type":"string","description":"Base64 retract signature from Party A's signing key."},"turnstile_token":{"type":"string"}}}}}},"responses":{"200":{"description":"Retracted."},"400":{"description":"Invalid request or signature.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Staging id not found or already sealed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/pact/cosign/{staging_id}":{"post":{"x-audience":"external","operationId":"cosignPact","summary":"Co-sign a staged pact and seal to permanent storage","description":"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.","tags":["Pact"],"parameters":[{"name":"staging_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["cosigner_pubkey","cosigner_signature","turnstile_token"],"properties":{"cosigner_pubkey":{"type":"string"},"cosigner_signature":{"type":"string"},"turnstile_token":{"type":"string"}}}}}},"responses":{"200":{"description":"Pact sealed.","content":{"application/json":{"schema":{"type":"object","required":["tx_id","delivery_url"],"properties":{"tx_id":{"type":"string"},"delivery_url":{"type":"string"}}}}}},"400":{"description":"Invalid signature, pubkey, or email-binding marker missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Staging id not found or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upload to permanent storage failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/pact/invite/accept":{"post":{"x-audience":"external","operationId":"acceptPactInvite","summary":"One-click accept of a pact invite from the email link","description":"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.","tags":["Pact"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["staging_id","token"],"properties":{"staging_id":{"type":"string","pattern":"^[a-f0-9]{32}$","description":"Staging ID from the URL path."},"token":{"type":"string","description":"HMAC-signed invite token from the `?t=` query param of the email link. Format: `base64url(payload).base64url(sig)`."},"cosigner_fingerprint":{"type":"string","pattern":"^[a-f0-9]{64}$","description":"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)."},"pubkey_b64url":{"type":"string","description":"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":{"type":"string","description":"Optional — base64url-encoded ML-DSA-65 signature (3,309 bytes) over `\"QUB_IDENTITY_AUTH_V1\" || utf8(token)`. Verified server-side with `pubkey_b64url`."}}}}}},"responses":{"200":{"description":"Marker written. The cosign endpoint will accept Party B's signature without a separate magic-link verify.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true},"attestation_created":{"type":"boolean","description":"`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":{"type":"string","format":"email","description":"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`."}}}}}},"400":{"description":"Invalid token (`TOKEN_INVALID`), expired (`TOKEN_EXPIRED`), staging_id mismatch, malformed body, or invalid fingerprint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"The staging record was retracted or has expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Pact has already been cosigned (`already_sealed`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Token already used (`TOKEN_USED`). Single-use enforcement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Auth not configured (`AUTH_DISABLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/pacts/mine":{"get":{"x-audience":"external","operationId":"listMyPacts","summary":"Cross-device list of staged pacts authored by the device's linked email","description":"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.","tags":["Pact"],"parameters":[{"name":"device_id","in":"query","required":true,"description":"32-hex device identifier.","schema":{"type":"string","pattern":"^[a-f0-9]{32}$"}}],"responses":{"200":{"description":"Most-recent-first list of staged pacts.","content":{"application/json":{"schema":{"type":"object","required":["pacts"],"properties":{"pacts":{"type":"array","items":{"type":"object","required":["staging_id","title","party_b_label","created_at","expires_at","status"],"properties":{"staging_id":{"type":"string"},"title":{"type":"string"},"party_b_label":{"type":"string"},"party_b_contact":{"type":["string","null"]},"created_at":{"type":"integer"},"expires_at":{"type":"integer"},"unlock_at":{"type":"integer","description":"Unix seconds when the pact body becomes decryptable (mirrors `envelope.unlock_at`). Optional — entries written before this field was added omit it; clients should default missing values to 0."},"status":{"type":"string","enum":["awaiting_cosign","sealed","retracted"]},"tx_id":{"type":"string"}}}}}}}}},"400":{"description":"Missing or malformed `device_id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Device not linked. Returns `{ code: 'NOT_LINKED' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/oembed":{"get":{"x-audience":"external","operationId":"getOembed","summary":"oEmbed discovery","description":"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.","tags":["Embed"],"parameters":[{"name":"url","in":"query","required":true,"description":"Canonical qub viewer URL (e.g. `https://qub.social/c/<tx_id>`).","schema":{"type":"string","format":"uri"}},{"name":"format","in":"query","required":false,"description":"Response format. Only `json` is supported.","schema":{"type":"string","enum":["json"]}},{"name":"maxwidth","in":"query","required":false,"schema":{"type":"integer"}},{"name":"maxheight","in":"query","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"oEmbed JSON.","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"URL does not match a known qub.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/og/{tx_id}.png":{"get":{"x-audience":"external","operationId":"getOgImage","summary":"Dynamic Open Graph image","description":"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.","tags":["Viewer"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"SVG OG card (1200×630 viewport).","content":{"image/svg+xml":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Unknown tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}