Skip to content

DOMAIN:EU_REGULATION:KYC_IMPLEMENTATION

OWNER: eric ALSO_USED_BY: julian, aimee, hugo, ALL dev agents building regulated features UPDATED: 2026-03-26 SCOPE: Technical implementation guide for KYC features — providers, integrations, schemas, flows


PURPOSE

This page provides technical guidance for implementing KYC in software. Covers identity verification providers, integration patterns, database design, and audit trail requirements. For regulatory context see kyc-aml.md. For process detail see kyc-processes.md.


EU-BASED IDENTITY VERIFICATION PROVIDERS

PROVIDER COMPARISON

Provider HQ Strengths Best For Pricing Compliance
Signicat Norway Nordic eID schemes, eSignatures, BankID Nordic/EU regulated sectors Enterprise eIDAS, ETSI
Veriff Estonia 6-sec avg, 98% automation, 12000+ doc types Fintech, crypto, marketplaces Mid-high ISO 30107-3, iBeta L2
iDenfy Lithuania Pay-per-success, human-in-loop, 3D liveness SMBs, startups Flexible ISO 30107-3
IDnow Germany VideoIdent (human), AutoIdent (AI), BaFin-approved German regulated markets Enterprise BaFin, eIDAS
Sumsub UK End-to-end KYC/KYB/AML, fraud prevention Fintech, crypto, platforms Mid SOC 2, ISO 27001
Onfido UK (Entrust) AI-driven doc + biometric, 1100+ orgs Broad EU/UK market Mid-high ISO 27001, SOC 2

POST-BREXIT ASSESSMENT (UK PROVIDERS)

STATUS: EU adequacy decision renewed Dec 2025, valid until Dec 2031. IMPLICATION: data transfers to UK-based providers (Onfido, Sumsub) remain lawful under GDPR. RISK: UK Data (Use and Access) Act 2025 may widen GDPR divergence — monitor EDPB assessments. MITIGATION: ensure UK provider uses EU data centres and Standard Contractual Clauses (SCCs). RECOMMENDATION: prefer EU-headquartered providers (Veriff, iDenfy, IDnow, Signicat) for new integrations. REASON: removes dependency on adequacy decision renewal and reduces regulatory uncertainty.

PROVIDER SELECTION CRITERIA

MUST_HAVE: - ISO 30107-3 or iBeta Level 2 certification for liveness detection - EU data centres (or contractual commitment to EU data residency) - GDPR DPA (Data Processing Agreement) with clear processor role - webhook support for async verification results - sandbox/test environment - document coverage for EU/EEA ID documents (minimum)

SHOULD_HAVE: - NFC chip reading capability - eIDAS 2.0 / EUDI Wallet readiness (for future-proofing) - KYB (Know Your Business) features for legal entity verification - ongoing monitoring/screening (PEP, sanctions) - multi-language support (NL, EN, DE, FR at minimum for EU projects) - SOC 2 Type II certification

NICE_TO_HAVE: - white-label/embedded SDK - native mobile SDK (iOS Swift, Android Kotlin) - address verification - proof of address document verification - fraud signal sharing


NL-SPECIFIC VERIFICATION TOOLS

iDIN (Bank-Based Identification)

WHAT: digital identification via Dutch banks (ABN AMRO, ING, Rabobank, etc.) HOW: customer authenticates with their bank — bank shares verified identity attributes ATTRIBUTES: name, date of birth, gender, address (bank-verified) ASSURANCE: HIGH — bank has already performed KYC on the individual USE_CASE: supplement or replace document verification for Dutch residents LIMITATION: only works for customers with a Dutch bank account LIMITATION: does not provide photo or biometric data INTEGRATION: via Currence (iDIN scheme operator) or through PSP (Mollie, Adyen)

WHEN_TO_USE: - standard CDD for Dutch natural persons - fast onboarding where bank-verified identity is sufficient - combine with document photo for biometric match if needed

DigiD

WHAT: Dutch government digital identity for citizens/residents ASSURANCE: SUBSTANTIAL to HIGH (depending on authentication level) LEVELS: - DigiD Basis: username + password (low assurance — not sufficient for KYC) - DigiD Midden: + SMS verification (substantial) - DigiD Hoog: + ID document scan or DigiD app with NFC (high) USE_CASE: government services, healthcare — NOT typically available for private-sector KYC LIMITATION: only available for interactions with government-connected services NOTE: eIDAS 2.0 EUDI Wallet will eventually supersede DigiD for cross-border use

