Skip to content

02 — Identity & Onboarding (Sub-system Design)

02 — Identity & Onboarding (Sub-system Design)

Section titled “02 — Identity & Onboarding (Sub-system Design)”

Status: Draft Date: 2026-05-23 Owner: backend agent Parent: 2026-05-23-nowyourlink-master-design.md (binding — esp. §4.1, §10.4, D7, D15, D24)


Owns pre-bidding identity: registration, DOI, VIES VAT validation (with degraded-pending fallback), magic-link auth + session, lazy Stripe Customer + SetupIntent (off-session B2B card, SCA/3DS), and Stripe Identity escalation. Non-EU routes to manual admin queue (D7).

In scope: §3 endpoints; users, sessions, vat_validation_attempts, setup_intents, kyc_escalations; Stripe Customer/SetupIntent/Identity; VIES + retry queue; magic-link abuse limits. Out of scope: bid ranking (04), invoice/capture/cascade (05), creative moderation (03), admin UI (10), legal text + retention (12).


D1 tables; UUIDs as text, ISO-8601 UTC.

2.1 users (canonical — see master §4.1)

Section titled “2.1 users (canonical — see master §4.1)”

Extends master User with onboarding bookkeeping:

ColumnTypeNotes
idtext PKUUID v4
emailtext UNIQUElower-cased, RFC 5321
email_verified_attext NULLDOI redemption
company_name, countrytextcountry ISO-3166-1 alpha-2
vat_idtext NULLnormalised; NULL non-EU
vat_validation_statustextpending | vies_ok | vies_degraded_pending | manual_approved | rejected
vat_validation_checked_attext NULLlast VIES success
stripe_customer_idtext NULLset on first-bid
default_payment_method_idtext NULLafter SetupIntent succeeded
pm_last4, pm_exp_month, pm_exp_yeartext NULLfor UI + pre-close health check
kyc_leveltextnone | identity_required | identity_passed | identity_failed
tiertextspectator / contender / challenger / champion
strikesint0..3
banned_attext NULL
locale, created_attexten|de; ISO ts

Note: vies_degraded_pending extends master §4.1’s enum under §10.4; resolves within 72 h via the retry queue (§4).

Key sess:<opaque-256-bit> (URL-safe base64); value { user_id, issued_at, last_seen_at, ip_hash, ua_hash }; TTL 30 d sliding, cap 90 d absolute. Cookie nyl_sess: HttpOnly; Secure; SameSite=Lax; Domain=.nowyourlink.com; Path=/. DO-memory cache in front of KV (master §10.2).

id PK, user_id FK, vat_id (snapshot), attempt_at, outcome (valid\|invalid\|service_unavailable\|timeout\|format_error), vies_request_id NULL, raw_response NULL — append-only audit trail.

id PK (Stripe seti_…), user_id FK, client_secret_issued_at, status (mirrors Stripe: requires_payment_method\|requires_confirmation\|requires_action\|succeeded\|canceled), last_error_code NULL, next_action_type NULL, payment_method_id NULL, pending_bid_token NULL.

id PK, user_id FK, reason (bid_over_500_new_account\|signature_suspicious\|manual_admin\|chargeback_history\|vies_degraded_first_bid), stripe_identity_session_id NULL (vs_…), verification_url NULL, status (open\|verification_pending\|verified\|rejected\|cancelled), created_at, resolved_at.

Reason vies_degraded_first_bid covers §7 trigger #2 (master D30); added in migration 0003_kyc_escalation_vies_degraded.sql. verification_url is populated when the Stripe Identity session is created in-band so POST /first-bid/setup can return the same URL on idempotent retries without re-calling Stripe.


All on api.nowyourlink.com. Error envelope per master §4.5. Framework: Hono (per ADR-0001 / D40); routes registered as Hono handlers in apps/api/src/app.ts. :id-style segments become Hono path params via c.req.param('id').

