Skip to main content

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:
  1. Verify the customer’s identity without manual document upload
  2. Pre-fill profile information (name, DOB) for better UX
  3. Check for any fraud signals before account creation
  4. Comply with regulatory requirements

Architecture

Implementation

Step 1: Create Onboarding Service

// 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

// 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

// 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 TypeKYC StatusTransaction LimitFeatures
FullVerified₹1,00,000/dayAll features enabled
LimitedPartial/Not Found₹10,000/dayBasic features only
RejectedBlocklistedN/AAccount not created

Edge Cases

Handle Partial KYC Data

// 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

// 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

// 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