Skip to main content

Building a Neobank

Learn how to build a complete digital banking application using Zentra’s Banking-as-a-Service platform.
Treat this guide as architecture-first. The reviewed public surfaces in the current repo are transfers, payments, identity, cards, virtual accounts, billpay, and webhooks. Higher-level wallet or product behaviors should still be treated as application-layer composition unless separately documented for your tenant.
In the examples below, customer and wallet are application-layer concepts. Implement them in your own backend, or map them to tenant-specific overlays if your environment exposes extra draft APIs. The KYC snippets assume a tenant-gated reviewed identity surface and should still be validated against your enabled environment.

What You’ll Build

By the end of this guide, you’ll have:
  • Customer onboarding with KYC
  • Virtual bank accounts for each customer
  • Wallet management
  • Money transfers
  • Virtual card issuing
  • Transaction history
  • Real-time notifications

Primitive-First Model

Build your app in layers:
  1. primitives: transfers, webhooks, audit-safe references, and tenant-gated identity checks where enabled
  2. optional rail add-ons: virtual accounts, cards, and payment collection
  3. product logic in your own backend: ledgers, onboarding flows, limits, rewards, savings experiences, and subscription logic

Prerequisites

Before starting, ensure you have:
  • Zentra developer account (Create account)
  • Basic knowledge of your chosen programming language
  • Development environment set up

Architecture Overview

┌─────────────┐
│   Your App  │
│  (Frontend) │
└──────┬──────┘


┌──────────────┐
│  Your Server │
│  (Backend)   │
└──────┬───────┘


┌──────────────┐
│  Zentra API  │
└──────────────┘

Step 1: Set Up Authentication

First, set up your API client:
const Zentra = require('@zentra/sdk');

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

Step 2: Customer Onboarding

When a user signs up, create your own app-level customer record and then run KYC against Zentra’s tenant-specific identity surface if your environment exposes it:
async function onboardCustomer(userData) {
  const customer = await db.customers.create({
    email: userData.email,
    first_name: userData.firstName,
    last_name: userData.lastName,
    phone: userData.phone
  });
  
  // Verify BVN
  const verification = await client.identity.verifyBVN({
    bvn: userData.bvn,
    first_name: userData.firstName,
    last_name: userData.lastName,
    date_of_birth: userData.dob
  });
  
  if (!verification.verified) {
    throw new Error('BVN verification failed');
  }
  await db.customers.markKycVerified(customer.id, verification);
  return customer;
}

Step 3: Create Virtual Account

Create a dedicated account number for the customer:
async function createBankAccount(userId, railCustomerId, userBvn) {
  const account = await client.virtualAccounts.create({
    customer_id: railCustomerId,
    account_name: 'John Doe',
    preferred_bank: 'wema-bank',
    bvn: userBvn
  });
  
  // Save to your database
  await db.accounts.create({
    user_id: userId,
    account_id: account.id,
    account_number: account.account_number,
    bank_name: account.bank_name
  });
  
  return account;
}

Step 4: Set Up Wallet

Project or initialize the user’s balance state in your own backend:
async function setupWallet(userId) {
  const wallet = await db.wallets.getOrCreate(userId);
  
  // Display balance
  console.log(`Balance: ₦${wallet.balance_minor / 100}`);
  
  return wallet;
}

Step 5: Handle Deposits

Listen for webhook notifications when virtual account is credited:
app.post('/webhooks/zentra', async (req, res) => {
  const { event, data } = req.body;
  
  if (event === 'virtual_account.credited') {
    // Update user's balance
    await db.transactions.create({
      user_id: data.customer_id,
      type: 'deposit',
      amount_minor: data.amount_minor,
      reference: data.reference,
      status: 'completed'
    });
    
    // Notify user
    await sendNotification(data.customer_id, {
      title: 'Deposit Received',
      message: `₦${data.amount_minor / 100} credited to your account`
    });
  }
  
  res.status(200).send('OK');
});

Step 6: Enable Transfers