KvK API (Handelsregister API)

ENDPOINT: api.kvk.nl AUTH: API key (register at developers.kvk.nl) RETURNS: legal name, KvK number, legal form, registration date, SBI codes, addresses DOES_NOT_RETURN: UBO data, financial data RATE_LIMIT: depends on subscription tier COST: per-query pricing (check current kvk.nl developer portal)

USE_IN_KYC: - automate business client verification at onboarding - validate KvK number provided by client - retrieve and store authorised signatory information - periodic re-verification via scheduled API calls

UBO API (coming Q2 2026)

STATUS: KvK announced UBO API availability from Q2 2026 ACCESS: requires eHerkenning authentication RESTRICTION: access level depends on organisation type and legal basis FOR_GE: GE is not a Wwft obliged entity — access level to be confirmed ALTERNATIVE: request UBO declaration directly from the client


INTEGRATION PATTERNS

REDIRECT FLOW

HOW: user is redirected from your application to the provider's verification page. AFTER: provider redirects back with a verification session ID. RESULT: your backend polls or receives webhook with verification outcome.

PROS: - simplest integration (just a URL redirect) - provider handles all UI, camera access, document capture - provider handles browser/device compatibility

CONS: - user leaves your application (brand continuity broken) - less control over UX - mobile: may open browser from app (context switch)

BEST_FOR: MVPs, low-volume applications, quick time-to-market.

EMBEDDED SDK

HOW: provider's SDK is embedded in your frontend (web SDK or mobile SDK).

WEB_SDK: - JavaScript/TypeScript SDK loaded in your page - renders verification flow in an iframe or overlay - communicates results via JavaScript callbacks or postMessage

MOBILE_SDK: - native iOS (Swift) or Android (Kotlin) SDK - integrated into your app's navigation flow - provides camera access, NFC reading, liveness detection natively

PROS: - user stays in your application - brand-consistent UX - better mobile experience (native camera, NFC) - more control over flow and error handling

CONS: - more integration effort - SDK version management and updates - larger app bundle size (mobile)

BEST_FOR: production applications, mobile-first products, UX-sensitive projects.

API-ONLY

HOW: your application captures images/data and sends to provider API. YOU_HANDLE: camera capture, document scanning, selfie capture PROVIDER_HANDLES: document analysis, liveness analysis, matching, decision

PROS: - full control over UX - no third-party UI code - works for backend-to-backend integrations

CONS: - most complex integration - you own camera capture quality (significant source of failures) - you must handle image quality validation before submission - may require building liveness detection UI

BEST_FOR: highly customised flows, enterprise integrations, server-to-server processing.


DOCUMENT PROCESSING

OCR PIPELINE

CAPTURE → QUALITY_CHECK → CLASSIFY → EXTRACT → VALIDATE → STORE

  1. CAPTURE: user photographs or uploads document image
  2. QUALITY_CHECK: verify image resolution, blur, glare, cropping
  3. CLASSIFY: determine document type (passport, ID card, driving licence, etc.)
  4. EXTRACT: OCR text from visual zone; parse MRZ if present
  5. VALIDATE: cross-check MRZ against visual zone data; verify check digits
  6. STORE: encrypted, access-controlled storage of document image and extracted data

QUALITY_GUIDANCE_FOR_USERS: - flat surface, good lighting, no shadows - all four corners visible - no fingers covering text or photo - both sides for ID cards

MRZ READING

FORMAT: ICAO Doc 9303 PASSPORT: 2-line MRZ (44 chars each) ID_CARD: 3-line MRZ (30 chars each)

FIELDS_EXTRACTED: - document type - issuing country (3-letter code) - surname, given names - document number + check digit - nationality - date of birth + check digit - sex - expiry date + check digit - composite check digit

VALIDATION: - compute check digits and compare against MRZ values - any mismatch = potential tampering or OCR error - cross-reference extracted name/DOB against visual zone

NFC CHIP VERIFICATION