Method + PathAuthPurpose
POST /registernone + Turnstile{email, company_name, country, vat_id?, locale} → inserts user (pending, unverified), emits DOI via Resend. Idempotent on email.
POST /verify-emailDOI token{token} → marks email_verified_at, kicks VIES (sync; falls back to queue, §4).
POST /magic-linknone + Turnstile{email} → always 204. If verified + not banned, mails link.
GET /auth/callback?token=…magic-linkOne-shot redemption → sets nyl_sess cookie, 302 to app.
POST /logoutsessionDeletes KV, clears cookie.
GET /mesessionProjection: id, email, tier, kyc_level, vat_validation_status, default_pm summary, strikes, banned_at.
POST /first-bid/setupsession{auction_id, amount_cents, ad_id} → lazy stripe.customers.create, stripe.setupIntents.create, signed pending_bid_token (15-min TTL). Returns {client_secret, setup_intent_id, pending_bid_token, escalation?}. If amount > 50000 cents AND account < 14 d → opens kyc_escalations and returns escalation={type:"stripe_identity", verification_url}.
POST /first-bid/confirmsession{setup_intent_id, pending_bid_token} → stripe.setupIntents.retrieve; if succeeded, stripe.paymentMethods.attach + stripe.customers.update({invoice_settings.default_payment_method}), emit bid.placed to 04. If requires_action → 409 + next_action.
POST /webhooks/stripeStripe sigRoutes setup_intent.*, identity.verification_session.*; other events → 05.
POST /admin/users/:id/{manual-approve|reject|escalate-identity}CF AccessAdmin actions on a user.
GET /admin/queue/{non-eu|vies-degraded}CF AccessAdmin work queues.

  • Endpoint: https://ec.europa.eu/taxation_customs/vies/services/checkVatService (SOAP 1.1, checkVat).
  • EU-27 country set: AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK (GR → EL on wire). Non-EU skip VIES → admin queue.
  • Per request: 4 s connect, 6 s total. Single inline attempt on POST /verify-email.
  • Degraded fallback (§10.4): on timeout / 5xx / SERVICE_UNAVAILABLE / MS_UNAVAILABLE / GLOBAL_MAX_CONCURRENT_REQ → status vies_degraded_pending, enqueue q-vies-retry. Backoff 5 m / 15 m / 1 h / 4 h / 12 h / 24 h (6 attempts, ~72 h). valid→vies_ok; INVALID_INPUT→rejected; exhausted→admin queue.
  • Bidding allowed during vies_degraded_pending (else outages block revenue), but auto-opens a kyc_escalations if first bid > 100 € (D30).
  • Attempts logged to vat_validation_attempts. Successful results cached 90 d; re-validate on vat_id change or before invoice handoff to 05. VIES is advisory; Stripe Tax is the legal source at invoice time.

5. Stripe SetupIntent flow (off-session B2B card)

Section titled “5. Stripe SetupIntent flow (off-session B2B card)”

Trigger: first authenticated POST /first-bid/setup.

  1. Lazy Customer (idempotency key customer:${user_id}): if stripe_customer_id IS NULL, stripe.customers.create({ email, name: company_name, address:{country}, tax_id_data:[{type:'eu_vat'|'de_ustid_nr', value:vat_id}] }) (omit tax_id_data for non-EU). Persist cus_…. For never_attempted, atomically change stripe_customer_creation_state to started against the current identity before calling Stripe, then re-read it. An unpersisted started request stays locked because Stripe may have created the Customer despite a lost response. The first rollout leaves migrated and newly registered rows unknown: company edits stay locked, while the existing Customer creation path continues. A later activation may mark new registrations never_attempted only after all older API Workers have drained; historical unknown rows need Stripe reconciliation before company edits.
  2. SetupIntent (idempotency setup:${user_id}:${pending_bid_token}): stripe.setupIntents.create({ customer, usage:'off_session', automatic_payment_methods:{ enabled:true, allow_redirects:'never' }, metadata:{ user_id, auction_id, pending_bid_token } }). allow_redirects:'never' keeps MVP card-only per D10.
  3. Client confirms with stripe.confirmSetup (Stripe.js Elements); inline 3DS.
  4. Server-side POST /first-bid/confirm always re-fetches stripe.setupIntents.retrieve — never trusts the client outcome alone.
  5. On succeeded: stripe.paymentMethods.attach(pm, {customer}) (idempotent) + stripe.customers.update(customer, { invoice_settings:{ default_payment_method: pm } }). Persist pm_last4, pm_exp_*. Hand off pending_bid_token to 04 as bid.placed.

requires_action: client SDK opens modal; if abandoned, /first-bid/confirm returns 409 setup.requires-action, no bid placed. Bid persisted only after succeeded. pending_bid_token TTL 15 min; on expiry restart at /first-bid/setup.

Stripe codeUI message (EN)Retryable
card_declined (generic / insufficient_funds)“Card was declined / insufficient funds — try another card.”new card
expired_card”Card expired.”new card
incorrect_cvc”Security code didn’t match.”yes
authentication_required”Your bank requires 3-D Secure verification.”resume
setup_intent_authentication_failure”Verification with your bank failed.”yes
rate_limitGeneric retry banner.auto 1×

  • Token: 256-bit random, base64url. KV mlink:<token> → {user_id, created_at, used:false}. TTL: 15 min. Single-use; redemption deletes the key.
  • URL: https://nowyourlink.com/auth/callback?token=….
  • Rate limits (per email + per IP, KV sliding counters): 1/30 s, 5/h, 20/24 h. Overrun returns 204 (no enumeration), no mail sent, log auth.magic_link.rate_limited. Turnstile required on POST /magic-link.
  • Lockout: 10 failed redemptions for the same email within 1 h → locked_at; admin clears, or auto-clears in 24 h.
  • Replay protection: token deleted on first redemption (KV get+delete with CAS); concurrent redemptions resolve to one winner.

Triggered when: (1) first bid > 500 € AND account < 14 d old; (2) VIES degraded AND first bid > 100 € (D30); (3) admin force via /admin/users/:id/escalate-identity; (4) ≥3 chargebacks on the same VAT-ID hash (cross-account, from 05).

Flow: stripe.identity.verificationSessions.create({ type:'document', metadata:{ user_id }, options:{ document:{ require_matching_selfie:true, require_live_capture:true } } }). Persist vs_…, return verification_url. Webhook identity.verification_session.verified → kyc_level='identity_passed', mark verified, release staged bid. …requires_input / …canceled → Resend notice; bid blocked; repeats → admin queue.

Gating: while a kyc_escalations row is open / verification_pending, POST /first-bid/confirm returns 403 kyc.identity-required and 04 refuses bids (reads kyc_level via the USER_READ binding, §8).


8. Strike-bookkeeping integration with saga (05) and admin (10)

Section titled “8. Strike-bookkeeping integration with saga (05) and admin (10)”

This sub-system owns writes to users for: email_verified_at, vat_validation_status, stripe_customer_id, default_payment_method_id, pm_*, kyc_level, tier, locale. It exposes reads + write hooks via Service Binding USER_READ:

  • incrementStrike(user_id, reason) — called by 05. Atomic D1 UPDATE users SET strikes = strikes + 1, banned_at = CASE WHEN strikes + 1 >= 3 THEN now() ELSE banned_at END WHERE id=?. Emits saga.strike_added.
  • markPmExpired(user_id) / refreshPmHealth(user_id) — 05 pre-close health check.
  • getBiddingEligibility(user_id) → { eligible, reasons[] } — 04 calls before accepting a bid. Reasons: banned, email_unverified, vat_rejected, kyc_pending, pm_missing.
  • Admin endpoints (§3) for 10: approve, reject, force-escalate, clear strikes — each writes to append-only user_admin_actions.

