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:
primitives: transfers, webhooks, audit-safe references, and tenant-gated identity checks where enabled
optional rail add-ons: virtual accounts, cards, and payment collection
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:
Switch to Live Mode
const client = new Zentra . Client ({
apiKey: process . env . ZENTRA_LIVE_KEY ,
environment: 'live'
});
Complete Compliance
Submit business documents
Complete KYC verification
Set up webhook endpoints
Test Thoroughly
Run end-to-end tests
Test error scenarios
Load test your infrastructure
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: