Oannes Codex Paywall — Design
Date: 2026-07-05
Status: Approved (brainstorming → spec)
Project: Oannes Content Engine — the Codex (oannescode-site/codex/)
Goal
Turn the Codex (oannescode.com/codex/, shipped 2026-07-05) from a free searchable archive
into the first real paid product from the claim graph — the top near-term revenue lever
identified in the 2026-07-04 money-model research. Plain search alone isn’t worth paying
for; the paid tier needs genuinely new value, not just an unlocked toggle.
Non-goals / explicitly deferred
- Saved searches + email alerts. Needs real transactional email infra we don’t have. Cut from v1. “Early access” (below) covers the ordering value cheaply without it. Revisit if subscribers actually ask for it.
- Payment account creation. Joshua creates the Stripe account, Product, and recurring Price himself — that’s a financial-account action outside what Claude may do. He provides the Price ID + API keys the same way he provided the Beehiiv key (pasted in chat), which get stored as Cloudflare Pages secrets, never committed to git, never entered into any payment field by Claude. Card data is never handled by our code at all — Stripe Checkout and the Billing Portal are both fully Stripe-hosted redirects.
- Beehiiv Premium Subscriptions. Considered and rejected — ties the Codex to Beehiiv’s plan-gating (the create-post API was a surprise Enterprise-only gate during the Brief work); a standalone Stripe integration keeps the Codex’s fate independent of that risk.
The core constraint
The existing Codex ships one public claims.json with everything in it, including
corroboration links. A paywall can’t be a UI toggle on top of that — anyone can read the
full file from dev tools regardless of what the UI shows. Free and paid must be genuinely
different payloads: the free static file is deliberately smaller, and the paid payload is
served only after a server-side subscription check. There is no client-side enforcement
step in this design; every gated capability is a server call.
Architecture
- Cloudflare Pages Functions (
oannescode-site/functions/api/*.ts), not a separate Worker project. These deploy through the exactwrangler pages deploystep already in.github/workflows/deploy-oannescode.yml— no new CI, no new Cloudflare project. - Cloudflare KV, one namespace (
oannescode_subscribers), two purposes: - Subscriber records:
email -> {stripeCustomerId, subscriptionId, status, currentPeriodEnd}, written/updated by the Stripe webhook. - The premium dataset itself (full claims with resolved corroboration links + dossiers
index), written by the export pipeline. Not reachable via any public URL — only through
the
/api/premiumFunction, which checks the caller’s session first. - Stripe Checkout (subscription mode, price read from a
STRIPE_PRICE_IDsecret — never hardcoded, so Joshua can change price in Stripe without a code change) and Stripe Billing Portal (self-serve cancel/update, no manual handling of cancellation requests). - Signed HttpOnly cookie (HMAC over
{email, exp}, secret inCOOKIE_SIGNING_SECRET) as the session mechanism. No user-facing login/password; the cookie is set once, right after a successful Checkout redirect.
Data flow
- Visitor hits
/codex/, freeclaims.jsonloads exactly as today (full text/claimant/ topics/date for all 4,121 claims, corroboration counts only — see Feature Matrix). - Visitor clicks “Unlock the Codex” →
POST /api/checkoutcreates a Stripe Checkout Session (mode=subscription, price=STRIPE_PRICE_ID) and the client redirects to the returned Stripe URL. - Stripe redirects back to
/codex/?session_id=...on success. Client callsGET /api/checkout/verify?session_id=..., which retrieves the session from Stripe, confirmspayment_status=paid, upserts the KV subscriber record, sets the signed cookie, and the client reloads the premium view. - Independently,
POST /api/stripe-webhook(signature-verified) keeps the KV record in sync oncustomer.subscription.updated/.deleted/invoice.payment_failed— this is the source of truth for access, not just the one-time verify call, so a cancelled subscription actually loses access at the next check. - On every page load, if the signed cookie is present and unexpired, the client calls
GET /api/premium. The Function re-validates the cookie signature, looks up the email in KV, and only returns the full payload ifstatus === "active". Any failure → 401 → client silently falls back to the free-only UI (no error state shown to a lapsed subscriber, just fewer unlocked features). GET /api/dossiers/:topicandGET /api/exportfollow the identical gate-check pattern.POST /api/billing-portalcreates a Stripe Billing Portal session for a valid cookie holder and returns the redirect URL.
Feature matrix
Free (static claims.json) |
Paid (/api/premium, /api/dossiers, /api/export) |
|
|---|---|---|
| Claim text, claimant, topics, date | All 4,121 | All 4,121 |
| Search | Capped at top ~15 results/query, “Subscribe to see all N” prompt beyond that | Unlimited |
| Advanced filters (date range, sort-by-corroboration-count, claimant-only) | — | ✅ |
| Corroboration/contradiction links | Count only (“🔗 3 related claims”) | Full linked claim text |
| Topic Dossiers (AI-synthesized cross-claim narrative per topic) | — | ✅ |
| CSV/JSON export | — | ✅ |
| New-claim timing | Weekly, on the existing static regeneration cadence | As soon as ingested (see below) |
| Cancel/manage | n/a | Stripe Billing Portal (self-serve) |
New pipeline pieces (vault _scripts/)
generate_dossiers.py— mirrorscorroborate.py‘s pattern (topic-blocked Claude batches, per-topic state hash so only new/changed topics reprocess, same consecutive- failure circuit breaker). For each linkable topic, sends its claims + resolved corroboration links to Claude, asks for a structured synthesis (overview, strongest corroborated claims, notable contradictions, open questions) in the existing Oannes brand voice. Output: one JSON per topic, pushed to KV.export_codex.pygets a second output mode: the existing free export is unchanged; a new--premiumflag produces the full-fidelity payload (real corroboration arrays, not counts) and pushes it to KV via the Cloudflare API (needs aCLOUDFLARE_API_TOKENwith KV write scope — reuse or extend the token already used for Pages deploys).- Cadence: premium export runs right after
corroborate.pyin the existing weekly-ingest cron (immediate KV push, no deploy/build step needed since KV is read directly by the Function). Freeclaims.jsonregeneration + commit + push is currently a manual step (I ran it once, 2026-07-05) — bringing that onto the same weekly cron is a small, closely related follow-up, not bundled into this project’s implementation plan. Until it’s automated, “early access” is naturally even wider (the free tier only updates when someone manually re-runs and pushes).
Error handling
- Every
/api/*Function fails closed: any Stripe API error, KV miss, or signature mismatch results in “treat as not subscribed,” never in accidentally granting access. - Stripe webhook signature verification uses the raw request body (Cloudflare Pages Functions require reading the body as text before verifying — a common gotcha, called out here so the implementation doesn’t silently accept unverified webhook payloads).
- The webhook handler is idempotent (Stripe retries on non-2xx) — re-processing the same event twice must not double-charge or corrupt the KV record.
Testing
- Pure-logic unit tests (cookie signing/verification, KV record shape, feature-matrix
truncation logic for the free search cap) run the same way the existing
oannes-enginevitest suite does. - Stripe test-mode (test API keys, Stripe CLI’s
stripe triggerfor webhook events) is used to exercise the full checkout → webhook → access flow before switching to live keys. generate_dossiers.pygets the same kind of test coverage ascorroborate.py(tests/test_corroborate.py): pure parsing/prompt-building logic tested without live Claude calls.
Rollout
- Joshua: create the Stripe Product + recurring Price, provide Price ID + Secret Key + Publishable Key + (after webhook endpoint exists) Webhook Signing Secret.
- Claude: build the Pages Functions, KV namespace, cookie signing, and wire the Codex UI’s paywall CTAs — in Stripe test mode first.
- End-to-end test-mode verification (checkout → webhook → premium unlock → cancel → access revoked) before flipping to live keys.
generate_dossiers.pyfirst run (likely the most expensive single step — ~362 topics × Claude call).- Switch to live Stripe keys, ship.