{"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 messages 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 Arweave"},{"name":"Engagement","description":"Watch / view counters and notify-me subscriptions"},{"name":"Auth","description":"Magic-link email verification and identity recovery"},{"name":"API Keys","description":"API key checkout, retrieval, and rotation"},{"name":"Payment","description":"Stripe checkout and entitlements"},{"name":"Moderation","description":"Content reporting and denylist"},{"name":"Admin","description":"Admin-only endpoints"},{"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_...`."},"adminSecret":{"type":"http","scheme":"bearer","description":"Admin secret for privileged endpoints. Pass in the Authorization header as `Bearer <secret>`."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"code":{"type":"string"}}},"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":["sealed_cbor_base64","device_id","content_size"],"properties":{"sealed_cbor_base64":{"type":"string","description":"Base64-encoded sealed CBOR payload."},"device_id":{"type":"string"},"turnstile_token":{"type":"string","description":"Cloudflare Turnstile token. Optional when using API key auth."},"content_size":{"type":"integer"}}},"UploadResponse":{"type":"object","required":["tx_id","delivery_url"],"properties":{"tx_id":{"type":"string","description":"Arweave transaction ID."},"delivery_url":{"type":"string","format":"uri"},"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."}}},"SealRequest":{"type":"object","required":["body","unlock_at"],"properties":{"body":{"type":"string","description":"Plaintext message body to seal."},"unlock_at":{"type":"number","description":"Unix timestamp (seconds) for when the qub should unlock."},"sender_label":{"type":"string","description":"Optional display name for the sender."}}},"SealResponse":{"type":"object","required":["tx_id","delivery_url","qub_id","unlock_at","drand_round"],"properties":{"tx_id":{"type":"string"},"delivery_url":{"type":"string","format":"uri"},"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>` 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."}}},"QubLockedResponse":{"type":"object","required":["status","tx_id","qub_id","unlock_at","time_remaining_seconds","drand_round","visibility"],"properties":{"status":{"type":"string","const":"locked"},"tx_id":{"type":"string"},"qub_id":{"type":"string"},"unlock_at":{"type":"number"},"time_remaining_seconds":{"type":"number"},"drand_round":{"type":"integer"},"visibility":{"type":"string"}}},"QubUnlockedResponse":{"type":"object","required":["status","tx_id","qub_id","unlock_at","created_at","body","content_type","body_hash","body_hash_verified","qub_id_verified"],"properties":{"status":{"type":"string","const":"unlocked"},"tx_id":{"type":"string"},"qub_id":{"type":"string"},"unlock_at":{"type":"number"},"created_at":{"type":"number"},"body":{"type":"string"},"sender_label":{"type":"string"},"content_type":{"type":"string"},"body_hash":{"type":"string"},"body_hash_verified":{"type":"boolean"},"qub_id_verified":{"type":"boolean"}}},"EntitlementsResponse":{"type":"object","required":["tier","qubs_remaining"],"properties":{"tier":{"type":"string"},"qubs_remaining":{"type":"integer"},"purchased_at":{"type":"string","format":"date-time"}}},"CheckoutRequest":{"type":"object","required":["price_id","device_id"],"properties":{"price_id":{"type":"string"},"device_id":{"type":"string"},"email":{"type":"string","format":"email"}}},"CheckoutResponse":{"type":"object","required":["checkout_url"],"properties":{"checkout_url":{"type":"string","format":"uri"}}},"ReportRequest":{"type":"object","required":["tx_id","reason"],"properties":{"tx_id":{"type":"string"},"reason":{"type":"string","enum":["illegal","harassment","personal_info","other"]},"text":{"type":"string"}}},"DenylistRequest":{"type":"object","required":["action","tx_id"],"properties":{"action":{"type":"string","enum":["block","unblock"]},"tx_id":{"type":"string"},"reason":{"type":"string"}}},"WebhookCreateRequest":{"type":"object","required":["tx_id","url","secret"],"properties":{"tx_id":{"type":"string","description":"Arweave 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. Sent to the callback in the `X-Qub-Signature` header. 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. Both fields are optional — absent fields mean the GraphQL fetch failed or the transaction is not yet confirmed in a block.","properties":{"arweave_block_timestamp":{"type":"integer","description":"Unix seconds when the qub's Arweave transaction was first included in a block."},"intent":{"type":"string","description":"Intent tag set at compose time (e.g. `announcement`, `thesis`, `prediction`, `letter`, `secret`).","enum":["announcement","thesis","prediction","letter","secret"]},"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 Arweave 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."}}},"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."}}},"SealedHistoryEntry":{"type":"object","required":["tx_id","qub_id","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"},"qub_id":{"type":"string"},"unlock_at":{"type":"integer"},"created_at":{"type":"integer"}}},"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"}}}},"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."}}},"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":["p50","p95","count","truncated"],"description":"M2 — distribution-wide shares-per-qub percentiles.","properties":{"p50":{"type":"number"},"p95":{"type":"number"},"count":{"type":"integer"},"truncated":{"type":"boolean"}}},"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."}}},"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","disabled","client_id"],"properties":{"key_hash":{"type":"string"},"disabled":{"type":"boolean"},"client_id":{"type":"string"}}},"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"],"description":"Attestation type identifier. Phase 2 writes only `email`."},"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}}}}},"paths":{"/api/v1/upload-auth":{"post":{"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":{"operationId":"upload","summary":"Upload sealed CBOR to Arweave","description":"Uploads a sealed CBOR payload to Arweave via the worker proxy. Returns the Arweave transaction ID and a delivery URL for the viewer.","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 Arweave failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/seal":{"post":{"operationId":"seal","summary":"Server-side seal (agent-only)","description":"Seals a plaintext message server-side using drand timelock encryption and uploads it to Arweave. Requires an API key. Note: the plaintext passes through the server.","tags":["Qubs"],"security":[{"apiKey":[]}],"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"}}}},"500":{"description":"Server error during sealing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream Arweave failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}":{"get":{"operationId":"getQub","summary":"Read qub status and content","description":"Returns the current status of a qub. If locked, returns metadata and countdown. If unlocked, returns the decrypted content. Rate limited to 30 requests per minute per IP. Optional API key auth for higher rate limits.","tags":["Qubs"],"security":[{},{"apiKey":[]}],"parameters":[{"name":"tx_id","in":"path","required":true,"description":"Arweave transaction ID.","schema":{"type":"string"}}],"responses":{"200":{"description":"Qub status and content.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/QubLockedResponse"},{"$ref":"#/components/schemas/QubUnlockedResponse"}],"discriminator":{"propertyName":"status","mapping":{"locked":"#/components/schemas/QubLockedResponse","unlocked":"#/components/schemas/QubUnlockedResponse"}}}}}},"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 Arweave or drand.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/entitlements":{"get":{"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/checkout":{"post":{"operationId":"createCheckout","summary":"Create Stripe checkout session","description":"Creates a Stripe checkout session for purchasing qub entitlements.","tags":["Payment"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutRequest"}}}},"responses":{"200":{"description":"Checkout session created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutResponse"}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Stripe API error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/payment":{"post":{"operationId":"paymentWebhook","summary":"Stripe webhook receiver","description":"Receives Stripe webhook events for payment confirmation. Not intended for direct use by API consumers.","tags":["Payment"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Webhook processed.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid webhook payload or signature.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/report":{"post":{"operationId":"reportContent","summary":"Report content","description":"Submit a content report for moderation review.","tags":["Moderation"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportRequest"}}}},"responses":{"200":{"description":"Report submitted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/denylist-check/{tx_id}":{"get":{"operationId":"checkDenylist","summary":"Check if a transaction is denylisted","description":"Public, lightweight endpoint that checks whether an Arweave transaction ID is on the content denylist. Returns 200 if clear, 404 if denylisted. No authentication required.","tags":["Moderation"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"},"description":"Arweave transaction ID to check."}],"responses":{"200":{"description":"Transaction is not denylisted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true}}}}}},"400":{"description":"Invalid transaction ID format.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Transaction is denylisted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/denylist":{"post":{"operationId":"manageDenylist","summary":"Block or unblock transaction IDs","description":"Admin endpoint to add or remove Arweave transaction IDs from the content denylist.","tags":["Admin"],"security":[{"adminSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DenylistRequest"}}}},"responses":{"200":{"description":"Denylist updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Bad request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/telemetry":{"post":{"operationId":"ingestTelemetry","summary":"Client telemetry ingestion","description":"Receives client-side telemetry events. Always returns 204 regardless of payload validity to avoid blocking the client. The `share_clicked` event accepts `channel` values `x|qr|native|copy|threads|bluesky|pact|embed|embed-copy`. The `embed` channel covers iframe footer-CTA clicks; `embed-copy` covers creator-side snippet copies on qub.social.","tags":["Meta"],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"204":{"description":"Telemetry accepted (always returned)."}}}},"/api/v1/admin/metrics":{"get":{"operationId":"getAdminMetrics","summary":"Admin metrics dashboard data","description":"Returns aggregated metrics for the admin dashboard.","tags":["Admin"],"security":[{"adminSecret":[]}],"parameters":[{"name":"range","in":"query","required":false,"description":"Time range for metrics aggregation.","schema":{"type":"string","enum":["1d","7d","30d","90d"],"default":"7d"}}],"responses":{"200":{"description":"Metrics data.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/webhooks":{"post":{"operationId":"createWebhook","summary":"Register a webhook","description":"Register a webhook URL to be notified when a specific qub unlocks.","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"}}}}}},"get":{"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":{"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."},"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":{"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":{"operationId":"getQubMeta","summary":"Lightweight qub metadata (no decrypt)","description":"Returns the Arweave block timestamp and intent tag for a sealed qub. No tlock decrypt, no Arweave 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":"Arweave 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":{"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}/view":{"post":{"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":{"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/auth/magic-link":{"post":{"operationId":"requestMagicLink","summary":"Issue a magic-link token","description":"Issues an HMAC-signed, single-use magic-link token for `{ email, device_id }`, stores the token hash in KV with a 15-minute TTL, and dispatches the email via SendGrid. Always returns `{ ok: true }` — the user shouldn't be able to distinguish 'email exists' from 'delivery failed'. Rate limited 3 requests per email per hour. Returns 503 if `AUTH_SECRET` is not configured.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MagicLinkRequest"}}}},"responses":{"200":{"description":"Token issued and email queued for delivery.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"Invalid email or device_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (3 per email per hour).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Auth not configured (`AUTH_SECRET` missing). Returns `{ code: 'AUTH_DISABLED' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/verify":{"post":{"operationId":"verifyMagicLink","summary":"Consume a magic-link token","description":"Verifies a magic-link token's HMAC signature, enforces single-use via the KV gate, links the device to the email, and returns the identity state including the per-identity sealed-history index. The HMAC payload carries the locale captured at issue time so future emails go out in the same language.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyRequest"}}}},"responses":{"200":{"description":"Verification succeeded. The device is now linked to the email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyResponse"}}}},"400":{"description":"Token failed verification. Returns `{ code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID' }` and the failure reason in `error` (`expired`/`bad_signature`/`malformed`/`bad_payload`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Token already used (single-use enforcement). Returns `{ code: 'TOKEN_USED' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Auth not configured (`AUTH_SECRET` missing). Returns `{ code: 'AUTH_DISABLED' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/identity":{"get":{"operationId":"getIdentity","summary":"Read-only identity lookup for a device","description":"Returns the linked-identity record for a device, or 404 if the device is not currently linked to any email. Used by the post-purchase success page to fetch the auto-linked identity (written server-side by the Stripe webhook) without a magic-link round-trip. Read-only and stateless — the device_id is a 128-bit local identifier, not a security token.","tags":["Auth"],"parameters":[{"name":"device_id","in":"query","required":true,"description":"32-hex device identifier.","schema":{"type":"string","pattern":"^[a-f0-9]{32}$"}}],"responses":{"200":{"description":"Linked identity for this device.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdentityResponse"}}}},"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"}}}}}}},"/api/v1/creator/qubs":{"get":{"operationId":"getCreatorQubs","summary":"Identity-scoped aggregate of a creator's sealed qubs","description":"F12 / DISTRIBUTION-STRATEGY.md §12.3. Returns the oldest sealed qub, the next upcoming reveal, the sum of watch counters across every qub the creator has sealed, and the total qub count — in a single round-trip. Used by the `/identity` retention surface and the anniversary reminder UX. Same device-id auth shape as `/api/v1/identity` — the device_id is the capability; no bearer token. Aggregation capped at the 200 most recent seals; `truncated: true` signals the cap fired (rare in practice).","tags":["Auth"],"parameters":[{"name":"device_id","in":"query","required":true,"description":"32-hex device identifier.","schema":{"type":"string","pattern":"^[a-f0-9]{32}$"}}],"responses":{"200":{"description":"Creator summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatorQubsResponse"}}}},"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"}}}}}}},"/api/v1/admin/kpis/shares-median":{"get":{"operationId":"getSharesMedian","summary":"Admin — shares-per-qub p50/p95","description":"M2 / DISTRIBUTION-STRATEGY.md §10.1. Computes the p50 and p95 of `shares:<tx_id>` counters across all qubs. Scan is capped at 10k qubs; `truncated` flips when the cap fires.","tags":["Admin"],"security":[{"adminSecret":[]}],"responses":{"200":{"description":"Shares-per-qub percentiles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharesMedianResponse"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/admin/kpis/activation-cohort":{"get":{"operationId":"getActivationCohort","summary":"Admin — first-qub to second-qub activation rate","description":"M8 / DISTRIBUTION-STRATEGY.md §10.3. Walks `identity:<email>` records in the ENTITLEMENTS KV, computes what percentage of new creators seal a second qub within the activation window. Cohort members whose first seal is too fresh to have observed the activation window are excluded so the rate doesn't deflate as the cohort matures.","tags":["Admin"],"security":[{"adminSecret":[]}],"parameters":[{"name":"lookback_days","in":"query","required":false,"description":"Cohort lookback window. Default 30, max 365.","schema":{"type":"integer","default":30,"minimum":1,"maximum":365}},{"name":"activation_window_days","in":"query","required":false,"description":"Second-seal deadline measured from the first seal. Default 14, max 90.","schema":{"type":"integer","default":14,"minimum":1,"maximum":90}}],"responses":{"200":{"description":"Activation rate snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationCohortResponse"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/admin/kpis/kill-criterion":{"get":{"operationId":"getKillCriterion","summary":"Admin — qubs below the watcher floor past the seeding window","description":"M10 / DISTRIBUTION-STRATEGY.md §5.4. Returns qubs whose seal is old enough for the seeding horizon to have elapsed (default ≥48h) but whose watcher count is still below the floor (default <100). Triage list for the growth team. Sorted worst-first and capped at 500 entries.","tags":["Admin"],"security":[{"adminSecret":[]}],"parameters":[{"name":"sealed_after_hours","in":"query","required":false,"description":"Minimum age of a qub to be considered 'early seeder'. Default 48, max 336 (14 days).","schema":{"type":"integer","default":48,"minimum":1,"maximum":336}},{"name":"watching_threshold","in":"query","required":false,"description":"Watcher-count floor below which a qub is flagged. Default 100, max 10000.","schema":{"type":"integer","default":100,"minimum":1,"maximum":10000}},{"name":"max_age_days","in":"query","required":false,"description":"Ignore qubs older than this. Default 14, max 90.","schema":{"type":"integer","default":14,"minimum":1,"maximum":90}}],"responses":{"200":{"description":"Triage list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KillCriterionResponse"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/identity/attestation/{fingerprint}":{"get":{"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/identity/attestation/email/begin":{"post":{"operationId":"beginEmailAttestation","summary":"Initiate an email attestation challenge","description":"Issues a 6-digit verification code via SendGrid and stashes a pending challenge in KV with a 10-minute TTL. The submitted public key must hash (SHA3-256) to the claimed fingerprint — this blocks pubkey squatting at the entry point. Rate limited 3/email/hour and 10/IP/hour.","tags":["Identity"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationEmailBeginRequest"}}}},"responses":{"200":{"description":"Code sent; pending challenge stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationEmailBeginResponse"}}}},"400":{"description":"Validation failure. `error` is one of `invalid_fingerprint`, `invalid_email`, `invalid_pubkey`, `unsupported_alg`, `fingerprint_mismatch`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The fingerprint already has an email attestation. Revoke it first via `DELETE /api/v1/identity/attestation/email`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (3/email/hour or 10/IP/hour).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/identity/attestation/email/verify":{"post":{"operationId":"verifyEmailAttestation","summary":"Complete an email attestation","description":"Verifies the 6-digit code plus a proof-of-possession ML-DSA-65 signature over the `QUB_IDENTITY_EMAIL_V1` challenge (IDENTITY.md §3.2). Writes the attestation record on success. Attempts are bumped BEFORE the code comparison so brute-force is capped at 5 even on all-fail traffic.","tags":["Identity"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationEmailVerifyRequest"}}}},"responses":{"200":{"description":"Attestation written. Returns the full record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationRecord"}}}},"400":{"description":"Verification failure. `error` is one of `no_pending`, `too_many_attempts`, `invalid_code`, `timestamp_expired`, `invalid_signature`, `invalid_fingerprint`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/identity/attestation/email":{"delete":{"operationId":"deleteEmailAttestation","summary":"Revoke an email attestation","description":"Removes the email entry from the attestation record. Requires a proof-of-possession ML-DSA-65 signature over the `QUB_IDENTITY_DELETE_V1` challenge (IDENTITY.md §6.2) — the signature is the auth, no session cookie. When the email was the only entry, the whole record is deleted per §4.2.","tags":["Identity"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationEmailDeleteRequest"}}}},"responses":{"200":{"description":"Email attestation removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttestationEmailDeleteResponse"}}}},"400":{"description":"Validation or verification failure. `error` is one of `invalid_fingerprint`, `fingerprint_mismatch`, `timestamp_expired`, `invalid_signature`, `no_attestation`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No attestation record exists for this fingerprint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/api-keys/checkout":{"post":{"operationId":"createApiKeyCheckout","summary":"Create a Builder API key checkout session","description":"Creates a Stripe checkout session that, on success, provisions a new Builder-tier API key. The raw key is later retrievable via `/api/v1/api-keys/provisioned?session_id=...` for one hour after webhook completion.","tags":["API Keys"],"security":[{"turnstile":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"},"turnstile_token":{"type":"string"}}}}}},"responses":{"200":{"description":"Checkout session created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutResponse"}}}},"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"}}}}}}},"/api/v1/api-keys/provisioned":{"get":{"operationId":"retrieveProvisionedApiKey","summary":"One-time retrieval of a freshly provisioned API key","description":"After a successful Builder checkout, the Stripe webhook stores the raw API key in KV under `apikey-provisioned:{session_id}` with a 1-hour TTL. This endpoint reads it once and deletes the KV entry. Returns `{ key: null }` if the webhook hasn't completed yet or the key has already been retrieved. Rate limited 30 requests per minute per IP.","tags":["API Keys"],"parameters":[{"name":"session_id","in":"query","required":true,"description":"Stripe checkout session ID (`cs_...`).","schema":{"type":"string","pattern":"^cs_"}}],"responses":{"200":{"description":"Key (or null if not yet provisioned / already retrieved).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyProvisionedResponse"}}}},"400":{"description":"Missing or malformed session_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/api-keys/rotate":{"post":{"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 until the next rotation. Quota state is preserved across the rotation. Requires the `rotate` scope on the current key. Concurrent rotations are blocked via a 30-second lock.","tags":["API Keys"],"security":[{"apiKey":[]}],"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, IP not allowed, or insufficient scope. Returns `{ code: 'API_KEY_DISABLED' | 'IP_NOT_ALLOWED' | 'INSUFFICIENT_SCOPE' }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A rotation is already in progress for this key (30-second lock).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/admin/api-keys":{"get":{"operationId":"adminListApiKeys","summary":"List all API keys with usage stats","description":"Admin-only listing of every provisioned API key with quota and usage metadata. Used by the admin dashboard for key lifecycle management.","tags":["Admin"],"security":[{"adminSecret":[]}],"responses":{"200":{"description":"Array of key entries.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdminKeyEntry"}}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/admin/api-keys/{key_hash}/{action}":{"post":{"operationId":"adminKeyAction","summary":"Disable or enable an API key","description":"Admin-only endpoint that flips the `disabled` flag on an API key by hash. The action component must be either `disable` or `enable`.","tags":["Admin"],"security":[{"adminSecret":[]}],"parameters":[{"name":"key_hash","in":"path","required":true,"description":"SHA-256 hash of the API key (as returned by `GET /api/v1/admin/api-keys`).","schema":{"type":"string"}},{"name":"action","in":"path","required":true,"schema":{"type":"string","enum":["disable","enable"]}}],"responses":{"200":{"description":"Action applied; returns the updated key state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminKeyActionResponse"}}}},"400":{"description":"Missing key hash or invalid action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — invalid admin secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"API key not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/c/{tx_id}":{"get":{"operationId":"viewerPage","summary":"Public viewer page (browsers + bots)","description":"Content-negotiated viewer entry point. For browsers: serves the SPA index.html. For bots (Twitterbot, facebookexternalhit, Slackbot, Discordbot, etc.): returns edge-computed HTML with OG meta tags built from the sealed qub's unlock time and live watch count. For requests with `Accept: application/json`: 307 redirects to `/api/v1/qub/{tx_id}`.","tags":["Viewer"],"parameters":[{"name":"tx_id","in":"path","required":true,"description":"Arweave transaction ID.","schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"HTML — either the SPA shell (browser) or bot meta tags.","content":{"text/html":{"schema":{"type":"string"}}}},"307":{"description":"JSON-accepting clients are redirected to the JSON read endpoint.","headers":{"Location":{"schema":{"type":"string","example":"/api/v1/qub/<tx_id>"}}}},"400":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/embed.js":{"get":{"operationId":"getEmbedLoader","summary":"Embed loader script (current version)","description":"Public loader script — alias for the current pinned version. Publishers `<script async src=\"...\">` this on their page; the script registers the `<qub-embed>` custom element. Served with `text/javascript`, `Cache-Control: public, max-age=300, s-maxage=86400`, and public CORS (`Access-Control-Allow-Origin: *`).","tags":["Embed"],"responses":{"200":{"description":"Loader script source.","content":{"text/javascript":{"schema":{"type":"string"}}}},"503":{"description":"Embed bundle not deployed (no `embed/v1/loader-current.js` object in R2).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/embed/v1.js":{"get":{"operationId":"getEmbedLoaderV1","summary":"Pinned v1 embed loader script","description":"Pinned v1 loader URL — same content as `/embed.js` for the lifetime of v1. Publishers should use this in production for stability: it gives a versioned URL that won't break when the loader is updated. Served with `text/javascript`, `Cache-Control: public, max-age=300, s-maxage=86400`, and public CORS (`Access-Control-Allow-Origin: *`).","tags":["Embed"],"responses":{"200":{"description":"Pinned loader script source.","content":{"text/javascript":{"schema":{"type":"string"}}}},"503":{"description":"Embed bundle not deployed (no `embed/v1/loader-current.js` object in R2).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/embed/v1/iframe-app.js":{"get":{"operationId":"getEmbedIframeApp","summary":"Embed iframe app bundle","description":"Bundled iframe app (~73 KB gzipped) — runs inside the iframe shell, performs in-browser tlock decrypt and renders the qub. During countdown it re-polls `/api/v1/qub/{tx_id}/meta` every 30 seconds so the watching count climbs live (matching the qub.social viewer); polling halts in the last minute before unlock to avoid racing the reveal transition. Served with `text/javascript`, `Cache-Control: public, max-age=300, s-maxage=86400`, and public CORS (`Access-Control-Allow-Origin: *`).","tags":["Embed"],"responses":{"200":{"description":"Iframe app bundle source.","content":{"text/javascript":{"schema":{"type":"string"}}}},"503":{"description":"Embed bundle not deployed (no `embed/v1/loader-current.js` object in R2).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/embed/{tx_id}":{"get":{"operationId":"getEmbedFrame","summary":"Embed iframe HTML shell","description":"HTML shell loaded inside the `<qub-embed>` iframe. Carries a locked-down CSP and intentionally OMITS `X-Frame-Options` so it can be embedded on third-party pages. Ships an inline first-paint skeleton (centred qub wordmark + pulsing lime dot, honouring `prefers-reduced-motion`) so the reader sees a branded placeholder while the iframe-app bundle parses; the skeleton is replaced by the first `render()` call from the bundle. The bundle also sets `<title>` dynamically once the qub's intent is known (e.g. `Sealed prediction · qub.social`) so direct-iframe visits get a useful tab label; terminal states (loading / denylisted / unsupported / error) keep the bare `qub` title so the page never claims `Sealed prediction` on a removed or broken qub. Accepts an optional `lang` query parameter (BCP 47 tag); when present it is written onto `<html lang>` and passed to the iframe-app, which resolves it against the locale registry via the same candidate chain used by server-side email templates. Unknown tags fall back to English. Served with `text/html; charset=utf-8` and `Cache-Control: public, max-age=60`. Full publisher-facing contract in `docs/EMBED.md`.","tags":["Embed"],"parameters":[{"name":"tx_id","in":"path","required":true,"description":"Arweave transaction ID.","schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}},{"name":"lang","in":"query","required":false,"description":"BCP 47 locale tag (e.g. `en`, `fr`, `pt-BR`, `en-GB`). Loose shape gate at the Worker boundary — the iframe-app re-coerces and walks the candidate chain. Absent or malformed → defaults to `en`.","schema":{"type":"string","pattern":"^[A-Za-z0-9-]{1,35}$"}}],"responses":{"200":{"description":"Iframe HTML shell.","content":{"text/html":{"schema":{"type":"string"}}}},"404":{"description":"Invalid tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/bytes":{"get":{"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 Arweave gateway hits.","tags":["Qub"],"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 on Arweave.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/qub/{tx_id}/react":{"post":{"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":{"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.","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":{"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":{"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":{"operationId":"cosignPact","summary":"Co-sign a staged pact and seal to Arweave","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 Arweave. 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":"Arweave upload failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/s/{code}":{"get":{"operationId":"resolveShortCode","summary":"Short-URL redirect","description":"Resolves a 7-character base62 short code allocated at upload or seal time to the canonical `/c/{tx_id}` viewer page via HTTP 302. Preferred over `/c/{tx_id}` for tweet text and QR matrices because it saves ~56 characters.","tags":["Viewer"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9]{7}$"}}],"responses":{"302":{"description":"Redirect to `/c/{tx_id}`."},"404":{"description":"Unknown short code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/oembed":{"get":{"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":{"operationId":"getOgImage","summary":"Dynamic Open Graph image","description":"Dynamic OG image 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.","tags":["Viewer"],"parameters":[{"name":"tx_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-zA-Z0-9_-]{1,64}$"}}],"responses":{"200":{"description":"PNG image.","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Unknown tx_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/lifecycle/unsubscribe":{"get":{"operationId":"lifecycleUnsubscribe","summary":"One-click lifecycle email unsubscribe","description":"One-click unsubscribe endpoint linked from creator lifecycle emails (seal confirmation, pre-reveal, post-reveal). The `token` query parameter is HMAC-signed and binds the request to a specific email + tx_id. On success, writes a suppression record so subsequent lifecycle sends to that address skip delivery.","tags":["Lifecycle"],"parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Unsubscribe confirmation HTML page.","content":{"text/html":{"schema":{"type":"string"}}}},"400":{"description":"Invalid or expired token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}