Skip to main content

KYC Integration

Learn how to verify customer identities using Zentra’s KYC (Know Your Customer) APIs. Support BVN, NIN, and document verification.
Identity is a reviewed but tenant-gated namespace in the current route manifest. Use these flows only when your environment explicitly enables the identity routes and verify the exact shape against your approved tenant contract.

What You’ll Learn

  • Verify BVN (Bank Verification Number)
  • Verify NIN (National Identification Number)
  • Perform liveness checks
  • Handle verification results
  • Build a complete onboarding flow

Prerequisites

  • Zentra API account with KYC enabled
  • Customer information to verify
  • Webhook endpoint for async results

KYC Flow Overview

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Customer   │────▶│  Your Server │────▶│  Zentra API  │
│   (Submit)   │     │   (Verify)   │     │   (Check)    │
└──────────────┘     └──────────────┘     └──────────────┘
       │                    │                    │
       │   1. Enter         │   2. Submit        │
       │      Details       │      for KYC       │
       │                    │   ──────────────▶  │
       │                    │                    │   3. Verify with
       │                    │                    │      NIBSS/NIMC
       │                    │   ◀──────────────  │
       │   4. Return        │   5. Result        │
       │      Result        │      (sync/async)  │
       │   ◀────────────────│                    │

Step 1: Verify BVN

BVN verification is the primary KYC method in Nigeria:
const Zentra = require('@zentra/sdk');

const client = new Zentra.Client({
  apiKey: process.env.ZENTRA_SECRET_KEY
});

async function verifyBVN(customerData) {
  const result = await client.identity.verifyBVN({
    bvn: customerData.bvn,
    first_name: customerData.firstName,
    last_name: customerData.lastName,
    date_of_birth: customerData.dateOfBirth, // YYYY-MM-DD
    phone: customerData.phone // Optional, for additional matching
  });

  return {
    verified: result.verified,
    match_score: result.match_score,
    details: {
      first_name_match: result.first_name_match,
      last_name_match: result.last_name_match,
      dob_match: result.dob_match,
      phone_match: result.phone_match
    }
  };
}

BVN Verification Response

{
  "verified": true,
  "match_score": 95,
  "first_name_match": true,
  "last_name_match": true,
  "dob_match": true,
  "phone_match": true,
  "bvn_data": {
    "first_name": "JOHN",
    "last_name": "DOE",
    "middle_name": "JAMES",
    "date_of_birth": "1990-05-15",
    "phone": "08012345678",
    "gender": "Male",
    "registration_date": "2015-03-20"
  }
}
BVN data is returned in uppercase. Normalize before comparing with your records.

Step 2: Verify NIN

Verify National Identification Number:
async function verifyNIN(customerData) {
  const result = await client.identity.verifyNIN({
    nin: customerData.nin,
    first_name: customerData.firstName,
    last_name: customerData.lastName,
    date_of_birth: customerData.dateOfBirth
  });

  return {
    verified: result.verified,
    match_score: result.match_score,
    photo: result.photo, // Base64 encoded photo
    details: result.nin_data
  };
}
NIN verification returns the person’s photo. Only store and display with proper consent.

Step 3: Liveness Check

Verify the person is real and matches their documents:
async function performLivenessCheck(customerId, selfieData) {
  const result = await client.identity.verifySelfie({
    customer_id: customerId,
    selfie: selfieData.imageBase64,
    reference_image: selfieData.referenceImage, // From BVN/NIN
    liveness_check: true
  });

  return {
    is_live: result.liveness_passed,
    face_match: result.face_match_score > 80,
    face_match_score: result.face_match_score,
    fraud_signals: result.fraud_signals
  };
}

Liveness Response

{
  "liveness_passed": true,
  "face_match_score": 92,
  "fraud_signals": [],
  "quality_score": 85,
  "details": {
    "eyes_open": true,
    "face_centered": true,
    "good_lighting": true
  }
}

Step 4: Complete Onboarding Flow

