KYC, AML, and Risk: What to Build First (and What to Defer)
Compliance requirements can paralyze fintech startups. Learn which KYC/AML controls to implement immediately and which can wait until you have traction.
Start with basic KYC (identity verification), simple transaction limits, and audit logging. Use a third-party KYC provider (Onfido, Persona) to move fast. Defer enhanced due diligence and sophisticated AML rule engines until you have enough transaction data to make them meaningful. Document everything—compliance is about proving what you did.
Understanding the Regulatory Landscape
Before building anything, understand what you're actually required to do. KYC, AML, and sanctions screening are related but distinct:KYC (Know Your Customer)Identity verification—confirming that customers are who they claim to be. This includes:- Collecting identifying information (name, address, date of birth)- Verifying identity documents (passport, driver's license)- Matching the person to the documents (liveness checks, selfie matching)AML (Anti-Money Laundering)Detecting and preventing money laundering through your platform. This includes:- Transaction monitoring for suspicious patterns- Reporting suspicious activity to regulators (SARs)- Enhanced due diligence for high-risk customersSanctions ScreeningChecking customers and transactions against sanctions lists (OFAC, UN, EU):- Screening at onboarding and ongoing- Blocking transactions with sanctioned entities- Real-time screening for high-risk geographiesJurisdiction Matters:Requirements vary dramatically by jurisdiction:| Jurisdiction | KYC Required? | AML Program? | Key Regulator ||--------------|---------------|--------------|---------------|| United States | Yes (FinCEN) | Yes (BSA) | FinCEN, State regulators || United Kingdom | Yes | Yes | FCA || European Union | Yes (AMLD6) | Yes | Local regulators || Singapore | Yes | Yes | MAS |Risk-Based Approach:Modern regulations don't require treating every customer identically. A risk-based approach means:- Higher scrutiny for high-risk customers (PEPs, high-risk countries, large transactions)- Lighter touch for low-risk customers (small transactions, established identities)- Your risk assessment methodology must be documented and defensible
Phase 1: MVP Compliance (Build First)
When launching a fintech MVP, you need these baseline controls. Anything less puts your license (and your founders) at risk.Basic Identity VerificationAt minimum, collect and verify:- Full legal name- Date of birth- Address- Government ID (passport or driver's license)- Selfie for liveness checkImplementation with Onfido:
1 import { Onfido } from '@onfido/api';
2
3 const onfido = new Onfido({ apiToken: process.env.ONFIDO_API_TOKEN });
4
5 async function verifyCustomer(customerId: string, userData: CustomerData) {
6 // Create applicant in Onfido
7 const applicant = await onfido.applicant.create({
8 firstName: userData.firstName,
9 lastName: userData.lastName,
10 email: userData.email,
11 dob: userData.dateOfBirth,
12 });
13
14 // Create verification check
15 const check = await onfido.check.create({
16 applicantId: applicant.id,
17 reportNames: ['document', 'facial_similarity_photo'],
18 });
19
20 // Store result and update customer status
21 await db.customer.update({
22 where: { id: customerId },
23 data: {
24 kycStatus: 'pending',
25 onfidoApplicantId: applicant.id,
26 onfidoCheckId: check.id,
27 }
28 });
29
30 return check.id;
31 }Simple Transaction LimitsUntil you have sophisticated monitoring, use hard limits:
1 const DAILY_LIMIT = 10000; // $10,000
2 const MONTHLY_LIMIT = 50000; // $50,000
3 const SINGLE_TRANSACTION_LIMIT = 5000; // $5,000
4
5 async function checkTransactionLimits(
6 customerId: string,
7 amount: number
8 ): Promise<{ allowed: boolean; reason?: string }> {
9 const customer = await db.customer.findUnique({
10 where: { id: customerId },
11 include: { transactions: {
12 where: { createdAt: { gte: startOfDay() } }
13 }}
14 });
15
16 // Check single transaction limit
17 if (amount > SINGLE_TRANSACTION_LIMIT) {
18 return { allowed: false, reason: 'exceeds_single_limit' };
19 }
20
21 // Check daily limit
22 const dailyTotal = customer.transactions.reduce((sum, t) => sum + t.amount, 0);
23 if (dailyTotal + amount > DAILY_LIMIT) {
24 return { allowed: false, reason: 'exceeds_daily_limit' };
25 }
26
27 // Check monthly limit
28 const monthlyTransactions = await db.transaction.aggregate({
29 where: { customerId, createdAt: { gte: startOfMonth() } },
30 _sum: { amount: true }
31 });
32 if ((monthlyTransactions._sum.amount || 0) + amount > MONTHLY_LIMIT) {
33 return { allowed: false, reason: 'exceeds_monthly_limit' };
34 }
35
36 return { allowed: true };
37 }Basic Transaction MonitoringLog every transaction with metadata for later analysis:
1 async function logTransaction(transaction: Transaction) {
2 await db.transactionLog.create({
3 data: {
4 transactionId: transaction.id,
5 customerId: transaction.customerId,
6 amount: transaction.amount,
7 currency: transaction.currency,
8 type: transaction.type,
9 sourceIp: transaction.metadata.ip,
10 deviceFingerprint: transaction.metadata.deviceId,
11 geolocation: transaction.metadata.geo,
12 timestamp: new Date(),
13 riskScore: await calculateBasicRiskScore(transaction),
14 }
15 });
16 }Audit LoggingEvery action that affects compliance should be logged immutably:
1 async function auditLog(event: AuditEvent) {
2 await db.auditLog.create({
3 data: {
4 eventType: event.type,
5 actorId: event.userId,
6 targetId: event.targetId,
7 targetType: event.targetType,
8 details: event.details,
9 timestamp: new Date(),
10 ipAddress: event.ip,
11 }
12 });
13 }
14
15 // Example usage
16 await auditLog({
17 type: 'CUSTOMER_KYC_APPROVED',
18 userId: adminUser.id,
19 targetId: customer.id,
20 targetType: 'customer',
21 details: { previousStatus: 'pending', newStatus: 'approved', reason: 'documents_verified' },
22 ip: req.ip,
23 });Phase 2: Growth Stage (Build When Scaling)
Once you have transaction volume and customer data, implement more sophisticated controls:Enhanced Due Diligence (EDD)For high-risk customers, collect additional information:- Source of funds documentation- Business ownership structure (for businesses)- PEP (Politically Exposed Person) screening- Adverse media screeningTrigger EDD based on risk indicators:
1 interface RiskAssessment {
2 score: number; // 0-100
3 factors: string[];
4 eddRequired: boolean;
5 }
6
7 function assessCustomerRisk(customer: Customer): RiskAssessment {
8 let score = 0;
9 const factors: string[] = [];
10
11 // Geographic risk
12 if (HIGH_RISK_COUNTRIES.includes(customer.country)) {
13 score += 30;
14 factors.push('high_risk_country');
15 }
16
17 // Occupation risk
18 if (HIGH_RISK_OCCUPATIONS.includes(customer.occupation)) {
19 score += 20;
20 factors.push('high_risk_occupation');
21 }
22
23 // Transaction pattern risk
24 if (customer.avgTransactionSize > 10000) {
25 score += 15;
26 factors.push('large_transactions');
27 }
28
29 // PEP status
30 if (customer.isPep) {
31 score += 40;
32 factors.push('politically_exposed_person');
33 }
34
35 return {
36 score,
37 factors,
38 eddRequired: score >= 50,
39 };
40 }Sophisticated Transaction MonitoringMove beyond simple limits to pattern detection:
1 interface MonitoringRule {
2 name: string;
3 detect: (transactions: Transaction[]) => boolean;
4 severity: 'low' | 'medium' | 'high';
5 }
6
7 const MONITORING_RULES: MonitoringRule[] = [
8 {
9 name: 'structuring',
10 detect: (txs) => {
11 // Multiple transactions just under reporting threshold
12 const nearThreshold = txs.filter(t => t.amount > 9000 && t.amount < 10000);
13 return nearThreshold.length >= 3 && isWithinDays(nearThreshold, 7);
14 },
15 severity: 'high',
16 },
17 {
18 name: 'rapid_movement',
19 detect: (txs) => {
20 // Money in and out quickly
21 const deposits = txs.filter(t => t.type === 'deposit');
22 const withdrawals = txs.filter(t => t.type === 'withdrawal');
23 return hasMatchingAmountsWithin24Hours(deposits, withdrawals);
24 },
25 severity: 'high',
26 },
27 {
28 name: 'unusual_velocity',
29 detect: (txs) => {
30 // Transaction velocity 3x higher than customer's normal
31 const recentVelocity = calculateVelocity(txs, 7);
32 const historicalVelocity = getHistoricalVelocity(txs[0].customerId);
33 return recentVelocity > historicalVelocity * 3;
34 },
35 severity: 'medium',
36 },
37 ];SAR (Suspicious Activity Report) Filing WorkflowWhen monitoring detects issues, you need a process:
1 async function createSuspiciousActivityCase(alert: MonitoringAlert) {
2 const case = await db.sarCase.create({
3 data: {
4 customerId: alert.customerId,
5 alertType: alert.ruleName,
6 severity: alert.severity,
7 status: 'open',
8 transactions: alert.transactionIds,
9 assignedTo: getComplianceOfficer(),
10 dueDate: addBusinessDays(new Date(), 5),
11 }
12 });
13
14 // Notify compliance team
15 await notify.complianceTeam({
16 type: 'new_sar_case',
17 caseId: case.id,
18 severity: alert.severity,
19 });
20
21 return case;
22 }Phase 3: Enterprise (Build for Institutional)
At scale, you need enterprise-grade compliance infrastructure:Custom Risk ModelsMove from rule-based to ML-based risk scoring:
1 // Train on your historical data
2 const riskModel = await trainRiskModel({
3 features: [
4 'transaction_amount',
5 'transaction_velocity_7d',
6 'country_risk_score',
7 'account_age_days',
8 'verification_level',
9 'previous_sar_count',
10 'peer_group_deviation',
11 ],
12 labels: historicalSarOutcomes,
13 });
14
15 // Score transactions in real-time
16 async function scoreTransaction(tx: Transaction): Promise<number> {
17 const features = await extractFeatures(tx);
18 return riskModel.predict(features);
19 }Real-Time Sanctions ScreeningScreen every transaction against sanctions lists:
1 async function screenTransaction(tx: Transaction): Promise<ScreeningResult> {
2 const parties = [
3 { name: tx.senderName, type: 'sender' },
4 { name: tx.recipientName, type: 'recipient' },
5 ];
6
7 for (const party of parties) {
8 const matches = await sanctionsProvider.screen({
9 name: party.name,
10 lists: ['OFAC_SDN', 'UN_CONSOLIDATED', 'EU_SANCTIONS'],
11 });
12
13 if (matches.length > 0) {
14 await blockTransaction(tx.id, 'sanctions_match');
15 await escalateToCompliance(tx, matches);
16 return { blocked: true, reason: 'sanctions_match', matches };
17 }
18 }
19
20 return { blocked: false };
21 }Advanced Case ManagementEnterprise compliance teams need proper tooling:- Case assignment and workload balancing- Investigation workflows with audit trails- Regulatory reporting automation- Analytics dashboards for compliance metrics- Integration with regulatory filing systems
KYC Implementation Guide
Choosing a KYC Provider| Provider | Strengths | Best For | Pricing ||----------|-----------|----------|---------|| Onfido | Great UX, strong global coverage | Consumer fintech | Per-verification || Persona | Highly customizable workflows | Complex use cases | Per-verification || Jumio | Enterprise-grade, high accuracy | Banks, high-volume | Volume-based || Veriff | Fast verification, good coverage | Speed-focused apps | Per-verification || Alloy | Orchestration layer | Multi-provider strategy | Platform fee |Integration Architecture
1 ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 2 │ Mobile │────▶│ Your API │────▶│ KYC Provider│ 3 │ App │ │ │ │ (Onfido) │ 4 └─────────────┘ └──────┬──────┘ └──────┬──────┘ 5 │ │ 6 │ │ Webhook 7 ▼ ▼ 8 ┌─────────────────────────────┐ 9 │ Your Database │ 10 │ (Customer KYC status) │ 11 └─────────────────────────────┘
Handling RejectionsNot everyone will pass KYC. Handle rejections gracefully:
1 async function handleKycResult(webhookPayload: KycWebhook) {
2 const { applicantId, result, subResults } = webhookPayload;
3
4 const customer = await db.customer.findFirst({
5 where: { kycApplicantId: applicantId }
6 });
7
8 if (result === 'clear') {
9 await db.customer.update({
10 where: { id: customer.id },
11 data: { kycStatus: 'approved', kycApprovedAt: new Date() }
12 });
13 await sendEmail(customer.email, 'kyc_approved');
14 } else {
15 // Determine if re-verification is possible
16 const canRetry = subResults.some(r =>
17 RETRYABLE_FAILURES.includes(r.failureReason)
18 );
19
20 await db.customer.update({
21 where: { id: customer.id },
22 data: {
23 kycStatus: canRetry ? 'retry_required' : 'rejected',
24 kycFailureReasons: subResults.map(r => r.failureReason),
25 }
26 });
27
28 await sendEmail(customer.email, canRetry ? 'kyc_retry' : 'kyc_rejected', {
29 reasons: humanReadableReasons(subResults),
30 });
31 }
32 }Transaction Monitoring
What Patterns to WatchRegulators expect you to detect these patterns:1. Structuring — Breaking large transactions into smaller ones to avoid reporting thresholds2. Rapid Movement — Funds deposited and withdrawn quickly (pass-through)3. Round-Trip Transactions — Money that leaves and returns to the same account4. Unusual Velocity — Transaction patterns inconsistent with stated purpose5. Geographic Anomalies — Transactions from unexpected locations6. Peer Group Deviation — Behavior inconsistent with similar customersVelocity Checks
1 interface VelocityRule {
2 name: string;
3 window: Duration;
4 maxTransactions?: number;
5 maxAmount?: number;
6 action: 'block' | 'flag' | 'require_review';
7 }
8
9 const VELOCITY_RULES: VelocityRule[] = [
10 { name: 'hourly_count', window: { hours: 1 }, maxTransactions: 10, action: 'block' },
11 { name: 'daily_amount', window: { days: 1 }, maxAmount: 50000, action: 'require_review' },
12 { name: 'weekly_count', window: { days: 7 }, maxTransactions: 100, action: 'flag' },
13 ];
14
15 async function checkVelocity(tx: Transaction): Promise<VelocityCheckResult> {
16 for (const rule of VELOCITY_RULES) {
17 const transactions = await getRecentTransactions(tx.customerId, rule.window);
18
19 if (rule.maxTransactions && transactions.length >= rule.maxTransactions) {
20 return { passed: false, rule: rule.name, action: rule.action };
21 }
22
23 if (rule.maxAmount) {
24 const total = transactions.reduce((sum, t) => sum + t.amount, 0);
25 if (total + tx.amount > rule.maxAmount) {
26 return { passed: false, rule: rule.name, action: rule.action };
27 }
28 }
29 }
30
31 return { passed: true };
32 }Peer Group AnalysisCompare customers to their peers:
1 async function calculatePeerGroupDeviation(customer: Customer): Promise<number> {
2 // Define peer group
3 const peers = await db.customer.findMany({
4 where: {
5 accountType: customer.accountType,
6 country: customer.country,
7 createdAt: { gte: subMonths(customer.createdAt, 1) },
8 },
9 take: 100,
10 });
11
12 // Calculate peer median behavior
13 const peerMedianMonthlyVolume = median(peers.map(p => p.monthlyVolume));
14 const peerMedianTransactionCount = median(peers.map(p => p.transactionCount));
15
16 // Calculate deviation
17 const volumeDeviation = customer.monthlyVolume / peerMedianMonthlyVolume;
18 const countDeviation = customer.transactionCount / peerMedianTransactionCount;
19
20 // Return combined deviation score
21 return Math.max(volumeDeviation, countDeviation);
22 }Building Audit Trails
What to LogLog everything that might be relevant to a compliance investigation:| Event Type | What to Capture ||------------|-----------------|| Customer onboarding | All submitted data, verification results, timestamps || KYC decisions | Who approved, what evidence, when || Transactions | Full details, risk scores, monitoring results || Account changes | What changed, who changed it, old/new values || Compliance reviews | Investigator notes, decisions, escalations || Access events | Who accessed what customer data, when |Retention RequirementsDifferent jurisdictions have different requirements:| Jurisdiction | Retention Period ||--------------|------------------|| US (BSA) | 5 years || UK (FCA) | 5 years after relationship ends || EU (AMLD) | 5 years after relationship ends || Singapore | 5 years |Implementation
1 // Immutable audit log - append only
2 interface AuditLogEntry {
3 id: string;
4 timestamp: Date;
5 eventType: string;
6 actorType: 'customer' | 'admin' | 'system';
7 actorId: string;
8 targetType: string;
9 targetId: string;
10 action: string;
11 previousState?: object;
12 newState?: object;
13 metadata: {
14 ip?: string;
15 userAgent?: string;
16 sessionId?: string;
17 };
18 checksum: string; // Hash for tamper detection
19 }
20
21 async function createAuditEntry(entry: Omit<AuditLogEntry, 'id' | 'checksum'>) {
22 const id = generateUUID();
23 const checksum = hashEntry({ ...entry, id });
24
25 await db.auditLog.create({
26 data: { ...entry, id, checksum }
27 });
28
29 // Also write to immutable backup(S3, append-only database)
30 await backupAuditEntry({ ...entry, id, checksum });
31 }Making Compliance's Life EasierBuild tools for your compliance team:- Customer 360 view — All customer data, transactions, and decisions in one place- Case management — Track investigations, assign work, set deadlines- Search and filter — Find transactions and customers by any attribute- Export tools — Generate reports for regulators in required formats- Dashboard — KPIs like KYC approval rates, monitoring alerts, case backlogs
Tools and Vendors
KYC/Identity Verification| Provider | Coverage | Speed | Best For ||----------|----------|-------|----------|| Onfido | 195 countries | 15 seconds | Consumer apps || Persona | 200+ countries | Varies | Customizable workflows || Jumio | 200+ countries | 30 seconds | High accuracy needs || Veriff | 190+ countries | 6 seconds | Speed-focused || Alloy | Orchestration | N/A | Multi-provider strategy |Transaction Monitoring| Tool | Type | Best For ||------|------|----------|| Sardine | AI-native | Modern fraud/AML || Unit21 | Configurable rules | Customizable monitoring || ComplyAdvantage | Screening + monitoring | Global coverage || Feedzai | Enterprise ML | Large scale |Case Management| Tool | Type | Best For ||------|------|----------|| Hummingbird | Compliance workflow | SAR filing || Alloy | End-to-end | Integrated KYC + monitoring || Unit21 | Investigation | Alert management |Sanctions Screening| Provider | Coverage | Speed ||----------|----------|-------|| ComplyAdvantage | All major lists | Real-time || Dow Jones | Comprehensive | Real-time || Refinitiv | Enterprise | Batch + real-time || Sanction Scanner | Budget option | Real-time |Our recommendation for early-stage:1. KYC: Onfido or Persona (good DX, reasonable pricing)2. Monitoring: Build simple rules in-house initially, add Unit21 when you have volume3. Sanctions: ComplyAdvantage (good API, reasonable cost)4. Case Management: Spreadsheet/Notion initially, proper tooling at 100+ cases/month
We help teams design and ship production-grade software in eLearning, fintech, and AI. Let's talk about your project.
Book a call