Bonaventure OgetoBy Bonaventure Ogeto|

Building a KYC Flow on Paystack Identity APIs

Build a tiered KYC flow: Tier 1 uses email and phone verification (your own system). Tier 2 adds Paystack Resolve Account Number to verify bank ownership. Tier 3 adds BVN verification for identity confirmation. Each tier unlocks higher transaction limits. Store verification status in a dedicated table, present each step clearly to the user, and never block a user from basic functionality before they complete full KYC.

What Progressive KYC Means

Traditional KYC forces users to verify everything upfront before they can do anything. Progressive KYC lets users start using your product immediately and only asks for more verification when they need to do more.

Tier 1: Email and phone. The user signs up with an email address and phone number. You verify both (email link, SMS OTP). This is enough for browsing, saving items, and basic interactions. This tier does not use Paystack at all.

Tier 2: Bank account verification. When the user wants to receive payouts or link a bank account, you use Paystack Resolve Account Number to confirm they own the account they claim. This unlocks withdrawals up to a limit you define.

Tier 3: Identity verification. When the user wants higher limits or access to premium features, you use Paystack BVN verification to confirm their legal identity. This unlocks the highest transaction limits.

Each tier adds friction, so each tier should unlock something valuable. Users accept verification when they understand what it gives them.

Database Schema for Verification Tracking

Track each user's verification state in a dedicated table.

-- kyc_verifications table
CREATE TABLE kyc_verifications (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  tier INTEGER NOT NULL DEFAULT 1,
  email_verified BOOLEAN NOT NULL DEFAULT false,
  email_verified_at TIMESTAMP,
  phone_verified BOOLEAN NOT NULL DEFAULT false,
  phone_verified_at TIMESTAMP,
  bank_verified BOOLEAN NOT NULL DEFAULT false,
  bank_account_number TEXT,
  bank_code TEXT,
  bank_account_name TEXT,
  bank_verified_at TIMESTAMP,
  bvn_verified BOOLEAN NOT NULL DEFAULT false,
  bvn_verified_at TIMESTAMP,
  paystack_customer_code TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(user_id)
);

-- Tier limits table
CREATE TABLE kyc_tier_limits (
  tier INTEGER PRIMARY KEY,
  max_per_transaction BIGINT NOT NULL,
  max_daily BIGINT NOT NULL,
  max_monthly BIGINT NOT NULL,
  description TEXT
);

INSERT INTO kyc_tier_limits (tier, max_per_transaction, max_daily, max_monthly, description) VALUES
(1, 0, 0, 0, 'No payouts - email and phone only'),
(2, 5000000, 20000000, 100000000, 'Bank verified - standard limits'),
(3, 50000000, 200000000, 1000000000, 'BVN verified - premium limits');

Amounts are in kobo. 5000000 kobo = 50,000 NGN. Adjust the limits to match your product and risk appetite.

Why a separate table: Keeping verification state separate from the users table lets you query verification status efficiently, add new verification types without altering the users table, and maintain a clean audit trail.

Implementing Tier 2: Bank Account Verification

When a user wants to add a bank account for payouts, run Resolve Account Number and name matching.

// tier-two-verify.js
const axios = require('axios');

async function verifyBankAccount(userId, accountNumber, bankCode, userName) {
  // Step 1: Resolve the account
  var response = await axios.get(
    'https://api.paystack.co/bank/resolve',
    {
      params: { account_number: accountNumber, bank_code: bankCode },
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
      timeout: 15000,
    }
  );

  var resolvedName = response.data.data.account_name;

  // Step 2: Compare names
  var nameResult = compareNames(userName, resolvedName);

  // Step 3: Store result and update tier
  if (nameResult.passes) {
    await db.query(
      'UPDATE kyc_verifications SET ' +
      'bank_verified = true, bank_account_number = $1, bank_code = $2, ' +
      'bank_account_name = $3, bank_verified_at = NOW(), tier = 2, updated_at = NOW() ' +
      'WHERE user_id = $4',
      [accountNumber, bankCode, resolvedName, userId]
    );
  }

  return {
    resolved_name: resolvedName,
    name_match: nameResult,
    verified: nameResult.passes,
  };
}

What to show the user: After resolution, display the resolved name: "This account is registered to OKAFOR JOHN DOE. Confirm this is your account." If the name match is close but not exact, let the user confirm manually.

What to do on failure: If the account does not resolve (422), tell the user the account was not found. If the name does not match at all, tell the user the account name does not match their profile. Let them try a different account or update their profile name.

Implementing Tier 3: BVN Verification

When a user needs higher limits, prompt them to complete BVN verification.

// tier-three-verify.js
async function initiateBvnVerification(userId) {
  // Get user and their Paystack customer code
  var user = await db.query(
    'SELECT u.first_name, u.last_name, u.email, k.paystack_customer_code, ' +
    'k.bank_account_number, k.bank_code ' +
    'FROM users u JOIN kyc_verifications k ON u.id = k.user_id ' +
    'WHERE u.id = $1',
    [userId]
  );

  var userData = user.rows[0];

  // Create Paystack customer if not exists
  var customerCode = userData.paystack_customer_code;
  if (!customerCode) {
    var customer = await createPaystackCustomer(
      userData.email,
      userData.first_name,
      userData.last_name
    );
    customerCode = customer.customer_code;

    await db.query(
      'UPDATE kyc_verifications SET paystack_customer_code = $1 WHERE user_id = $2',
      [customerCode, userId]
    );
  }

  // Submit BVN verification (result comes via webhook)
  return { customerCode: customerCode, status: 'verification_submitted' };
}