Strikes survive DSGVO deletion as (hashed_vat_id, strikes, banned_at) per master §4.1; the routine in 12 calls anonymizeUser(user_id) here, which retains only that row.


  1. Duplicate email — /register idempotent: unverified → resend DOI (rate-limited); verified → 204 with no leak (user gets magic-link).
  2. VAT-ID changed after vies_ok — reset to pending, re-run VIES, block bids until resolved; update Customer tax_id_data via stripe.customers.update.
  3. Card expires — webhook payment_method.detached or pre-close health check nulls default_payment_method_id; next bid forces a new SetupIntent; mid-flight expiry → 05 cascades.
  4. DSGVO delete with unpaid invoices — block, return legal.unpaid-invoices. Once cleared (or credit-noted), anonymise to deterministic hashes, retain strikes row.
  5. Non-EU registration — vat_id=NULL, status pending, routed to /admin/queue/non-eu; bidding blocked until manual_approved. 05 marks invoices “Nicht steuerbar”.
  6. VIES vs Stripe Tax discrepancy — Stripe Tax wins at invoice time; VIES informational. Log + admin alert.
  7. Magic-link mail bounces — Resend bounce webhook sets email_bounced_at; further requests silently 204.
  8. SetupIntent succeeds but pending_bid_token expired — PM saved, bid dropped with UX message to re-bid; PM lifecycle deliberately decoupled from bid lifecycle.

  1. New EU registrant: register → DOI → vies_ok (or vies_degraded_pending if VIES down) → magic-link → /me with session, end-to-end <60 s when VIES healthy.
  2. First-bid by a user without saved PM yields a client_secret; after 3DS the bid is accepted by 04.
  3. Subsequent bids do not trigger a new SetupIntent.
  4. 600 € bid from a 5-day-old account triggers Stripe Identity; bid staged, not placed, until verified.
  5. Simulated VIES 503 does not block registration; degraded state retries and resolves on recovery; exhaustion → admin queue at 72 h.
  6. 11 magic-link requests in 30 s for the same email all return 204; only 1 mail sent.
  7. Admin can approve / reject / force-escalate / clear-strikes; every action audit-logged.
  8. DSGVO delete with no open invoices anonymises and keeps strikes; with open invoices returns legal.unpaid-invoices.

  • Owner: backend agent.
  • Wrangler bindings: D1 DB; KV SESSIONS, RATE, AUTH_TOKENS (carries mlink: magic-link records AND doi: double-opt-in records AND lockout: rows — the name reflects the union, not just magic-links; renamed from MAGIC_LINKS in PR #53 to avoid misleading future maintainers); Queue producer q-vies-retry + consumer worker; secrets STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY, TURNSTILE_SECRET_KEY; service binding USER_READ exposed to 04 + 05.
  • Stripe API surface: stripe.customers.create, stripe.customers.update, stripe.setupIntents.create, stripe.setupIntents.retrieve, stripe.paymentMethods.attach, stripe.identity.verificationSessions.create. No invoice/charge calls — those belong to 05.
  • Webhooks handled here: setup_intent.succeeded, setup_intent.setup_failed, setup_intent.requires_action, payment_method.detached, identity.verification_session.{verified,requires_input,canceled}. Other Stripe events forwarded to 05.
  • Sentry tags: subsystem=identity-onboarding, vies_status, setup_intent_status.
  • Resolved (D30): vies_degraded_pending bidders trigger Identity escalation at the 100 € threshold (not 200 €). Confirmed by 12-legal-compliance.
  • Routing (D40 / ADR-0001): routes registered on the Hono app in apps/api/src/app.ts. Sub-feature route modules can be mounted via app.route('/', identityRoutes). The POST /webhooks/stripe route (spec 05 §6) uses a raw-body Hono middleware before Stripe.webhooks.constructEventAsync.