PROTOCOL: ISO 14443 (contactless communication) DATA_GROUPS: DG1 (MRZ data), DG2 (facial image), DG3 (fingerprints, if stored)

PASSIVE_AUTHENTICATION: - read Security Object (SOD) from chip - verify digital signature chain: chip → document signer → country signing CA - proves data was written by legitimate issuing authority - does NOT prove chip has not been cloned

ACTIVE_AUTHENTICATION: - chip signs a random challenge with its private key - proves the physical chip is original (not a clone) - not all documents support this

CHIP_ACCESS_CONTROL: - BAC (Basic Access Control): requires MRZ data to unlock chip (legacy) - PACE (Password Authenticated Connection Establishment): more secure, newer documents - reading app must implement both BAC and PACE for broad compatibility

MOBILE_IMPLEMENTATION: - iOS: Core NFC framework (iPhone 7+) - Android: android.nfc package - user holds document against phone's NFC antenna - read time: 2-5 seconds typically


LIVENESS DETECTION IMPLEMENTATION

ACTIVE LIVENESS

FLOW: 1. prompt user to position face in frame 2. request specific action: turn head left, turn head right, blink, smile 3. capture video frames during action 4. analyse motion, depth, texture 5. return liveness score + confidence

ACCESSIBILITY: - must provide alternative for users with mobility restrictions - consider: passive liveness fallback + manual review

PASSIVE LIVENESS

FLOW: 1. prompt user to take selfie or short video (no specific actions) 2. AI analyses image/video for: skin texture, depth cues, light reflection, micro-movements 3. detect: printed photos, screen replay, 3D masks, deepfake injection 4. return liveness score + confidence

INJECTION_ATTACK_DETECTION: - detect virtual cameras, screen recording, video injection at SDK level - verify image metadata (EXIF, device info) - provider SDK should validate camera stream integrity


DATABASE SCHEMA (DRIZZLE PATTERNS)

KYC RECORDS TABLE

export const kycRecords = pgTable('kyc_records', {
  id: uuid('id').defaultRandom().primaryKey(),
  clientId: uuid('client_id').notNull().references(() => clients.id),
  verificationType: text('verification_type').notNull(), // 'individual' | 'business'
  status: text('status').notNull().default('pending'),
  riskLevel: text('risk_level'), // 'low' | 'medium' | 'high'
  dueDiligenceLevel: text('due_diligence_level'), // 'sdd' | 'cdd' | 'edd'
  providerSessionId: text('provider_session_id'), // external provider reference
  providerName: text('provider_name'), // 'veriff' | 'idenfy' | 'idnow' | etc.
  verifiedAt: timestamp('verified_at'),
  expiresAt: timestamp('expires_at'), // next periodic review due date
  reviewedBy: text('reviewed_by'), // agent or person who reviewed
  rejectionReason: text('rejection_reason'),
  metadata: jsonb('metadata'), // provider-specific data
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

KYC CHECKS TABLE

export const kycChecks = pgTable('kyc_checks', {
  id: uuid('id').defaultRandom().primaryKey(),
  kycRecordId: uuid('kyc_record_id').notNull().references(() => kycRecords.id),
  checkType: text('check_type').notNull(),
  // 'document_verification' | 'liveness' | 'face_match' | 'pep_screening' |
  // 'sanctions_screening' | 'ubo_verification' | 'kvk_extract' | 'address_verification'
  status: text('status').notNull().default('pending'),
  // 'pending' | 'passed' | 'failed' | 'needs_review' | 'expired'
  score: real('score'), // provider confidence score (0-1)
  result: jsonb('result'), // provider-specific result payload
  performedAt: timestamp('performed_at'),
  performedBy: text('performed_by'), // 'automated' | agent name
  notes: text('notes'), // reviewer notes
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

KYC DOCUMENTS TABLE

export const kycDocuments = pgTable('kyc_documents', {
  id: uuid('id').defaultRandom().primaryKey(),
  kycRecordId: uuid('kyc_record_id').notNull().references(() => kycRecords.id),
  documentType: text('document_type').notNull(),
  // 'passport' | 'id_card' | 'driving_licence' | 'residence_permit' |
  // 'kvk_extract' | 'ubo_declaration' | 'proof_of_address' | 'shareholder_register'
  storagePath: text('storage_path').notNull(), // encrypted storage location
  extractedData: jsonb('extracted_data'), // OCR/MRZ extracted fields
  expiryDate: date('expiry_date'), // document expiry
  issuingCountry: text('issuing_country'), // ISO 3166-1 alpha-2
  retainUntil: date('retain_until').notNull(), // 5 years after relationship end
  deletedAt: timestamp('deleted_at'), // soft delete after retention period
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

KYC AUDIT LOG TABLE

export const kycAuditLog = pgTable('kyc_audit_log', {
  id: uuid('id').defaultRandom().primaryKey(),
  kycRecordId: uuid('kyc_record_id').notNull().references(() => kycRecords.id),
  action: text('action').notNull(),
  // 'created' | 'check_performed' | 'status_changed' | 'risk_updated' |
  // 'reviewed' | 'approved' | 'rejected' | 'escalated' | 'document_uploaded' |
  // 'document_deleted' | 'periodic_review' | 'sar_filed'
  previousValue: jsonb('previous_value'),
  newValue: jsonb('new_value'),
  performedBy: text('performed_by').notNull(), // agent name or 'system'
  reason: text('reason'),
  ipAddress: text('ip_address'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

INDEX RECOMMENDATIONS

CREATE INDEX idx_kyc_records_client ON kyc_records(client_id);
CREATE INDEX idx_kyc_records_status ON kyc_records(status);
CREATE INDEX idx_kyc_records_expires ON kyc_records(expires_at) WHERE status = 'approved';
CREATE INDEX idx_kyc_checks_record ON kyc_checks(kyc_record_id);
CREATE INDEX idx_kyc_checks_type_status ON kyc_checks(check_type, status);
CREATE INDEX idx_kyc_documents_record ON kyc_documents(kyc_record_id);
CREATE INDEX idx_kyc_documents_retain ON kyc_documents(retain_until) WHERE deleted_at IS NULL;
CREATE INDEX idx_kyc_audit_record ON kyc_audit_log(kyc_record_id);
CREATE INDEX idx_kyc_audit_action ON kyc_audit_log(action, created_at);

STATUS MACHINE

KYC RECORD STATES

                    ┌──────────────┐
                    │   pending    │
                    └──────┬───────┘
                           │ checks started
                    ┌──────▼───────┐
              ┌─────┤  in_review   ├─────┐
              │     └──────┬───────┘     │
              │            │             │
     ┌────────▼───┐  ┌─────▼─────┐  ┌───▼──────────┐
     │  approved  │  │ rejected  │  │ more_info    │
     └────────┬───┘  └───────────┘  │ _needed      │
              │                     └───────┬──────┘
              │                             │ info provided
              │                     ┌───────▼──────┐
              │                     │  in_review   │
              │                     └──────────────┘
     ┌────────▼───────┐
     │ periodic_review │ (triggered by expiry date)
     └────────┬───────┘
     ┌────────▼───┐
     │  in_review  │ (back to review cycle)
     └────────────┘

TRANSITIONS: - pending → in_review: first verification check initiated - in_review → approved: all required checks passed, reviewer approves - in_review → rejected: check failed and cannot be remediated - in_review → more_info_needed: additional documentation required from customer - more_info_needed → in_review: customer provides requested information - approved → periodic_review: scheduled review date reached - periodic_review → in_review: review process started - any state → rejected: if sanctions match confirmed or relationship must be exited

CONSTRAINTS: - only "approved" status permits active business relationship - "pending" and "in_review" may proceed with limited activity if risk is low - "rejected" is final unless overridden with documented justification - every transition MUST be logged in kyc_audit_log


WEBHOOK PATTERNS

RECEIVING VERIFICATION RESULTS

FLOW: 1. your backend creates a verification session via provider API 2. provider returns session ID and redirect URL (or SDK token) 3. user completes verification on provider side 4. provider sends webhook to your registered endpoint 5. your backend processes result, updates KYC record status

WEBHOOK_SECURITY: - verify webhook signature (HMAC or RSA, provider-specific) - validate source IP against provider's published IP ranges (if available) - use HTTPS endpoint only - implement idempotency: process each webhook event at most once - use webhook ID or event ID as deduplication key

WEBHOOK_PAYLOAD (typical):

{
  "event": "verification.completed",
  "session_id": "abc-123",
  "status": "approved",
  "checks": {
    "document": { "status": "passed", "confidence": 0.97 },
    "liveness": { "status": "passed", "confidence": 0.99 },
    "face_match": { "status": "passed", "confidence": 0.94 }
  },
  "extracted_data": {
    "first_name": "Jan",
    "last_name": "de Vries",
    "date_of_birth": "1985-03-15",
    "document_number": "ABC123456",
    "nationality": "NLD"
  },
  "timestamp": "2026-03-26T14:30:00Z"
}

RETRY_HANDLING: - provider will retry failed webhook deliveries (typically 3-5 times) - implement 200 OK response immediately, process asynchronously - if webhook missed: poll provider API as fallback

FALLBACK POLLING

WHEN: webhook not received within expected timeframe (provider-dependent, typically 5-15 min). HOW: poll provider's session status API with exponential backoff. MAX_POLL: 24 hours, then flag for manual review.


AUDIT TRAIL REQUIREMENTS

WHAT MUST BE LOGGED

EVERY_CHECK: - check type, timestamp, result, confidence score - provider name and session ID - raw provider response (stored encrypted)

EVERY_DECISION: - who made the decision (agent name or "automated") - what decision was made (approve, reject, request more info, escalate) - reasoning for the decision - timestamp

EVERY_DOCUMENT: - document type, issuing country, expiry date - upload timestamp - who uploaded (customer or agent) - storage location (encrypted reference) - retention period and scheduled deletion date

EVERY_SCREENING: - lists screened against - matches found (including false positives) - investigation notes for each match - clearance reasoning for false positives

IMMUTABILITY

RULE: audit log entries must NEVER be modified or deleted. IMPLEMENTATION: append-only table, no UPDATE or DELETE permissions on kyc_audit_log. BACKUP: audit log included in database backup rotation. RETENTION: audit logs retained for same period as KYC records (5 years + buffer).

REGULATORY AUDIT READINESS

MUST_PRODUCE_ON_REQUEST: - complete KYC file for any customer (all checks, decisions, documents) - timeline of all actions taken - evidence of periodic reviews performed - evidence of sanctions/PEP screening and results - risk assessment and reasoning

FORMAT: must be retrievable in machine-readable format (AMLR requirement from 2027).


RE-VERIFICATION TRIGGERS

AUTOMATIC TRIGGERS

PERIODIC_REVIEW_DUE: - based on risk level: 12/24/36 month cycles - system generates review task when expires_at is reached - assign to Eric (for GE clients) or appropriate compliance agent

DOCUMENT_EXPIRY: - passport/ID card approaching expiry - trigger re-verification 3 months before document expiry

SANCTIONS_LIST_UPDATE: - when monitored sanctions lists are updated - re-screen affected customer segments

MANUAL TRIGGERS

MATERIAL_CHANGE: - change of ownership (UBO change) - change of business activity - change of registered address - change of directors/signatories

RISK_ESCALATION: - adverse media report - suspicious transaction detected - intelligence from law enforcement - client associated with investigated entity

REGULATORY_CHANGE: - new sanctions regime applicable - client's sector becomes regulated - jurisdiction risk level changes


eIDAS 2.0 / EUDI WALLET PREPARATION

TIMELINE: - Dec 2026: all EU Member States must provide EUDI Wallets to citizens - Nov 2027: mandatory acceptance by banks, PSPs, large online platforms - 2030: EU target of 80% citizen adoption

WHAT_TO_BUILD_NOW: - design KYC flows to accept alternative identity credentials (not just document scan) - abstract identity verification behind provider interface - plan for wallet-based selective disclosure (verify attributes without full identity) - monitor EUDI Wallet technical specifications (ongoing through 2026)

WHAT_NOT_TO_BUILD_YET: - no production-ready EUDI Wallets exist as of Mar 2026 - implementing specifications and APIs are still in draft - wait for stable technical standards before implementation - plan to integrate once Member State wallet apps are available

ARCHITECTURE_PRINCIPLE: - design verification interface to be provider-agnostic - support multiple verification methods per flow - wallet verification will be one method alongside document + liveness


READ_ALSO: kyc-aml.md, kyc-processes.md, kyc-data-retention.md, kyc-for-platforms.md