qub API reference
Qub API — OpenAPI 1.0.0
API for Qub, a timed commitment and timed publication system backed by drand timelock encryption. Seal messages now, reveal them later.
Base URL(s):
https://qub.social
Authentication
turnstile — Cloudflare Turnstile verification token. Despite this being listed as a bearer scheme, the token is passed in the request body as turnstile_token, not in the Authorization header.
apiKey — API key with prefix qub_sk_. Pass in the Authorization header as Bearer qub_sk_....
adminSecret — Admin secret for privileged endpoints. Pass in the Authorization header as Bearer <secret>.
Endpoints
Qubs
Create, seal, and read qubs
GET /api/v1/qub/{tx_id} — Read qub status and content
Auth: API key
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction ID. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Qub status and content. | application/json |
404 |
Qub not found. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
451 |
Content denied by moderation. | Error (application/json) |
502 |
Upstream failure fetching from Arweave or drand. | Error (application/json) |
GET /api/v1/qub/{tx_id}/meta — Lightweight qub metadata (no decrypt)
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction ID. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Metadata. Both fields are optional — empty {} means the GraphQL fetch failed or the transaction isn't yet confirmed in a block. Always 200, never 404. |
QubMetaResponse (application/json) |
400 |
Invalid tx_id. | Error (application/json) |
200 response body — QubMetaResponse
| Field | Type | Required | Description |
|---|---|---|---|
arweave_block_timestamp |
integer |
no | Unix seconds when the qub's Arweave transaction was first included in a block. |
intent |
enum ("announcement" / "thesis" / "prediction" / "letter" / "secret") |
no | Intent tag set at compose time (e.g. announcement, thesis, prediction, letter, secret). |
denylisted |
boolean |
no | True if the qub has been removed from public viewing via the moderation denylist. The F4 embed iframe checks this before fetching Arweave bytes. |
POST /api/v1/seal — Server-side seal (agent-only)
Auth: API key
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
body |
string |
yes | Plaintext message body to seal. |
unlock_at |
number |
yes | Unix timestamp (seconds) for when the qub should unlock. |
sender_label |
string |
no | Optional display name for the sender. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Qub sealed and uploaded. | SealResponse (application/json) |
400 |
Bad request. | Error (application/json) |
401 |
API key required. | Error (application/json) |
500 |
Server error during sealing. | Error (application/json) |
502 |
Upstream Arweave failure. | Error (application/json) |
200 response body — SealResponse
| Field | Type | Required | Description |
|---|---|---|---|
tx_id |
string |
yes | |
delivery_url |
string (uri) |
yes | |
qub_id |
string |
yes | |
unlock_at |
number |
yes | |
drand_round |
integer |
yes | |
short_delivery_url |
string (uri) |
no | Full short-URL form https://qub.social/s/<code> 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. |
Upload
Upload sealed content to Arweave
POST /api/v1/upload — Upload sealed CBOR to Arweave
Auth: Turnstile or API key
Uploads a sealed CBOR payload to Arweave via the worker proxy. Returns the Arweave transaction ID and a delivery URL for the viewer.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
sealed_cbor_base64 |
string |
yes | Base64-encoded sealed CBOR payload. |
device_id |
string |
yes | |
turnstile_token |
string |
no | Cloudflare Turnstile token. Optional when using API key auth. |
content_size |
integer |
yes |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Upload successful. | UploadResponse (application/json) |
400 |
Bad request. | Error (application/json) |
402 |
Payment required — entitlement exhausted. | Error (application/json) |
403 |
Turnstile verification failed. | Error (application/json) |
413 |
Payload too large for the device's entitlement tier. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
502 |
Upstream Arweave failure. | Error (application/json) |
200 response body — UploadResponse
| Field | Type | Required | Description |
|---|---|---|---|
tx_id |
string |
yes | Arweave transaction ID. |
delivery_url |
string (uri) |
yes | |
short_code |
string |
no | 7-character base62 short code mapped to tx_id in KV. Resolves via /s/{code} to the canonical /c/{tx_id} viewer page. Absent when the allocator failed transiently — delivery_url is always usable as a fallback. |
POST /api/v1/upload-auth — Pre-check upload eligibility
Auth: Turnstile or API key
Validates the device's entitlement tier and rate limits before uploading. Rate limited to 20 requests per 10 minutes per IP and 10 per 10 minutes per device.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
turnstile_token |
string |
no | Cloudflare Turnstile token. Optional when using API key auth. |
device_id |
string |
yes | |
content_size |
integer |
yes | Size in bytes of the sealed content. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Eligibility check result. | UploadAuthResponse (application/json) |
400 |
Bad request. | Error (application/json) |
403 |
Turnstile verification failed. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
200 response body — UploadAuthResponse
| Field | Type | Required | Description |
|---|---|---|---|
allowed |
boolean |
yes | |
tier |
string |
yes | |
max_size |
integer |
yes | |
reason |
string |
no |
Engagement
Watch / view counters and notify-me subscriptions
POST /api/v1/qub/{tx_id}/notify — Subscribe an email to a qub's reveal
Auth: public
Adds an email address to the notify-me list for a sealed qub. The reveal-time cron sends a one-shot email when the qub unlocks. Always returns { ok: true } on success — re-submitting the same address is idempotent and updates the stored locale. Hard-capped at 1000 subscribers per qub. Rate limited 5 requests per minute per IP.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
email |
string (email) |
yes | Email address to subscribe. Max 200 chars; the domain is lowercased on store. |
unlock_at |
integer |
no | Unix seconds when the qub unlocks. Strongly preferred — without it the cron has no way to know when to send the email. |
locale |
string |
no | BCP 47 locale tag (e.g. en, pt-BR). Picks the language for the reveal email. Falls back to en if missing or unrecognised. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Subscribed (or cap reached — the cap is silent so the user doesn't see a degraded experience). | OkResponse (application/json) |
400 |
Invalid tx_id, email, or unlock_at. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
200 response body — OkResponse
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
yes |
POST /api/v1/qub/{tx_id}/react — Record a reaction on a revealed qub
Auth: public
Records a called_it or wrong reaction for a revealed prediction and returns the updated tallies. Client-side dedup via localStorage per (tx_id, device) is assumed; ~10% inflation is acceptable. Rate-limited 10/min/IP as a shallow abuse floor — over-limit requests return current tallies without mutating.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
reaction |
enum ("called_it" / "wrong") |
yes |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Updated reaction tallies. | object (application/json) |
400 |
Invalid reaction or tx_id. | Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
called_it |
integer |
yes | |
wrong |
integer |
yes | |
total |
integer |
yes |
POST /api/v1/qub/{tx_id}/view — Increment the post-reveal view counter
Auth: public
Increments and returns the post-reveal view count. Same shape as the watch counter but tracked on a separate KV key. Displayed on the revealed screen as 'Seen by {N} people'. Rate limited 10 requests per minute per IP.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
New count after this increment (or 0 if rate-limited). | EngagementCountResponse (application/json) |
400 |
Invalid tx_id. | Error (application/json) |
200 response body — EngagementCountResponse
| Field | Type | Required | Description |
|---|---|---|---|
count |
integer |
yes | Current count after this increment. Returns 0 if rate-limited (so the client can render zero or its cached value rather than an error). |
POST /api/v1/qub/{tx_id}/watch — Increment the pre-reveal watch counter
Auth: public
Increments and returns the 'watching' count for a sealed qub. Used by the viewer countdown screen as social proof. No per-fingerprint dedup — accepts ~10% inflation in exchange for a much simpler implementation. Rate limited 10 requests per minute per IP. On rate-limit returns { count: 0 } (200) so the client renders zero or its cached value.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
New count after this increment (or 0 if rate-limited). | EngagementCountResponse (application/json) |
400 |
Invalid tx_id. | Error (application/json) |
200 response body — EngagementCountResponse
| Field | Type | Required | Description |
|---|---|---|---|
count |
integer |
yes | Current count after this increment. Returns 0 if rate-limited (so the client can render zero or its cached value rather than an error). |
Auth
Magic-link email verification and identity recovery
POST /api/v1/auth/magic-link — Issue a magic-link token
Auth: public
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
email |
string (email) |
yes | Email address to send the magic link to. Max 200 chars. |
device_id |
string |
yes | Device identifier (32 hex chars). The verify path attaches this device to the resulting identity. — pattern: ^[a-f0-9]{32}$ |
locale |
string |
no | BCP 47 locale tag for the email body. Persisted onto the identity record so future server-sent emails use the same language. |
staging_id |
string |
no | 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. — pattern: ^[a-f0-9]{32}$ |
cosigner_fingerprint |
string |
no | 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. — pattern: ^[0-9a-f]{64}$ |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Token issued and email queued for delivery. | OkResponse (application/json) |
400 |
Invalid email or device_id. | Error (application/json) |
429 |
Rate limited (3 per email per hour). | Error (application/json) |
503 |
Auth not configured (AUTH_SECRET missing). Returns { code: 'AUTH_DISABLED' }. |
Error (application/json) |
200 response body — OkResponse
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
yes |
POST /api/v1/auth/verify — Consume a magic-link token
Auth: public
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
token |
string |
yes | The signed token from the magic link URL. Format: base64url(payload).base64url(sig). HMAC-signed, single-use, expires 15 minutes after issue. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Verification succeeded. The device is now linked to the email. | VerifyResponse (application/json) |
400 |
Token failed verification. Returns { code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID' } and the failure reason in error (expired/bad_signature/malformed/bad_payload). |
Error (application/json) |
410 |
Token already used (single-use enforcement). Returns { code: 'TOKEN_USED' }. |
Error (application/json) |
503 |
Auth not configured (AUTH_SECRET missing). Returns { code: 'AUTH_DISABLED' }. |
Error (application/json) |
200 response body — VerifyResponse
| Field | Type | Required | Description |
|---|---|---|---|
ok |
const true |
yes | |
email |
string (email) |
yes | |
tier |
enum ("free" / "creator" / "builder") |
yes | |
qubs_remaining |
integer |
yes | Free-tier qub credits remaining for this identity. |
sealed_history |
array of SealedHistoryEntry |
yes | Per-identity sealed-history index (capped server-side). The client merges this into its local IndexedDB cache. |
GET /api/v1/creator/qubs — Identity-scoped aggregate of a creator's sealed qubs
Auth: public
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).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
query | yes | string |
32-hex device identifier. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Creator summary. | CreatorQubsResponse (application/json) |
400 |
Missing or malformed device_id. | Error (application/json) |
404 |
Device not linked. Returns { code: 'NOT_LINKED' }. |
Error (application/json) |
200 response body — CreatorQubsResponse
| Field | Type | Required | Description |
|---|---|---|---|
oldest_sealed |
yes | The earliest entry in the creator's sealed_history, or null if they have sealed nothing. | |
next_reveal |
yes | The next qub whose unlock_at is in the future, or null when every qub has already revealed. | |
aggregate_watchers |
integer |
yes | Sum of watch:<tx_id> across every qub in the creator's history. |
qub_count |
integer |
yes | |
truncated |
boolean |
yes | True when the history exceeded 200 entries and the aggregate was computed over the most-recent-200 slice. |
GET /api/v1/identity — Read-only identity lookup for a device
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
query | yes | string |
32-hex device identifier. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Linked identity for this device. | IdentityResponse (application/json) |
400 |
Missing or malformed device_id. | Error (application/json) |
404 |
Device not linked. Returns { code: 'NOT_LINKED' }. |
Error (application/json) |
200 response body — IdentityResponse
| Field | Type | Required | Description |
|---|---|---|---|
ok |
const true |
yes | |
email |
string (email) |
yes | |
tier |
enum ("free" / "creator" / "builder") |
yes | |
qubs_remaining |
integer |
yes | |
sealed_history |
array of SealedHistoryEntry |
yes | |
locale |
string |
yes | BCP 47 locale stored on the identity record. Defaults to en for legacy identities without one. |
API Keys
API key checkout, retrieval, and rotation
POST /api/v1/api-keys/checkout — Create a Builder API key checkout session
Auth: Turnstile
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
email |
string (email) |
yes | |
turnstile_token |
string |
no |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Checkout session created. | CheckoutResponse (application/json) |
400 |
Bad request. | Error (application/json) |
403 |
Turnstile verification failed. | Error (application/json) |
200 response body — CheckoutResponse
| Field | Type | Required | Description |
|---|---|---|---|
checkout_url |
string (uri) |
yes |
GET /api/v1/api-keys/provisioned — One-time retrieval of a freshly provisioned API key
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
session_id |
query | yes | string |
Stripe checkout session ID (cs_...). |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Key (or null if not yet provisioned / already retrieved). | ApiKeyProvisionedResponse (application/json) |
400 |
Missing or malformed session_id. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
200 response body — ApiKeyProvisionedResponse
| Field | Type | Required | Description |
|---|---|---|---|
key |
string or null |
yes | 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. |
POST /api/v1/api-keys/rotate — Rotate the current API key
Auth: API key
Issues a new API key for the authenticated client and stores a grace-period mapping so the old key continues to work 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.
Responses
| Status | Description | Body |
|---|---|---|
200 |
Rotation succeeded; the response carries the new raw key. | ApiKeyRotateResponse (application/json) |
401 |
API key required or invalid. | Error (application/json) |
403 |
Disabled key, IP not allowed, or insufficient scope. Returns { code: 'API_KEY_DISABLED' | 'IP_NOT_ALLOWED' | 'INSUFFICIENT_SCOPE' }. |
Error (application/json) |
409 |
A rotation is already in progress for this key (30-second lock). | Error (application/json) |
200 response body — ApiKeyRotateResponse
| Field | Type | Required | Description |
|---|---|---|---|
key |
string |
yes | The new raw qub_sk_... API key. The old key continues to work via the grace-period mapping until the next rotation. |
Payment
Stripe checkout and entitlements
POST /api/v1/checkout — Create Stripe checkout session
Auth: public
Creates a Stripe checkout session for purchasing qub entitlements.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
price_id |
string |
yes | |
device_id |
string |
yes | |
email |
string (email) |
no |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Checkout session created. | CheckoutResponse (application/json) |
400 |
Bad request. | Error (application/json) |
500 |
Stripe API error. | Error (application/json) |
200 response body — CheckoutResponse
| Field | Type | Required | Description |
|---|---|---|---|
checkout_url |
string (uri) |
yes |
GET /api/v1/entitlements — Check device entitlement tier
Auth: public
Returns the current entitlement tier and remaining qub count for a device.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
device_id |
query | yes | string |
Device identifier. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Entitlement info. | EntitlementsResponse (application/json) |
400 |
Missing device_id. | Error (application/json) |
200 response body — EntitlementsResponse
| Field | Type | Required | Description |
|---|---|---|---|
tier |
string |
yes | |
qubs_remaining |
integer |
yes | |
purchased_at |
string (date-time) |
no |
POST /api/v1/payment — Stripe webhook receiver
Auth: public
Receives Stripe webhook events for payment confirmation. Not intended for direct use by API consumers.
Request body (application/json, required)
Type: object
Responses
| Status | Description | Body |
|---|---|---|
200 |
Webhook processed. | object (application/json) |
400 |
Invalid webhook payload or signature. | Error (application/json) |
Moderation
Content reporting and denylist
GET /api/v1/denylist-check/{tx_id} — Check if a transaction is denylisted
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction ID to check. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Transaction is not denylisted. | object (application/json) |
400 |
Invalid transaction ID format. | Error (application/json) |
404 |
Transaction is denylisted. | Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
no |
POST /api/v1/report — Report content
Auth: public
Submit a content report for moderation review.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
tx_id |
string |
yes | |
reason |
enum ("illegal" / "harassment" / "personal_info" / "other") |
yes | |
text |
string |
no |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Report submitted. | object (application/json) |
400 |
Bad request. | Error (application/json) |
429 |
Rate limited. | Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
no |
Admin
Admin-only endpoints
GET /api/v1/admin/api-keys — List all API keys with usage stats
Auth: Admin secret
Admin-only listing of every provisioned API key with quota and usage metadata. Used by the admin dashboard for key lifecycle management.
Responses
| Status | Description | Body |
|---|---|---|
200 |
Array of key entries. | array of AdminKeyEntry (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
POST /api/v1/admin/api-keys/{key_hash}/{action} — Disable or enable an API key
Auth: Admin secret
Admin-only endpoint that flips the disabled flag on an API key by hash. The action component must be either disable or enable.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key_hash |
path | yes | string |
SHA-256 hash of the API key (as returned by GET /api/v1/admin/api-keys). |
action |
path | yes | enum ("disable" / "enable") |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Action applied; returns the updated key state. | AdminKeyActionResponse (application/json) |
400 |
Missing key hash or invalid action. | Error (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
404 |
API key not found. | Error (application/json) |
200 response body — AdminKeyActionResponse
| Field | Type | Required | Description |
|---|---|---|---|
key_hash |
string |
yes | |
disabled |
boolean |
yes | |
client_id |
string |
yes |
GET /api/v1/admin/kpis/activation-cohort — Admin — first-qub to second-qub activation rate
Auth: Admin secret
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
lookback_days |
query | no | integer |
Cohort lookback window. Default 30, max 365. |
activation_window_days |
query | no | integer |
Second-seal deadline measured from the first seal. Default 14, max 90. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Activation rate snapshot. | ActivationCohortResponse (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
200 response body — ActivationCohortResponse
| Field | Type | Required | Description |
|---|---|---|---|
cohort_size |
integer |
yes | |
activated_count |
integer |
yes | |
activation_rate |
number |
yes | activated_count / cohort_size, rounded to 4 dp. Zero for an empty cohort. |
lookback_days |
integer |
yes | |
activation_window_days |
integer |
yes | |
truncated |
boolean |
yes |
GET /api/v1/admin/kpis/kill-criterion — Admin — qubs below the watcher floor past the seeding window
Auth: Admin secret
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
sealed_after_hours |
query | no | integer |
Minimum age of a qub to be considered 'early seeder'. Default 48, max 336 (14 days). |
watching_threshold |
query | no | integer |
Watcher-count floor below which a qub is flagged. Default 100, max 10000. |
max_age_days |
query | no | integer |
Ignore qubs older than this. Default 14, max 90. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Triage list. | KillCriterionResponse (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
200 response body — KillCriterionResponse
| Field | Type | Required | Description |
|---|---|---|---|
results |
array of KillCriterionEntry |
yes | |
params |
object |
yes | |
truncated |
boolean |
yes |
GET /api/v1/admin/kpis/shares-median — Admin — shares-per-qub p50/p95
Auth: Admin secret
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.
Responses
| Status | Description | Body |
|---|---|---|
200 |
Shares-per-qub percentiles. | SharesMedianResponse (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
200 response body — SharesMedianResponse
| Field | Type | Required | Description |
|---|---|---|---|
p50 |
number |
yes | |
p95 |
number |
yes | |
count |
integer |
yes | |
truncated |
boolean |
yes |
GET /api/v1/admin/metrics — Admin metrics dashboard data
Auth: Admin secret
Returns aggregated metrics for the admin dashboard.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
range |
query | no | enum ("1d" / "7d" / "30d" / "90d") |
Time range for metrics aggregation. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Metrics data. | object (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
POST /api/v1/denylist — Block or unblock transaction IDs
Auth: Admin secret
Admin endpoint to add or remove Arweave transaction IDs from the content denylist.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
action |
enum ("block" / "unblock") |
yes | |
tx_id |
string |
yes | |
reason |
string |
no |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Denylist updated. | object (application/json) |
400 |
Bad request. | Error (application/json) |
401 |
Unauthorized — invalid admin secret. | Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean |
no |
Webhooks
Webhook registration for qub unlock notifications
GET /api/v1/webhooks — List webhooks
Auth: API key
List all webhooks registered by the authenticated API key.
Responses
| Status | Description | Body |
|---|---|---|
200 |
List of webhooks. | array of Webhook (application/json) |
401 |
API key required. | Error (application/json) |
POST /api/v1/webhooks — Register a webhook
Auth: API key
Register a webhook URL to be notified when a specific qub unlocks.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
tx_id |
string |
yes | Arweave transaction ID to watch. |
url |
string (uri) |
yes | URL to receive the webhook POST. |
secret |
string |
yes | Shared secret used for HMAC-SHA256 signature verification. Sent to the callback in the X-Qub-Signature header. Minimum 16 characters. — minLength: 16 |
Responses
| Status | Description | Body |
|---|---|---|
201 |
Webhook registered. | WebhookCreateResponse (application/json) |
400 |
Bad request. | Error (application/json) |
401 |
API key required. | Error (application/json) |
201 response body — WebhookCreateResponse
| Field | Type | Required | Description |
|---|---|---|---|
webhook_id |
string |
yes |
DELETE /api/v1/webhooks/{id} — Remove a webhook
Auth: API key
Delete a previously registered webhook.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id |
path | yes | string |
Webhook ID. |
Responses
| Status | Description | Body |
|---|---|---|
204 |
Webhook deleted. | — |
401 |
API key required. | Error (application/json) |
404 |
Webhook not found. | Error (application/json) |
Viewer
Public viewer page (browsers + bots)
GET /api/v1/og/{tx_id}.png — Dynamic Open Graph image
Auth: public
Dynamic OG 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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
PNG image. | string (binary) (image/png) |
404 |
Unknown tx_id. | Error (application/json) |
GET /c/{tx_id} — Public viewer page (browsers + bots)
Auth: public
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}.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction ID. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
HTML — either the SPA shell (browser) or bot meta tags. | string (text/html) |
307 |
JSON-accepting clients are redirected to the JSON read endpoint. | — |
400 |
Invalid tx_id. | Error (application/json) |
GET /s/{code} — Short-URL redirect
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
code |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
302 |
Redirect to /c/{tx_id}. |
— |
404 |
Unknown short code. | Error (application/json) |
Meta
API metadata
GET /api/v1/openapi.json — OpenAPI specification
Auth: public
Returns this OpenAPI 3.1.0 specification document.
Responses
| Status | Description | Body |
|---|---|---|
200 |
OpenAPI specification. | object (application/json) |
POST /api/v1/telemetry — Client telemetry ingestion
Auth: public
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.
Request body (application/json)
Type: object
Responses
| Status | Description | Body |
|---|---|---|
204 |
Telemetry accepted (always returned). | — |
Embed
F4 embed loader + iframe shell + bundled iframe app for third-party page embedding
GET /embed.js — Embed loader script (current version)
Auth: public
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: *).
Responses
| Status | Description | Body |
|---|---|---|
200 |
Loader script source. | string (text/javascript) |
503 |
Embed bundle not deployed (no embed/v1/loader-current.js object in R2). |
Error (application/json) |
GET /embed/{tx_id} — Embed iframe HTML shell
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Arweave transaction ID. |
lang |
query | no | string |
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. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Iframe HTML shell. | string (text/html) |
404 |
Invalid tx_id. | Error (application/json) |
GET /embed/v1.js — Pinned v1 embed loader script
Auth: public
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: *).
Responses
| Status | Description | Body |
|---|---|---|
200 |
Pinned loader script source. | string (text/javascript) |
503 |
Embed bundle not deployed (no embed/v1/loader-current.js object in R2). |
Error (application/json) |
GET /embed/v1/iframe-app.js — Embed iframe app bundle
Auth: public
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: *).
Responses
| Status | Description | Body |
|---|---|---|
200 |
Iframe app bundle source. | string (text/javascript) |
503 |
Embed bundle not deployed (no embed/v1/loader-current.js object in R2). |
Error (application/json) |
GET /oembed — oEmbed discovery
Auth: public
oEmbed 1.0 discovery endpoint. Returns the JSON oEmbed payload for a qub viewer URL so WordPress, Notion, Medium, and Substack can auto-embed <qub-embed> without the user pasting a manual snippet.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
url |
query | yes | string (uri) |
Canonical qub viewer URL (e.g. https://qub.social/c/<tx_id>). |
format |
query | no | enum ("json") |
Response format. Only json is supported. |
maxwidth |
query | no | integer |
|
maxheight |
query | no | integer |
Responses
| Status | Description | Body |
|---|---|---|
200 |
oEmbed JSON. | object (application/json) |
404 |
URL does not match a known qub. | Error (application/json) |
Identity
GET /api/v1/identity/attestation/{fingerprint} — Look up public attestations for a pubkey fingerprint
Auth: public
Unauthenticated public lookup. Returns the attestation record for a given author-key fingerprint (IDENTITY.md §2 + §4), or 404 if the keypair is unattested (Layer 0). The viewer uses this at reveal time to render the richest identity label per §5.3. Response is cached at the edge for 5 minutes.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
fingerprint |
path | yes | string |
64-char lower-case hex SHA3-256 of the author public key. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Attestation record. | AttestationRecord (application/json) |
400 |
Malformed fingerprint. | Error (application/json) |
404 |
No attestations for this fingerprint (Layer 0 / not attested). | Error (application/json) |
200 response body — AttestationRecord
| Field | Type | Required | Description |
|---|---|---|---|
attestations |
array of AttestationEntry |
yes | |
pubkey_alg |
integer |
yes | sig_alg registry value (PROTOCOL.md §9.2). Phase 2 always 1 (ML-DSA-65). |
created_at |
integer |
yes | |
updated_at |
integer |
yes |
DELETE /api/v1/identity/attestation/email — Revoke an email attestation
Auth: public
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
fingerprint |
string |
yes | pattern: ^[0-9a-f]{64}$ |
pubkey |
string |
yes | Base64url-encoded ML-DSA-65 public key (1,952 bytes decoded). Must hash to the claimed fingerprint. |
timestamp |
integer |
yes | Unix seconds UTC; must be within 120 seconds of Worker receipt. |
signature |
string |
yes | Base64url-encoded ML-DSA-65 signature over the IDENTITY.md §6.2 delete challenge. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Email attestation removed. | AttestationEmailDeleteResponse (application/json) |
400 |
Validation or verification failure. error is one of invalid_fingerprint, fingerprint_mismatch, timestamp_expired, invalid_signature, no_attestation. |
Error (application/json) |
404 |
No attestation record exists for this fingerprint. | Error (application/json) |
200 response body — AttestationEmailDeleteResponse
| Field | Type | Required | Description |
|---|---|---|---|
deleted |
const true |
yes |
POST /api/v1/identity/attestation/email/begin — Initiate an email attestation challenge
Auth: public
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
fingerprint |
string |
yes | SHA3-256 of the author public key, lower-case hex. — pattern: ^[0-9a-f]{64}$ |
email |
string (email) |
yes | |
pubkey |
string |
yes | Base64url-encoded ML-DSA-65 public key (1,952 bytes decoded). |
pubkey_alg |
enum (1) |
yes | sig_alg registry value. Phase 2 only supports 1 (ML-DSA-65). |
locale |
string |
no | BCP 47 locale tag for the verification-code email body. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Code sent; pending challenge stored. | AttestationEmailBeginResponse (application/json) |
400 |
Validation failure. error is one of invalid_fingerprint, invalid_email, invalid_pubkey, unsupported_alg, fingerprint_mismatch. |
Error (application/json) |
409 |
The fingerprint already has an email attestation. Revoke it first via DELETE /api/v1/identity/attestation/email. |
Error (application/json) |
429 |
Rate limited (3/email/hour or 10/IP/hour). | Error (application/json) |
200 response body — AttestationEmailBeginResponse
| Field | Type | Required | Description |
|---|---|---|---|
expires_at |
integer |
yes | Unix seconds UTC at which the pending verification code expires (10 minutes after issue). |
POST /api/v1/identity/attestation/email/verify — Complete an email attestation
Auth: public
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.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
fingerprint |
string |
yes | pattern: ^[0-9a-f]{64}$ |
code |
string |
yes | 6-digit verification code delivered by email. — pattern: ^[0-9]{6}$ |
timestamp |
integer |
yes | Unix seconds UTC; must be within 120 seconds of Worker receipt. |
signature |
string |
yes | Base64url-encoded ML-DSA-65 signature (3,309 bytes decoded) over the IDENTITY.md §3.2 challenge. |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Attestation written. Returns the full record. | AttestationRecord (application/json) |
400 |
Verification failure. error is one of no_pending, too_many_attempts, invalid_code, timestamp_expired, invalid_signature, invalid_fingerprint. |
Error (application/json) |
200 response body — AttestationRecord
| Field | Type | Required | Description |
|---|---|---|---|
attestations |
array of AttestationEntry |
yes | |
pubkey_alg |
integer |
yes | sig_alg registry value (PROTOCOL.md §9.2). Phase 2 always 1 (ML-DSA-65). |
created_at |
integer |
yes | |
updated_at |
integer |
yes |
Qub
GET /api/v1/qub/{tx_id}/bytes — Raw sealed CBOR bytes
Auth: public
Returns the raw sealed CBOR payload for a qub with R2 cache-aside and immutable caching (Cache-Control: public, max-age=31536000, immutable). Used by the in-browser embed and SPA client to perform client-side tlock decrypt without redundant Arweave gateway hits.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tx_id |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Raw sealed CBOR bytes. | string (binary) (application/octet-stream) |
404 |
Qub not found on Arweave. | Error (application/json) |
Pact
POST /api/v1/pact/cosign/{staging_id} — Co-sign a staged pact and seal to Arweave
Auth: public
Party B submits their ML-DSA-65 cosigner signature over the same sig_input as Party A. The worker merges both signatures into the envelope, seals with tlock, and uploads to 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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
staging_id |
path | yes | string |
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
cosigner_pubkey |
string |
yes | |
cosigner_signature |
string |
yes | |
turnstile_token |
string |
yes |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Pact sealed. | object (application/json) |
400 |
Invalid signature, pubkey, or email-binding marker missing. | Error (application/json) |
404 |
Staging id not found or expired. | Error (application/json) |
409 |
Pact is already cosigned (already_cosigned) or another cosign is in progress (cosign_in_progress). Retry of the in-progress case is safe once the prior request completes. |
Error (application/json) |
502 |
Arweave upload failed. | Error (application/json) |
200 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
tx_id |
string |
yes | |
delivery_url |
string |
yes |
POST /api/v1/pact/stage — Stage a signed pact envelope for co-signing
Auth: public
Party A stages a signed pact envelope (content type 0x03, ML-DSA-65 author signature). Returns a staging id and a human-readable staging URL (/p/<id>). If Party B's contact is a valid email, a review invite email is auto-dispatched and email_sent is true. Staged pacts expire after 7 days if not co-signed.
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
envelope_cbor |
string |
yes | Base64-encoded canonical CBOR QubEnvelope with content_type=0x03, sig_alg=0x01, author_pubkey and author_signature present, cosigner fields absent. |
turnstile_token |
string |
yes | |
device_id |
string |
yes | |
locale |
string |
no | Optional BCP 47 locale used for the review invite email. |
Responses
| Status | Description | Body |
|---|---|---|
201 |
Pact staged. | object (application/json) |
400 |
Invalid request. | Error (application/json) |
403 |
Turnstile verification failed. | Error (application/json) |
429 |
Rate limited. | — |
201 response body — inline
| Field | Type | Required | Description |
|---|---|---|---|
staging_id |
string |
yes | |
staging_url |
string |
yes | |
email_sent |
boolean |
yes |
DELETE /api/v1/pact/stage/{staging_id} — Retract a staged pact before co-signing
Auth: public
Party A proves possession of the author signing key and retracts a pending staged pact. Requires a fresh retract signature over SHA3-256("QUB_PACT_RETRACT_V1" || staging_id_bytes).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
staging_id |
path | yes | string |
Request body (application/json, required)
| Field | Type | Required | Description |
|---|---|---|---|
retract_signature |
string |
yes | Base64 retract signature from Party A's signing key. |
turnstile_token |
string |
yes |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Retracted. | — |
400 |
Invalid request or signature. | Error (application/json) |
404 |
Staging id not found or already sealed. | Error (application/json) |
GET /api/v1/pact/stage/{staging_id} — Review a staged pact
Auth: public
Returns the staged pact's CBOR envelope fields, decoded PactTerms, and Party A's public key so Party B can review before co-signing. If the pact has already been co-signed, returns a sealed redirect payload with tx_id and delivery_url.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
staging_id |
path | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Staged pact details (or sealed redirect). | object (application/json) |
404 |
Staging id not found or expired. | Error (application/json) |
Lifecycle
GET /api/v1/lifecycle/unsubscribe — One-click lifecycle email unsubscribe
Auth: public
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.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
token |
query | yes | string |
Responses
| Status | Description | Body |
|---|---|---|
200 |
Unsubscribe confirmation HTML page. | string (text/html) |
400 |
Invalid or expired token. | Error (application/json) |
This page is generated from workers/api/openapi.json by workers/api/scripts/build-openapi-md.mjs. For the raw JSON, see /api/v1/openapi.json.