Story 2531: waitlist leads — simple Postgres table + key-authed webhook

Why this exists

pramaan-web /api/leads forwards every waitlist signup as JSON to LEADS_WEBHOOK_URL. That env var had no target, so leads survived only as Vercel log lines. Founder ruling (2026-07-16): store them in a SIMPLE Postgres table — explicitly not DynamoDB — with no firm scoping and no RLS, because a waitlist lead is public marketing data captured before any firm or user exists.

The flow

pramaan.io waitlist form
  → pramaan-web /api/leads (validates, honeypot, confirmation email)
  → POST $LEADS_WEBHOOK_URL  (this endpoint: /webhooks/waitlist on admin-api)
       X-Pramaan-Webhook-Key: <shared secret>
  → honeypot check → name+email validation
  → INSERT INTO ops.waitlist_leads ... ON CONFLICT (email) DO NOTHING
  → 200 {"ok": true}   (never echoes PII; outcome only in logs/span)

Contract

SurfaceRule
ops.waitlist_leads (migration 0177) id UUID PK, name TEXT, email CITEXT UNIQUE (case-insensitive dedupe), phone / jurisdiction / city / country / note / source / utm_* TEXT, created_at TIMESTAMPTZ default now(). No RLS by design; pramaan_app gets SELECT + INSERT only — append-only from the app side.
Auth Shared-secret header compared constant-time against WAITLIST_WEBHOOK_KEY (env-injected at deploy from SSM /pramaan/stg/waitlist-webhook-key). Wrong/missing key → 401. Key not configured → 503, fail closed. No Clerk on this route; every other route keeps its Clerk auth untouched.
Honeypot Truthy company_website → pretend success (200), insert nothing, log outcome=rejected. Same field pramaan-web plants, so bots bypassing the web tier still score nothing.
Idempotency ON CONFLICT (email) DO NOTHING — replays and double-submits land exactly one row (outcome=duplicate, still 200).
Observability Structured log per request with outcome inserted | duplicate | rejected and trace context — never the email value. Span enrichment via the approved event.type key (waitlist.lead.<outcome>), ADR-124 razorpay-webhook precedent.

GTM: count & export

-- Totals
SELECT count(*) FROM ops.waitlist_leads;

-- By day
SELECT created_at::date AS day, count(*)
FROM ops.waitlist_leads GROUP BY 1 ORDER BY 1 DESC;

-- CSV export (psql)
\copy (SELECT name, email, phone, jurisdiction, city, country, note, source,
       utm_source, utm_medium, utm_campaign, utm_content, utm_term, created_at
       FROM ops.waitlist_leads ORDER BY created_at)
       TO 'waitlist_leads.csv' WITH CSV HEADER
Founder config to go live: (1) create SSM param /pramaan/stg/waitlist-webhook-key (the deploy fails loudly if it is missing — deliberate); (2) set Vercel LEADS_WEBHOOK_URL to the admin-api base URL + /webhooks/waitlist; (3) one-line pramaan-web change so deliver() sends the X-Pramaan-Webhook-Key header.
Proven: 12 route tests (key auth incl. fail-closed, honeypot, validation without PII echo, inserted/duplicate mapping) plus a real-Postgres dedupe test — two case-flipped sends, one row. Migration upgrade/downgrade/upgrade + alembic check clean. The append-only grant was observed red: DELETE as pramaan_app is denied.