Build a full KYC onboarding:
async function onboardCustomer(userData) {
  // Step 1: Create an app-level customer record in your own backend
  const customer = await db.customers.create({
    email: userData.email,
    first_name: userData.firstName,
    last_name: userData.lastName,
    phone: userData.phone,
    date_of_birth: userData.dateOfBirth,
    kyc_status: 'pending'
  });

  // Step 2: Verify BVN
  const bvnResult = await client.identity.verifyBVN({
    bvn: userData.bvn,
    first_name: userData.firstName,
    last_name: userData.lastName,
    date_of_birth: userData.dateOfBirth
  });

  if (!bvnResult.verified) {
    throw new Error('BVN verification failed');
  }

  // Step 3: Persist verified status in your own system
  await db.customers.update({
    where: { id: customer.id },
    data: {
      kyc_status: 'verified',
      kyc_level: 1,
      bvn_verified: true,
      bvn_verified_at: new Date()
    }
  });

  // Step 4: Optionally provision add-on rails after KYC passes
  const railCustomerId = await ensureRailCustomerForTenant(customer);
  const account = await client.virtualAccounts.create({
    customer_id: railCustomerId,
    account_name: `${userData.firstName} ${userData.lastName}`,
    bvn: userData.bvn
  });

  return {
    customer_id: customer.id,
    rail_customer_id: railCustomerId,
    account,
    kyc_status: 'verified'
  };
}

Step 5: Handle Async Results

Some verifications complete asynchronously:
app.post('/webhooks/zentra', async (req, res) => {
  const { event, data } = req.body;

  switch (event) {
    case 'identity.verified':
      await handleVerificationSuccess(data);
      break;
    case 'identity.failed':
      await handleVerificationFailed(data);
      break;
    case 'identity.pending':
      await handleVerificationPending(data);
      break;
  }

  res.status(200).send('OK');
});

async function handleVerificationSuccess(data) {
  const { customer_id, verification_type, details } = data;

  await db.customers.update({
    where: { zentra_id: customer_id },
    data: {
      kyc_status: 'verified',
      kyc_verified_at: new Date(),
      kyc_details: details
    }
  });

  // Notify customer
  await notificationService.send(customer_id, {
    title: 'Verification Complete',
    body: 'Your identity has been verified. You can now access all features.'
  });
}

async function handleVerificationFailed(data) {
  const { customer_id, reason, retry_allowed } = data;

  await db.customers.update({
    where: { zentra_id: customer_id },
    data: {
      kyc_status: 'failed',
      kyc_failure_reason: reason
    }
  });

  // Notify customer
  await notificationService.send(customer_id, {
    title: 'Verification Failed',
    body: retry_allowed 
      ? 'Please try again with correct details.'
      : 'Contact support for assistance.'
  });
}

KYC Levels

Implement tiered verification for different access levels:
LevelRequirementsLimits
0Email + PhoneView only
1BVN verified₦50,000/day
2BVN + NIN₦200,000/day
3Full KYC + Liveness₦1,000,000/day
function getTransactionLimit(kycLevel) {
  const limits = {
    0: 0,
    1: 5000000,   // ₦50,000
    2: 20000000,  // ₦200,000
    3: 100000000  // ₦1,000,000
  };
  return limits[kycLevel] || 0;
}

Error Handling

Handle common KYC errors:
try {
  const result = await client.identity.verifyBVN(data);
  return result;
} catch (error) {
  switch (error.code) {
    case 'invalid_bvn':
      throw new Error('Invalid BVN format');
    case 'bvn_not_found':
      throw new Error('BVN not found in database');
    case 'name_mismatch':
      throw new Error('Name does not match BVN records');
    case 'dob_mismatch':
      throw new Error('Date of birth does not match');
    case 'verification_limit':
      throw new Error('Daily verification limit reached');
    case 'service_unavailable':
      throw new Error('Verification service temporarily unavailable');
    default:
      throw new Error('Verification failed. Please try again.');
  }
}

Testing in Sandbox

Use test data in sandbox mode:
BVNResult
12345678901Successful verification
12345678902Name mismatch
12345678903DOB mismatch
12345678904BVN not found
Test all failure scenarios to ensure your app handles them gracefully.

Best Practices

  1. Normalize Data: Convert names to uppercase before verification
  2. Handle Partial Matches: Accept 80%+ match scores with manual review
  3. Rate Limit: Prevent brute-force attempts
  4. Audit Trail: Log all verification attempts
  5. Data Security: Encrypt and minimize storage of PII

Next Steps

Identity API

Full API reference

Virtual Accounts

Create accounts after KYC

Webhooks

Handle KYC events

Going Live

Production checklist