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
| Surface | Rule |
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.