Customer Onboarding Verification
Streamline your KYC (Know Your Customer) process by verifying customer identity during registration using just their mobile number.Business Scenario
During user registration, you want to:- Verify the customer’s identity without manual document upload
- Pre-fill profile information (name, DOB) for better UX
- Check for any fraud signals before account creation
- Comply with regulatory requirements
Architecture
Implementation
Step 1: Create Onboarding Service
Copy
// services/onboarding-verification.ts
import { TxnCheckClient } from './fraud-buster';
export interface OnboardingInput {
mobile: string;
userProvidedName?: string;
userProvidedDOB?: string;
}
export interface OnboardingResult {
verified: boolean;
kycStatus: 'verified' | 'partial' | 'not_found' | 'error';
prefillData?: {
fullName: string;
maskedPan: string;
maskedAadhaar: string;
dob: string;
};
linkedVPAs: string[];
riskFlags: string[];
accountType: 'full' | 'limited' | 'rejected';
requiresManualReview: boolean;
}
export class OnboardingService {
private client: TxnCheckClient;
constructor(apiKey: string) {
this.client = new TxnCheckClient({ apiKey });
}
async verifyNewCustomer(input: OnboardingInput): Promise<OnboardingResult> {
const result: OnboardingResult = {
verified: false,
kycStatus: 'not_found',
linkedVPAs: [],
riskFlags: [],
accountType: 'limited',
requiresManualReview: false,
};
try {
// Perform full check to get all available data
const verification = await this.client.fullCheck(input.mobile, { sync: true });
if (verification.status === 'FAILED') {
result.kycStatus = 'error';
result.accountType = 'limited';
return result;
}
const data = verification.result || {};
const kycData = data.kycByMobile || {};
const upiData = data.upiByMobile || {};
const blocklist = data.vpaChargebackCheck || {};
// Extract KYC data
if (kycData.fullName && kycData.pan) {
result.kycStatus = 'verified';
result.verified = true;
result.prefillData = {
fullName: kycData.fullName,
maskedPan: this.maskPAN(kycData.pan),
maskedAadhaar: kycData.maskedAadhaar || '',
dob: kycData.dob || '',
};
} else if (upiData.name) {
result.kycStatus = 'partial';
result.prefillData = {
fullName: upiData.name,
maskedPan: '',
maskedAadhaar: '',
dob: '',
};
}
// Extract linked VPAs
result.linkedVPAs = upiData.upi || [];
// Risk assessment
const riskFlags = this.assessRisks(data, input);
result.riskFlags = riskFlags;
// Determine account type based on risks
result.accountType = this.determineAccountType(result, riskFlags);
result.requiresManualReview = riskFlags.includes('BLOCKLISTED_VPA') ||
riskFlags.includes('NAME_MISMATCH');
return result;
} catch (error) {
console.error('Onboarding verification error:', error);
result.kycStatus = 'error';
result.accountType = 'limited';
return result;
}
}
private assessRisks(data: any, input: OnboardingInput): string[] {
const flags: string[] = [];
const kycData = data.kycByMobile || {};
const blocklist = data.vpaChargebackCheck || {};
// Check for blocklisted VPAs
if (blocklist.blocklisted?.length > 0) {
flags.push('BLOCKLISTED_VPA');
}
// Check name mismatch (if user provided name)
if (input.userProvidedName && kycData.fullName) {
const similarity = this.calculateNameSimilarity(
input.userProvidedName.toLowerCase(),
kycData.fullName.toLowerCase()
);
if (similarity < 0.7) {
flags.push('NAME_MISMATCH');
}
}
// Check DOB mismatch
if (input.userProvidedDOB && kycData.dob) {
if (input.userProvidedDOB !== kycData.dob) {
flags.push('DOB_MISMATCH');
}
}
// No linked VPAs might indicate synthetic identity
if ((data.upiByMobile?.upi || []).length === 0) {
flags.push('NO_UPI_HISTORY');
}
return flags;
}
private determineAccountType(
result: OnboardingResult,
riskFlags: string[]
): 'full' | 'limited' | 'rejected' {
// Reject if blocklisted VPA found
if (riskFlags.includes('BLOCKLISTED_VPA')) {
return 'rejected';
}
// Full access if KYC verified and no critical risks
if (result.kycStatus === 'verified' &&
!riskFlags.includes('NAME_MISMATCH') &&
!riskFlags.includes('DOB_MISMATCH')) {
return 'full';
}
// Limited access otherwise
return 'limited';
}
private maskPAN(pan: string): string {
if (pan.length !== 10) return pan;
return pan.slice(0, 2) + '****' + pan.slice(-4);
}
private calculateNameSimilarity(a: string, b: string): number {
// Simple Levenshtein-based similarity
const distance = this.levenshteinDistance(a, b);
const maxLength = Math.max(a.length, b.length);
return 1 - distance / maxLength;
}
private levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[b.length][a.length];
}
}
Step 2: Registration API Endpoint
Copy
// routes/registration.ts
import express from 'express';
import { OnboardingService } from '../services/onboarding-verification';
const router = express.Router();
const onboardingService = new OnboardingService(process.env.FRAUD_BUSTER_API_KEY!);
// Step 1: Verify mobile and get KYC data
router.post('/verify-mobile', async (req, res) => {
const { mobile, otpToken } = req.body;
// Verify OTP first
const otpValid = await verifyOTP(mobile, otpToken);
if (!otpValid) {
return res.status(401).json({ error: 'Invalid OTP' });
}
try {
const verification = await onboardingService.verifyNewCustomer({ mobile });
// Don't expose internal flags to client
res.json({
success: true,
kycStatus: verification.kycStatus,
prefillData: verification.prefillData,
accountType: verification.accountType,
linkedVPAs: verification.linkedVPAs.slice(0, 3), // Limit exposure
});
} catch (error) {
console.error('Mobile verification error:', error);
res.status(500).json({ error: 'Verification service unavailable' });
}
});
// Step 2: Complete registration
router.post('/complete', async (req, res) => {
const { mobile, name, dob, email, acceptedTerms } = req.body;
// Re-verify with user-provided data
const verification = await onboardingService.verifyNewCustomer({
mobile,
userProvidedName: name,
userProvidedDOB: dob,
});
if (verification.accountType === 'rejected') {
return res.status(403).json({
error: 'Unable to create account',
message: 'Please contact support for assistance',
});
}
// Create user account
const user = await createUser({
mobile,
name: verification.prefillData?.fullName || name,
dob: verification.prefillData?.dob || dob,
email,
kycVerified: verification.kycStatus === 'verified',
accountType: verification.accountType,
linkedVPAs: verification.linkedVPAs,
});
// Flag for manual review if needed
if (verification.requiresManualReview) {
await flagForReview(user.id, verification.riskFlags);
}
res.json({
success: true,
userId: user.id,
accountType: user.accountType,
kycVerified: user.kycVerified,
});
});
export default router;
Step 3: Frontend Registration Flow
Copy
// components/Registration.tsx
import React, { useState } from 'react';
export function RegistrationFlow() {
const [step, setStep] = useState<'mobile' | 'otp' | 'profile' | 'complete'>('mobile');
const [mobile, setMobile] = useState('');
const [kycData, setKycData] = useState<any>(null);
const [profileData, setProfileData] = useState({ name: '', dob: '', email: '' });
const handleMobileSubmit = async () => {
// Send OTP
await sendOTP(mobile);
setStep('otp');
};
const handleOTPVerify = async (otp: string) => {
const response = await fetch('/api/registration/verify-mobile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile, otpToken: otp }),
});
const result = await response.json();
if (result.success) {
setKycData(result);
// Pre-fill profile if KYC data available
if (result.prefillData) {
setProfileData({
name: result.prefillData.fullName,
dob: result.prefillData.dob,
email: '',
});
}
setStep('profile');
}
};
const handleProfileSubmit = async () => {
const response = await fetch('/api/registration/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mobile,
...profileData,
acceptedTerms: true,
}),
});
const result = await response.json();
if (result.success) {
setStep('complete');
}
};
return (
<div className="registration-flow">
{step === 'mobile' && (
<div>
<h2>Create Account</h2>
<input
type="tel"
placeholder="Mobile Number (+91...)"
value={mobile}
onChange={(e) => setMobile(e.target.value)}
/>
<button onClick={handleMobileSubmit}>Send OTP</button>
</div>
)}
{step === 'otp' && (
<OTPInput onVerify={handleOTPVerify} onResend={() => sendOTP(mobile)} />
)}
{step === 'profile' && (
<div>
<h2>Complete Your Profile</h2>
{kycData?.kycStatus === 'verified' && (
<div className="kyc-badge">
✓ Identity Verified
<small>Your details have been verified</small>
</div>
)}
<input
type="text"
placeholder="Full Name"
value={profileData.name}
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
disabled={kycData?.kycStatus === 'verified'}
/>
<input
type="date"
placeholder="Date of Birth"
value={profileData.dob}
onChange={(e) => setProfileData({ ...profileData, dob: e.target.value })}
disabled={kycData?.kycStatus === 'verified'}
/>
<input
type="email"
placeholder="Email"
value={profileData.email}
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
/>
{kycData?.accountType === 'limited' && (
<div className="account-notice">
<p>Your account will have limited features until additional verification is completed.</p>
</div>
)}
<button onClick={handleProfileSubmit}>Create Account</button>
</div>
)}
{step === 'complete' && (
<div className="success">
<h2>Welcome!</h2>
<p>Your account has been created successfully.</p>
</div>
)}
</div>
);
}
Account Types & Limits
Define different account tiers based on verification status:| Account Type | KYC Status | Transaction Limit | Features |
|---|---|---|---|
| Full | Verified | ₹1,00,000/day | All features enabled |
| Limited | Partial/Not Found | ₹10,000/day | Basic features only |
| Rejected | Blocklisted | N/A | Account not created |
Edge Cases
Handle Partial KYC Data
Copy
// If KYC lookup returns partial data, request missing fields
if (result.kycStatus === 'partial') {
// Name from UPI data but no PAN/Aadhaar
return {
...result,
requiredFields: ['pan', 'dob'],
message: 'Please provide additional details for full verification',
};
}
Multiple Mobile Numbers
Copy
// Some users have multiple mobiles - allow linking
router.post('/link-mobile', async (req, res) => {
const { userId, newMobile, otpToken } = req.body;
// Verify the new mobile
const verification = await onboardingService.verifyNewCustomer({ mobile: newMobile });
// Check if name matches existing account
const existingUser = await getUserById(userId);
if (verification.prefillData?.fullName !== existingUser.name) {
return res.status(400).json({
error: 'Name mismatch',
message: 'The mobile number appears to belong to a different person',
});
}
// Link mobile to account
await linkMobileToUser(userId, newMobile);
res.json({ success: true });
});
Compliance Considerations
KYC data (PAN, Aadhaar) must be handled in compliance with data protection regulations:
- Store only masked/hashed versions
- Log access for audit purposes
- Implement data retention policies
- Encrypt data at rest and in transit
Data Minimization
Copy
// Only store what's necessary
const userRecord = {
mobile: input.mobile,
name: verification.prefillData?.fullName,
// Store hash of PAN, not actual PAN
panHash: hashPAN(verification.prefillData?.maskedPan),
kycVerified: verification.verified,
verifiedAt: new Date(),
};
Production Recommendations
Rate Limit
Limit KYC lookups per IP/session to prevent enumeration attacks
Audit Logging
Log all KYC lookups with user consent timestamp
Fallback Flow
Have manual document upload as fallback for failed lookups
Data Retention
Define and enforce data retention policies for KYC data