Allow users to send money:
async function transferMoney(userId, transferData) {
  // Check balance
  const wallet = await db.wallets.getOrCreate(userId);
  const totalAmountMinor = transferData.amountMinor + 50; // Amount + fee
  
  if (wallet.balance_minor < totalAmountMinor) {
    throw new Error('Insufficient balance');
  }
  
  // Create transfer
  const transfer = await client.transfers.create({
    amountMinor: transferData.amountMinor,
    recipient_account: transferData.recipientAccount,
    recipient_bank_code: transferData.bankCode,
    recipient_name: transferData.recipientAccountName,
    narration: transferData.narration,
    reference: `TRF_${Date.now()}`
  });
  
  // Save to database
  await db.transactions.create({
    user_id: userId,
    type: 'transfer',
    amount_minor: transferData.amountMinor,
    recipient: transferData.recipientAccountName,
    reference: transfer.reference,
    status: transfer.status
  });
  
  return transfer;
}

Step 7: Issue Virtual Cards

Let users create virtual cards:
async function issueCard(userId, cardData) {
  const card = await client.cards.create({
    customer_id: userId,
    type: 'virtual',
    currency: 'NGN'
  });

  if (cardData.initialFundingMinor > 0) {
    await client.cards.fund(card.id, {
      amountMinor: cardData.initialFundingMinor,
      currency: 'NGN',
      transactionReference: `card_fund_${card.id}`,
      idempotencyKey: `card_fund_${card.id}`
    });
  }
  
  // Save card details (securely)
  await db.cards.create({
    user_id: userId,
    card_id: card.id,
    last4: card.last4,
    brand: card.brand,
    status: card.status
  });
  
  return {
    id: card.id,
    last4: card.last4,
    status: card.status
  };
}

Step 8: Transaction History

Display user’s transaction history:
async function getTransactions(userId, page = 1) {
  const { data, pagination } = await db.transactions.listByUser(userId, {
    page,
    limit: 20,
    sort: '-created_at'
  });
  
  return {
    transactions: data.map(txn => ({
      id: txn.id,
      type: txn.type,
      amount_minor: txn.amount_minor,
      description: txn.narration,
      status: txn.status,
      date: txn.created_at
    })),
    pagination
  };
}

Step 9: Real-time Balance Updates

Use webhooks to update balances in real-time:
// When virtual account is credited
case 'virtual_account.credited':
  await updateUserBalance(data.customer_id, data.amount_minor, 'credit');
  break;

// When transfer completes
case 'transfer.completed':
  await updateUserBalance(data.customer_id, data.amount_minor, 'debit');
  break;

// When card transaction occurs
case 'card.transaction':
  await updateUserBalance(data.customer_id, data.amount_minor, 'debit');
  break;

Step 10: Going Live

When ready for production:
  1. Switch to Live Mode
    const client = new Zentra.Client({
      apiKey: process.env.ZENTRA_LIVE_KEY,
      environment: 'live'
    });
    
  2. Complete Compliance
    • Submit business documents
    • Complete KYC verification
    • Set up webhook endpoints
  3. Test Thoroughly
    • Run end-to-end tests
    • Test error scenarios
    • Load test your infrastructure
  4. Monitor Everything
    • Set up error tracking (Sentry)
    • Monitor API usage
    • Track transaction success rates

Complete Example App

Here’s a minimal Express.js app. Notice that user/customer state and transaction history live in your backend, while Zentra handles the reviewed rail and money-movement surfaces:
const express = require('express');
const Zentra = require('@zentra/sdk');

const app = express();
const client = new Zentra.Client({
  apiKey: process.env.ZENTRA_SECRET_KEY,
  environment: 'sandbox'
});

app.use(express.json());

// Onboard customer
app.post('/api/onboard', async (req, res) => {
  const customer = await db.customers.create(req.body);
  const railCustomerId = await ensureRailCustomerForTenant(customer);
  const account = await client.virtualAccounts.create({
    customer_id: railCustomerId,
    account_name: `${customer.first_name} ${customer.last_name}`,
    bank_code: '058',
    bvn: req.body.bvn
  });
  
  res.json({ customer, account });
});

// Transfer money
app.post('/api/transfer', async (req, res) => {
  const transfer = await client.transfers.create(req.body);
  res.json(transfer);
});

// Get transactions
app.get('/api/transactions', async (req, res) => {
  const userId = req.query.user_id;
  const txns = await db.transactions.listByUser(userId);
  res.json(txns);
});

// Handle webhooks
app.post('/webhooks/zentra', (req, res) => {
  const { event, data } = req.body;
  console.log('Webhook:', event, data);
  // Process event...
  res.status(200).send('OK');
});

app.listen(3000);

Next Steps

API Reference

Explore all available endpoints

Webhook Guide

Master webhook handling

Going Live

Production checklist

Test Data

Test in sandbox mode

Get Help

Questions? We’re here to help: