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):

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.