// Webhook handler for BVN result
async function handleBvnWebhook(event) {
  if (event.event === 'customeridentification.success') {
    var customerCode = event.data.customer_code;
    await db.query(
      'UPDATE kyc_verifications SET ' +
      'bvn_verified = true, bvn_verified_at = NOW(), tier = 3, updated_at = NOW() ' +
      'WHERE paystack_customer_code = $1',
      [customerCode]
    );
  }
}

Ask for the BVN in a separate, focused screen. Do not bury the BVN input in a long form. Show a dedicated page that explains what BVN is, why you need it, and that you will not store the number itself. A "Why do I need to do this?" expandable section helps.

For the full BVN implementation, see the BVN verification guide.

Checking Limits at Runtime

Every time a user requests a payout, check their tier and enforce the corresponding limits.

// check-kyc-limits.js
async function canUserPayout(userId, amount) {
  var kyc = await db.query(
    'SELECT k.tier, l.max_per_transaction, l.max_daily, l.max_monthly ' +
    'FROM kyc_verifications k JOIN kyc_tier_limits l ON k.tier = l.tier ' +
    'WHERE k.user_id = $1',
    [userId]
  );

  if (kyc.rows.length === 0) {
    return { allowed: false, reason: 'KYC not initialized' };
  }

  var limits = kyc.rows[0];

  if (amount > limits.max_per_transaction) {
    return {
      allowed: false,
      reason: 'Exceeds per-transaction limit for your verification level',
      upgrade_to: limits.tier + 1,
    };
  }

  // Check daily total
  var dailyTotal = await db.query(
    'SELECT COALESCE(SUM(amount), 0) as total FROM payouts ' +
    'WHERE user_id = $1 AND status = $2 AND created_at > NOW() - INTERVAL '24 hours'',
    [userId, 'completed']
  );

  if (parseInt(dailyTotal.rows[0].total) + amount > limits.max_daily) {
    return {
      allowed: false,
      reason: 'Would exceed daily limit for your verification level',
      upgrade_to: limits.tier + 1,
    };
  }

  return { allowed: true };
}

When a limit is hit, suggest upgrading. Do not just say "limit exceeded." Say "Your current verification level allows payouts up to [amount]. Complete bank verification to increase your limit." Link directly to the verification page.

Designing the Verification UX

The UX of your KYC flow directly affects completion rates. Bad UX means users abandon verification and cannot use your product fully.

Show progress. Display which tier the user has reached and what each remaining tier unlocks. A simple progress bar with labels works well.

Explain the benefit at each step. "Verify your bank account to start receiving payouts." "Complete identity verification to increase your daily limit to [amount]." Benefits, not obligations.

Handle errors without panic. "We could not verify your account right now. This usually means the bank system is temporarily busy. Try again in a few minutes." Not "VERIFICATION FAILED."

One step at a time. Do not show all three tiers on a single screen with a giant form. Show only the next step the user needs to complete. After they complete it, celebrate the success and explain what they unlocked.

Let users skip and come back. If a user is not ready to verify their bank account yet, let them continue using the product at Tier 1 limits. Prompt them again when they try to do something that requires Tier 2.

Key Takeaways

  • Progressive KYC means users verify in stages. Basic actions need basic verification. High-value actions need stronger verification. Do not demand full KYC upfront.
  • Tier 1 (email + phone) is your responsibility, not Paystack. Tier 2 (bank account resolution) and Tier 3 (BVN) use Paystack identity APIs.
  • Store verification state in a dedicated database table. Track which tier a user has reached, when each verification happened, and the results.
  • Design the UX so verification feels like unlocking features, not passing through a checkpoint. "Verify your bank account to enable withdrawals" is better than "Complete KYC Step 2."
  • Cache identity API results. Account names change rarely. Re-verify only when the user changes their details or after a long period.
  • Always get consent before running identity checks. The Kenya DPA and Nigeria NDPR both require it.

Frequently Asked Questions

Should I require full KYC before a user can do anything?
No. Progressive KYC lets users start using your product immediately with basic verification (email and phone). Only require stronger verification when they need to perform higher-risk actions like receiving payouts or exceeding transaction limits. Requiring full KYC upfront increases drop-off rates dramatically.
How often should I re-verify a user?
Bank account verification results can be cached for 30 to 90 days. BVN verification is typically a one-time event. Re-verify when the user changes their bank details, when a suspicious activity flag is raised, or as required by your compliance policy. Routine re-verification every 6 to 12 months is reasonable for high-risk products.
What happens if Paystack identity APIs are down?
Do not block the entire user experience. If Resolve Account Number times out, tell the user verification is temporarily unavailable and let them save their details for later verification. Process payouts that have already been verified. Only block new, unverified payouts.
Can I use the same KYC flow for Nigeria and Kenya?
The structure (progressive tiers) works for both countries, but the specific APIs differ. Account resolution works for both countries. BVN is Nigeria-specific. Kenya may use national ID verification through different channels. Design your tier system to accommodate country-specific verification types.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon