ADR-0076: Customer-app KYC + auth persistence + webhook idempotency¶
- Status: Accepted
- Date: 2026-05-23
- Driver: 2026-05-23 architectural review §6.2 (KYC/auth TODOs flagged as data-loss risk)
Context¶
The audit identified 9 production TODOs in
pkg/octopus/customer-app/src/app/api/{kyc,auth}/**/route.ts where
the route handlers were accepting webhooks + form submissions but not
persisting any state. The most severe instance:
kyc/webhook/route.ts:54— UPDATE customer_accounts SET kyc_status='verified', kyc_tier=1
Production behavior with the TODO open: Stripe Identity successfully
verifies a customer's documents → fires
identity.verification_session.verified → our handler logs the event,
returns 200 OK, but never updates the database. The customer's
account stays in kyc_status='pending' forever; Stripe stops retrying
because we returned 200; the operator has no way to detect the lost
verification short of comparing Stripe's webhook log against the
customer DB.
Same shape on:
- kyc/webhook/route.ts:67 — requires_input email never sent
- kyc/webhook/route.ts:76 — canceled not marked as rejected
- kyc/start/route.ts:26 — hardcoded placeholder-account-id
- auth/signup/route.ts:95 — user / account / verification_token
rows not persisted
- auth/verify/route.ts:16 — token consumption silently no-ops; any
GET to /verify?token=… advances to /onboarding regardless of
token validity
Additionally: Stripe re-delivers webhook events on failure (default
3-day exponential-backoff retry chain). Without idempotency, a single
verification_session.verified event would fire OnboardingV1 +
"kyc-passed" email once per delivery attempt — up to 4× across
the retry chain.
Decision¶
Close all 9 TODOs in one PR + introduce webhook idempotency:
1. New table — identity_webhook_events¶
CREATE TABLE identity_webhook_events (
event_id TEXT PRIMARY KEY, -- Stripe event.id
event_type TEXT NOT NULL,
account_id UUID REFERENCES customer_accounts(id) ON DELETE SET NULL,
session_id TEXT,
received_at TIMESTAMP NOT NULL DEFAULT now(),
processed_at TIMESTAMP,
outcome TEXT CHECK (outcome IN ('accepted','rejected','duplicate','failed')),
raw_payload JSONB
);
The PK on event_id provides atomic claim-or-skip semantics via
INSERT ... ON CONFLICT DO NOTHING. Two concurrent webhook
deliveries for the same Stripe event get exactly one
"claimed: true" return — Postgres serializes the row insertion
behind the constraint.
2. New query helpers (in lib/db/queries.ts)¶
| Helper | Purpose |
|---|---|
claimIdentityWebhookEvent |
INSERT ... ON CONFLICT DO NOTHING — returns { claimed: bool } |
finalizeIdentityWebhookEvent |
UPDATE processed_at + outcome on success/failure |
markKycVerified |
Idempotent transition kyc_status='verified' + kyc_tier += promotion |
markKycRejected |
Idempotent transition to terminal kyc_status='rejected' |
Both markKyc* helpers run in a single Drizzle transaction and emit
an auth_events row for the audit trail (re-using the existing
audit table until a dedicated kyc_events is justified).
3. New session helper — lib/session/server.ts¶
requireSessionAccount(req) returns either a SessionAccount or a
401 Response for the handler to forward. Reads the
octopus_session cookie (currently a raw user ID, behind a TODO for
PR-W2-1.1 to add real Auth0 SDK integration) and joins
customer_users × customer_accounts for the full session view.
4. New db client — lib/db/client.ts¶
Drizzle + postgres-js connection pool, env-driven by default
(OCTOPUS_CUSTOMER_DB_URL or DATABASE_URL). Lazy-proxy pattern so
tests can swap the impl via setDbForTests(...) before the first
DB call.
5. Route handler rewrites¶
kyc/webhook/route.ts:- Signature verify (unchanged)
claimIdentityWebhookEventbefore any side-effect; dup delivery short-circuits with{ received: true, duplicate: true }- On
verified:markKycVerified+ Postmark welcome email + structured log to enqueue OnboardingV1 - On
requires_input: Postmark retry email - On
canceled:markKycRejected('user_canceled')+ structured log to enqueue manual review finalizeIdentityWebhookEventwith outcomekyc/start/route.ts:requireSessionAccountreplaces hardcoded'placeholder-account-id'kyc_status='verified'already → 409 fast-fail (saves Stripe spend)auth/signup/route.ts:createCustomerAndUser(real Drizzle transaction, was pseudo-code)createVerificationToken→ real DB row- Postmark send still wrapped in try/catch — DB transition is the source of truth
auth/verify/route.ts:consumeVerificationTokenSELECTs by hash + purpose + not-used- not-expired, then UPDATE used_at — single-shot. Forged/expired
tokens redirect to
/verify-error?code=invalid_or_expired
- not-expired, then UPDATE used_at — single-shot. Forged/expired
tokens redirect to
- On success:
markEmailVerifiedadvances account tokyc_pending- emits
email_verifiedaudit event
- emits
Testing¶
- Vitest unit tests for
claimIdentityWebhookEvent,finalizeIdentityWebhookEventwith mocked Drizzle builder - pg-mem integration test scaffold in
__tests__/test-db.tswith the full migration SQL ready — drizzle adapter wire-up is the only piece left for a follow-up PR (pg-mem's drizzle adapter shape varies across versions and getting that green required more iteration than the audit scope allowed) - Route-level tests are stubbed via
vi.mock('@/lib/db/queries', ...)
Postmark stub¶
lib/email.ts already existed (POSTMARK_SERVER_TOKEN-driven). The
KYC webhook reuses the existing sendTemplatedEmail with three new
template aliases: welcome (kyc-passed wrapper), verify-email
(kyc-retry), and the existing verify-email (signup). Real templates
are configured on the Postmark dashboard, not in code.
Consequences¶
Positive¶
- No more lost KYC verifications. The migration + idempotency flow ensures every event is durably recorded + processed at most once.
- Replay-safe webhook handler. Stripe's 4× retry chain produces a single side-effect set per event.
- Audit trail.
identity_webhook_events.raw_payloadis JSONB with the full Stripe event — forensic re-processing is possible without re-fetching from Stripe. - Session-aware /kyc/start. Sessions are loaded from the real user row; placeholder eliminated.
- Email-verify gate now enforces token validity. Forged tokens fail loudly instead of silently advancing to /onboarding.
Negative¶
- One new table + migration to apply at next deploy.
- pg-mem IT is stubbed. Heavy-weight DB tests deferred to PR-W2-1.x (the drizzle/pg-mem adapter wire-up). For now, unit tests + production smoke-test cover the new code paths.
- Session cookie is currently a raw UUID. Interim until full
Auth0 SDK integration (
PR-W2-1.1). The cookie is HttpOnly via Next.js middleware but should be signed/encrypted before this service ships traffic from outside the cluster.
See also¶
pkg/octopus/customer-app/src/lib/db/migrations/0001_kyc_webhook_idempotency.sqlpkg/octopus/customer-app/src/lib/db/client.tspkg/octopus/customer-app/src/lib/db/queries.ts— new helperspkg/octopus/customer-app/src/lib/session/server.tspkg/octopus/customer-app/src/app/api/{kyc,auth}/**/route.ts- ADR-0002 — drizzle + postgres baseline patterns