Build a Fintech Payment Processing API

Build a payment API with virtual accounts, HMAC-verified webhooks, and bank payouts using Payaza.

Introduction

30 Second Summary

Every time you tap "Pay" in a fintech app, a backend API is orchestrating the flow of money between accounts, verifying cryptographic signatures, and recording every transaction. Building that backend is the core skill that separates fintech engineers from general web developers.

In this project, you will build a payment processing API that collects money through virtual accounts and sends payouts to bank accounts. Everything runs against Payaza's test environment so no real money moves, but the architecture mirrors what you'd ship at a real fintech company.

What You'll Build

A live API you can hit with real HTTP requests to generate virtual account numbers, receive cryptographically verified payment notifications, and initiate bank transfers with a full audit trail stored in MongoDB.

By the end of this project, you'll have:

  • A working collection flow where you POST customer details and receive back a real virtual account number from Payaza that a customer could pay into.
  • A secured webhook endpoint that rejects forged payment notifications using HMAC SHA-512 signature verification, proving you can distinguish legitimate Payaza events from imposters.
  • A payout flow where you verify a recipient's account name and then initiate a bank transfer, watching the transaction status update through Payaza's API.
  • Secret Mission: Add idempotency enforcement so duplicate payment requests are safely rejected without sending money twice.

Are there any prerequisites?

You should have a basic understanding of REST APIs and CRUD operations with Node.js and Express. You also need VS Code and Node.js already installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Payment handling is at the core of every fintech product, so building it securely is the skill that makes you production-ready.

Set Up Your Project and Accounts

Before you can process a single payment, you need the infrastructure in place. That means a Payaza account to handle money movement, a MongoDB database to track transactions, and a Node.js project wired up to both.

In this step, you will create your accounts, install your dependencies, and boot a server that proves everything is connected.

In this step, get ready to:
  • Create a Payaza business account and a MongoDB Atlas cluster.
  • Initialize a Node.js project with all required dependencies.
  • Start an Express server connected to MongoDB.
Create Payaza and MongoDB Atlas accounts

You need two external services before writing any code. Payaza handles the actual money movement (virtual accounts, transfers, webhooks). MongoDB Atlas stores your transaction records in the cloud.

  • Open your browser and navigate to https://business.payaza.africa.
  • Click Sign Up and complete the registration form with your email and business details.
  • Once logged in, click Settings in the left sidebar.
  • Click Developers under Settings.
  • Click Generate Keys to create your API keys.
  • Switch to Test Mode using the toggle in the dashboard.
  • Copy your test public key and secret key. Paste them somewhere safe (you will add them to your project shortly).

Why test mode?

Test mode uses sandbox keys that simulate real payment flows without moving actual money. You get the same API responses and webhook events, but no real Naira changes hands.

Now set up your database.

  • Open https://www.mongodb.com/cloud/atlas and sign up for a free account (no credit card required).
  • Create a free M0 cluster when prompted.
  • Add your current IP address to the IP Access List when Atlas asks (this allows your machine to connect).
  • Create a database user with a username and password you will remember.
  • Click Connect on your cluster, choose Drivers, and copy the connection string.
  • Replace <password> in the connection string with the password you just created. Paste the full string somewhere safe.

What is the connection string?

The connection string is the address your application uses to find and authenticate with your MongoDB cluster. It looks like mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/.

🙋‍♀️ Can't connect later?

The most common issue is your IP address not being on the access list. If you switch networks (home to coffee shop), go back to Atlas and add your new IP under Network Access.

help me debug my MongoDB Atlas connection

Initialize the Node.js project and install dependencies

With your accounts ready, create the project folder and install the packages that power the API.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir payaza-fintech-api
cd payaza-fintech-api
  • Initialize a new Node.js project by running:
npm init -y

You should see output confirming a package.json file was created.

  • Install the project dependencies by running:
npm install express@5.2.1 mongoose@9.9.0 jsonwebtoken@9.0.3 zod@4.4.3 dotenv@16.5.0

You should see output showing 5 packages added successfully.

Seeing permission errors?

On macOS, try prefixing the command with sudo. On Windows, right-click your terminal and select Run as administrator.

help me fix npm install permission errors

  • Now update package.json so it has the correct entry point and scripts. Open package.json in VS Code and replace its entire contents with:
{
  "name": "payaza-fintech-api",
  "version": "1.0.0",
  "description": "Fintech payment processing API with Payaza",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  },
  "dependencies": {
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "jsonwebtoken": "9.0.3",
    "mongoose": "9.9.0",
    "zod": "4.4.3"
  }
}

What do these scripts do?

The start script runs your server normally. The dev script uses Node's built-in --watch flag to automatically restart when you save changes.

  • Create the folder structure for your source code by running:
mkdir -p src/routes src/middleware src/models src/services
  • Confirm the folders exist by running:
ls src

You should see middleware, models, routes, and services listed in the output.

  • Create a .env file in the project root. In VS Code, right-click the payaza-fintech-api folder in the Explorer sidebar and select New File. Name it .env.
  • Paste the following into .env, replacing the placeholder values with your real credentials:
PORT=3000
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/payaza-fintech?retryWrites=true&w=majority
JWT_SECRET=your-jwt-secret-here
PAYAZA_PUBLIC_KEY=your-payaza-public-key-here
PAYAZA_SECRET_KEY=your-payaza-secret-key-here
PAYAZA_ACCOUNT_REFERENCE=your-payaza-account-reference-here
PAYAZA_TRANSACTION_PIN=your-transaction-pin-here

Where do these values come from?

  • MONGODB_URI: the connection string you copied from Atlas (replace your-username, your-password, and the cluster address with your real values).
  • JWT_SECRET: any random string you choose (e.g. my-super-secret-key-2026). This signs your auth tokens.
  • PAYAZA_PUBLIC_KEY and PAYAZA_SECRET_KEY: the test keys you copied from the Payaza Developers page.
  • PAYAZA_ACCOUNT_REFERENCE and PAYAZA_TRANSACTION_PIN: found in your Payaza dashboard under your account security settings. You will use these for payouts in a later step. Leave the placeholders for now if you cannot locate them yet.
  • Save .env and package.json.

Never commit your .env file

The .env file contains secrets. If you use Git, add .env to your .gitignore so it never gets pushed to a repository.

Create the entry point and verify connectivity

Your project needs a single file that loads the environment, connects to MongoDB, and starts the Express server. If this file runs without errors, your entire foundation is solid.

  • Create a new file at src/server.js. In VS Code, right-click the src folder and select New File. Name it server.js.
  • Paste the following code into src/server.js:
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');

const app = express();

app.use(express.json());

const PORT = process.env.PORT || 3000;

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

What does this code do?

  • require('dotenv').config() loads your .env values into process.env so the rest of your code can read them.
  • mongoose.connect() opens a connection to your Atlas cluster using the URI from .env.
  • The server only starts (app.listen) after MongoDB connects successfully. If the connection fails, the process exits with an error message instead of running without a database.
  • Save src/server.js.

Before you run this, what do you expect to see if your .env values are correct?

  • Start the server by running:
node src/server.js

You should see both of these lines in your terminal:

Connected to MongoDB
Server running on port 3000

Seeing a MongoDB connection error?

  • Double-check that you replaced the placeholder username, password, and cluster address in your MONGODB_URI with the real values from Atlas.
  • Confirm your current IP address is on the Atlas IP Access List.
  • Make sure your .env file is in the project root (payaza-fintech-api/.env), not inside src/.

help me fix my MongoDB connection error in my Express server

✔️ Awesome, I've got everything!

Great. Make sure you have saved both package.json and src/server.js.

ⓧ I'd like to double check the full code

Here is what src/server.js should look like:

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');

const app = express();

app.use(express.json());

const PORT = process.env.PORT || 3000;

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

And here is what .env should look like (with your real values substituted):

PORT=3000
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/payaza-fintech?retryWrites=true&w=majority
JWT_SECRET=your-jwt-secret-here
PAYAZA_PUBLIC_KEY=your-payaza-public-key-here
PAYAZA_SECRET_KEY=your-payaza-secret-key-here
PAYAZA_ACCOUNT_REFERENCE=your-payaza-account-reference-here
PAYAZA_TRANSACTION_PIN=your-transaction-pin-here

And package.json:

{
  "name": "payaza-fintech-api",
  "version": "1.0.0",
  "description": "Fintech payment processing API with Payaza",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  },
  "dependencies": {
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "jsonwebtoken": "9.0.3",
    "mongoose": "9.9.0",
    "zod": "4.4.3"
  }
}

Your foundation is solid. You have accounts, dependencies, and a running server connected to a real database. Next up, you will build the authentication layer that protects your payment endpoints from unauthorized access.

Build the Express Server with JWT Auth and Validation

Your server starts and connects to MongoDB, but it has no endpoints yet. Anyone on your network could hit those endpoints once they exist.

Payment endpoints must be protected so only authenticated users can create accounts or initiate transfers. In this step, you will add JWT authentication and request validation so that every financial operation requires a valid token and well-formed data.

In this step, get ready to:
  • Create a User model with password hashing and a registration/login API.
  • Build JWT auth middleware and Zod request validation middleware.
  • Protect a payments health endpoint behind authentication and verify the full flow.
Create the User model and auth route

To register and log in users, you need a Mongoose model that stores emails and hashed passwords. You also need a validation middleware that the auth route will use to reject bad input before it reaches the database.

  • Create the User model by adding this code to src/models/User.js:
const mongoose = require('mongoose');
const crypto = require('crypto');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true
  },
  password: {
    type: String,
    required: true
  }
}, { timestamps: true });

userSchema.pre('save', function (next) {
  if (!this.isModified('password')) return next();
  this.password = crypto.createHash('sha256').update(this.password).digest('hex');
  next();
});

userSchema.methods.comparePassword = function (candidatePassword) {
  const hashed = crypto.createHash('sha256').update(candidatePassword).digest('hex');
  return hashed === this.password;
};

module.exports = mongoose.model('User', userSchema);

What does this code do?

The pre('save') hook automatically hashes the password with SHA-256 before saving it to the database. This means plain-text passwords never get stored.

The comparePassword method hashes a candidate password the same way and checks if it matches the stored hash. The login route will use this to verify credentials.

  • Save src/models/User.js. You will test this model when you register your first user shortly.
  • Create the validation middleware by adding this code to src/middleware/validate.js:
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues
      });
    }
    req.validatedBody = result.data;
    next();
  };
}

module.exports = validate;

What does this code do?

This middleware factory takes a Zod schema and returns Express middleware. It runs safeParse on the request body. If the data is invalid, it responds with a 400 error and the specific validation issues.

If validation passes, the cleaned data lands on req.validatedBody so your route handlers always work with validated input.

  • Save src/middleware/validate.js. The auth route you build next uses this middleware.

Now build the auth route that uses both the User model and the validate middleware.

  • Create the auth route with the register endpoint by adding this code to src/routes/auth.js:
const express = require('express');
const jwt = require('jsonwebtoken');
const { z } = require('zod');
const User = require('../models/User');
const validate = require('../middleware/validate');

const router = express.Router();

const authSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6)
});

router.post('/register', validate(authSchema), async (req, res) => {
  try {
    const { email, password } = req.validatedBody;

    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    const user = new User({ email, password });
    await user.save();

    res.status(201).json({ message: 'User registered successfully' });
  } catch (err) {
    res.status(500).json({ error: 'Registration failed' });
  }
});

module.exports = router;

What does this code do?

The authSchema defines the expected shape: a valid email and a password of at least 6 characters. The validate(authSchema) middleware rejects any request that does not match before it reaches the route handler.

The register handler checks for duplicate emails and creates a new user. The password gets hashed automatically by the pre('save') hook you wrote in the User model.

  • Save src/routes/auth.js.
  • Wire the auth route into your server. Open src/server.js and find this line near the top:
const mongoose = require('mongoose');
  • Add this line directly below it:
const authRoutes = require('./routes/auth');
  • Then find this line:
app.use(express.json());
  • Add this line directly below it:
app.use('/api/auth', authRoutes);
  • Save src/server.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.js
  • Open a second terminal. Test the register endpoint by running:
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret123"}'

You should see this response:

{"message":"User registered successfully"}

Seeing an error instead?

  • If you see Cannot find module, check that your file paths match exactly: src/models/User.js, src/middleware/validate.js, and src/routes/auth.js.
  • If you see a MongoDB error, confirm your MONGODB_URI in .env includes your actual username, password, and cluster address.
  • Help me debug my registration endpoint.

Now add the login endpoint so users can get a JWT token.

  • In src/routes/auth.js, find this line at the bottom:
module.exports = router;
  • Add the login route directly above module.exports = router; so the end of your file looks like this:
router.post('/login', validate(authSchema), async (req, res) => {
  try {
    const { email, password } = req.validatedBody;

    const user = await User.findOne({ email });
    if (!user || !user.comparePassword(password)) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const token = jwt.sign(
      { userId: user._id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );

    res.json({ token });
  } catch (err) {
    res.status(500).json({ error: 'Login failed' });
  }
});

module.exports = router;

What does this code do?

The login handler looks up the user by email, then uses comparePassword to verify the password hash matches. If credentials are valid, it creates a JWT token containing the user's ID and email with a 24-hour expiry.

This token is what you will send in future requests to prove you are authenticated.

  • Save src/routes/auth.js.
  • Restart your server (Ctrl+C, then node src/server.js).
  • Test the login endpoint in your second terminal by running:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret123"}'

You should see a JSON response with a token field containing a long encoded string. That is your JWT.

Getting Invalid credentials?

  • Make sure you registered with the same email and password in the previous curl command.
  • Check that JWT_SECRET is set in your .env file. Any non-empty string works.
  • Help me debug my login endpoint.
Create JWT auth middleware and protect payments

You have a way to issue tokens. Now you need middleware that checks incoming requests for a valid token before allowing access to protected routes.

  • Create the auth middleware by adding this code to src/middleware/auth.js:
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

module.exports = authenticate;

What does this code do?

This middleware reads the Authorization header, expecting the format Bearer <token>. If the token is missing or invalid, it returns a 401 immediately.

If the token is valid, the decoded payload (containing userId and email) gets attached to req.user so downstream route handlers know who is making the request.

  • Save src/middleware/auth.js.

Now create the payments route file with a simple health check endpoint. This endpoint will be protected by the auth middleware.

  • Create the payments route by adding this code to src/routes/payments.js:
const express = require('express');
const { z } = require('zod');
const authenticate = require('../middleware/auth');
const validate = require('../middleware/validate');

const router = express.Router();

router.use(authenticate);

router.get('/health', (req, res) => {
  res.json({ status: 'ok', user: req.user.email });
});

module.exports = router;

What does this code do?

The router.use(authenticate) line applies the JWT middleware to every route in this file. Any request to a payments endpoint without a valid token gets rejected before reaching the handler.

The /health endpoint returns the authenticated user's email. It is a quick way to verify that your auth flow works end to end.

  • Save src/routes/payments.js.
  • Wire the payments route into your server. Open src/server.js and find the line:
const authRoutes = require('./routes/auth');
  • Add this line directly below it:
const paymentRoutes = require('./routes/payments');
  • Then find the line:
app.use('/api/auth', authRoutes);
  • Add this line directly below it:
app.use('/api/payments', paymentRoutes);
  • Save src/server.js.
  • Restart your server (Ctrl+C, then node src/server.js).

Server won't start?

  • Check that src/routes/payments.js and src/middleware/auth.js exist at the correct paths.
  • Make sure your require paths in server.js start with ./ for relative imports.
  • Help me fix the server startup error.
Wire up routes and test the full auth flow

Both routes are mounted. Time to prove the full auth flow works: register, login, then use the token on a protected endpoint.

Before you run the next command, predict: what do you think will happen if you hit the health endpoint without a token?

  • Test the protected health endpoint without a token by running:
curl http://localhost:3000/api/payments/health

You should see a 401 response:

{"error":"No token provided"}
  • Now log in again to get a fresh token by running:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret123"}'
  • Copy the token value from the response (the long string inside the token field).
  • Test the health endpoint with your token by running:
curl http://localhost:3000/api/payments/health \
  -H "Authorization: Bearer your-token-here"

Paste your actual token

Replace your-token-here with the actual JWT you copied from the login response. It will be a long string of letters, numbers, and dots.

You should see a 200 response with your email:

{"status":"ok","user":"test@example.com"}

What just happened?

Without a token, the auth middleware rejected the request with a 401. With a valid JWT, it decoded the token, attached the user info to the request, and let it through to the health handler.

Every payment endpoint you add in the next steps will be protected the same way. Only authenticated users can trigger financial operations.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the final src/server.js for this step:

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const authRoutes = require('./routes/auth');
const paymentRoutes = require('./routes/payments');

const app = express();

app.use(express.json());

app.use('/api/auth', authRoutes);
app.use('/api/payments', paymentRoutes);

const PORT = process.env.PORT || 3000;

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

Here is src/middleware/auth.js:

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

module.exports = authenticate;

Here is src/middleware/validate.js:

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues
      });
    }
    req.validatedBody = result.data;
    next();
  };
}

module.exports = validate;

Here is src/models/User.js:

const mongoose = require('mongoose');
const crypto = require('crypto');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true
  },
  password: {
    type: String,
    required: true
  }
}, { timestamps: true });

userSchema.pre('save', function (next) {
  if (!this.isModified('password')) return next();
  this.password = crypto.createHash('sha256').update(this.password).digest('hex');
  next();
});

userSchema.methods.comparePassword = function (candidatePassword) {
  const hashed = crypto.createHash('sha256').update(candidatePassword).digest('hex');
  return hashed === this.password;
};

module.exports = mongoose.model('User', userSchema);

Here is src/routes/payments.js:

const express = require('express');
const { z } = require('zod');
const authenticate = require('../middleware/auth');
const validate = require('../middleware/validate');

const router = express.Router();

router.use(authenticate);

router.get('/health', (req, res) => {
  res.json({ status: 'ok', user: req.user.email });
});

module.exports = router;

For src/routes/auth.js, scroll up and confirm you have both the register route (from the first chunk) and the login route (from the second chunk) present in order, with module.exports = router; at the very end.

Add environment validation on startup

Production fintech servers crash immediately if a required secret is missing. A silent failure where the server boots but throws at the first payment request is worse than not starting at all. The industry-standard pattern is called fail-fast: validate every required environment variable at startup and exit with a clear error if any are missing.

  • Create a new file at src/utils/validateEnv.js and add the environment validation function:
// List every env var the app needs to function
const REQUIRED_VARS = [
  'MONGODB_URI',
  'JWT_SECRET',
  'PAYAZA_PUBLIC_KEY',
  'PAYAZA_SECRET_KEY',
  'PAYAZA_TRANSACTION_PIN',
  'PAYAZA_ACCOUNT_REFERENCE'
];

function validateEnv() {
  const missing = REQUIRED_VARS.filter((key) => !process.env[key]);

  if (missing.length > 0) {
    console.error('FATAL: Missing required environment variables:');
    missing.forEach((key) => console.error(`  - ${key}`));
    console.error('\nAdd them to your .env file and restart.');
    process.exit(1);
  }
}

module.exports = validateEnv;

What does this code do?

It loops through every environment variable your app depends on. If any are missing, it prints all of them at once (not just the first) and exits with code 1. This saves you from a cycle of fixing one missing var, restarting, finding another, and repeating.

Companies like Stripe and Paystack use this pattern. Their server processes refuse to accept traffic until all secrets, database URLs, and feature flags are confirmed present.

  • Save src/utils/validateEnv.js.
  • Open src/server.js and find this line near the top:
require('dotenv').config();
  • Add the validation call directly below it, so the top of your file now reads:
require('dotenv').config();
const validateEnv = require('./utils/validateEnv');
validateEnv();
  • Save src/server.js.
  • Test the fail-fast behaviour by temporarily renaming a variable in .env. Change JWT_SECRET to JWT_SECRET_DISABLED and restart your server:
node src/server.js

You should see the FATAL message listing JWT_SECRET as missing, and the process exits immediately without connecting to MongoDB.

  • Rename the variable back to JWT_SECRET in .env and restart your server to confirm it boots normally.

Server still exits after restoring the variable?

  • Make sure you removed the _DISABLED suffix completely. The variable name must match exactly.
  • Confirm there are no extra spaces around the = sign in your .env file.
  • Help me fix my environment validation.
Add a standardised error response middleware

Right now, each route handler formats errors differently. Clients parsing your API need a consistent shape so they can handle all errors with a single parsing strategy.

  • Create a new file at src/middleware/errorHandler.js and add the global error handler:
// Global error handler - catches unhandled errors from any route
function errorHandler(err, req, res, next) {
  // Log the full error server-side for debugging
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.path}:`, err.message);

  // Determine the status code
  const statusCode = err.statusCode || 500;

  // Send a consistent response shape to the client
  res.status(statusCode).json({
    error: err.message || 'Internal server error',
    statusCode,
    path: req.path,
    timestamp: new Date().toISOString()
  });
}

module.exports = errorHandler;

What does this code do?

Express recognises a middleware with four parameters (err, req, res, next) as an error handler. When any route calls next(err) or throws an unhandled error, Express routes it directly here.

Every error response now has the same shape: an error message, a numeric status code, the path that failed, and a timestamp. Clients and monitoring tools can parse this structure once and handle all errors uniformly.

  • Save src/middleware/errorHandler.js.
  • Open src/server.js and add the import near the top with your other requires:
const errorHandler = require('./middleware/errorHandler');
  • Add the error handler as the very last middleware, after all route mounts. Find the mongoose.connect line and add this directly above it:
// Must be AFTER all routes - catches unhandled errors
app.use(errorHandler);
  • Save src/server.js and restart your server:
node src/server.js
  • Test the error handler by sending an invalid request to the register endpoint:
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "not-an-email", "password": "ab"}'

You should see a 400 response with the Zod validation issues listing both the invalid email and the too-short password. The validate middleware catches this before the global error handler, but any unhandled throw in your routes will now produce the standardised format.

Why does error format matter in fintech?

Payment integrations are debugged under pressure. When a payout fails at 2am, the on-call engineer needs to see exactly which endpoint failed, when, and why. A consistent error shape means your monitoring tools can parse and alert on errors automatically without per-endpoint configuration.

Your API now has a complete auth system. Only users with a valid JWT can access payment endpoints. Next up, you will use this foundation to create virtual accounts for collecting payments through Payaza.

Create Virtual Accounts for Payment Collection

Your auth system is locked down and your server is connected to MongoDB. Now it's time to do what fintech APIs are built for: moving money.

To collect payments from customers, you need to generate unique virtual account numbers they can transfer money to. Payaza creates these accounts on demand through their API, and each one is tied to a specific transaction. In this step, you'll build the service layer that talks to Payaza, create a data model to track every transaction, and wire up the endpoint that ties it all together.

What is Payaza?

Payaza is a payment infrastructure provider for African markets. Its API covers the full money-movement lifecycle that production fintech apps need, split into three pillars:

  • Collections (receiving money) — card charging with both 3D Secure and 2D Secure flows for online payments, direct bank transfer collection, and dynamic virtual accounts with configurable expiry times. Virtual accounts are the most common pattern for B2B payments: you generate a unique account number per transaction, the customer transfers money into it, and Payaza notifies you when funds arrive.
  • Disbursements (sending money) — payouts to bank accounts via NUBAN (Nigeria Uniform Bank Account Number), real-time payout status tracking through polling or webhooks, and multi-currency support across NGN, USD, GHS, KES, UGX, TZS, and XOF. Every transfer requires a 6-digit transaction PIN and a verified account reference for security.
  • Utility endpoints — bank list retrieval (fetch all supported banks dynamically so your frontend never hardcodes bank codes), account name enquiry (resolve an account number to the registered holder's name before sending money), refund initiation and refund status tracking, and full transaction history for reconciliation and audit trails.

In this project, you are building with the virtual accounts, webhooks, account enquiry, and payout endpoints. The same request patterns (authenticated POST with a structured service_payload) apply to Payaza's card charging and refund flows.

💡 How does Payaza's API architecture work?

Payaza's API has several architectural quirks that differ from typical REST APIs. Understanding these upfront saves debugging time later.

  • Non-standard auth header — Payaza uses Authorization: Payaza <base64-encoded-key> instead of Bearer tokens. This is the single most common cause of 401 errors when integrating for the first time.
  • Fixed /live/ path prefix — the base URL is https://api.payaza.africa/live/ for both test and production. The /live/ segment never changes. Test vs live mode is controlled entirely by the API key you use and the X-TenantID header value (test or live).
  • Required headers on every request — three headers must accompany every call: Authorization (your encoded key), X-TenantID (environment selector), and Content-Type: application/json. An optional X-ProductID header defaults to app.
  • Webhook-driven notifications — Payaza notifies your server of payment events (collections arriving, payouts completing) via POST requests signed with HMAC SHA-512. The signature lives in the x-payaza-signature header. You verify it by recomputing the hash using your secret key.

You will implement all of these patterns in the code that follows. The service module you build next centralises the auth header logic so you only write it once.

In this step, get ready to:
  • Create a Payaza service module that authenticates with their API.
  • Define a Transaction model to track payment state in MongoDB.
  • Add a virtual account creation endpoint with Zod validation.
Create the Payaza service layer

Payaza uses a custom authorization format. Instead of the typical Bearer token, it requires Authorization: Payaza <base64-encoded-public-key>. A dedicated service module keeps this authentication logic in one reusable place.

  • Create a new file at src/services/payaza.js and add the base URL constant and authentication helper by pasting this code:
const BASE_URL = 'https://api.payaza.africa/live';

function getAuthHeaders() {
  const publicKeyBase64 = Buffer.from(process.env.PAYAZA_PUBLIC_KEY).toString('base64');
  return {
    'Authorization': `Payaza ${publicKeyBase64}`,
    'X-TenantID': 'test',
    'Content-Type': 'application/json'
  };
}

What does this code do?

The getAuthHeaders() function builds the three headers every Payaza request needs.

  • It base64-encodes your public key and places it after the Payaza prefix in the Authorization header.
  • X-TenantID: test tells Payaza to route requests to the sandbox environment so no real money moves.
  • Below the getAuthHeaders function, add the virtual account creation function and the module export:
async function createVirtualAccount({ amount, customerEmail, customerName, transactionReference }) {
  const response = await fetch(`${BASE_URL}/merchant-collection/merchant/virtual_account/generate_virtual_account`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      service_payload: {
        request_application: 'Payaza',
        application_module: 'USER_MODULE',
        application_version: '1.0.0',
        request_class: 'MerchantCreateDynamicVirtualAccount',
        customer_email: customerEmail,
        customer_first_name: customerName.split(' ')[0],
        customer_last_name: customerName.split(' ').slice(1).join(' ') || customerName,
        account_reference: transactionReference,
        virtual_account_amount: amount,
        currency: 'NGN'
      }
    })
  });

  return response.json();
}

module.exports = {
  createVirtualAccount
};

What does this code do?

The createVirtualAccount function POSTs to Payaza's dynamic virtual account endpoint. Payaza returns a unique bank account number that the customer can transfer money into.

  • It splits the customer's full name into first and last for Payaza's required fields.
  • The account_reference ties this virtual account back to your internal transaction reference.
  • The response includes an account_number and bank_name the customer uses to send their payment.

💡 Why is currency hardcoded to NGN?

Payaza supports multi-currency operations across NGN, USD, GHS, KES, UGX, TZS, and XOF. In production, you would accept a currency parameter from the client and validate it against this list.

For this project, you are working in Payaza's Nigerian test environment where NGN is the only active sandbox currency. The pattern stays the same regardless of currency.

  • Save src/services/payaza.js.

Seeing a syntax error?

  • Make sure the module.exports block is outside and below the createVirtualAccount function's closing brace.
  • Confirm you have matching opening and closing braces for the JSON.stringify() call. The nested object structure is three levels deep.

Help me fix a syntax error in my Payaza service file

Add request timeouts with AbortController

External APIs can hang. If Payaza takes 30 seconds to respond, your server holds that connection open, the client waits, and if many requests pile up your server runs out of resources. Production fintech APIs always enforce a timeout on outbound HTTP calls.

  • In src/services/payaza.js, find the createVirtualAccount function. Replace the fetch call's opening line:
const response = await fetch(`${BASE_URL}/merchant-collection/merchant/virtual_account/generate_virtual_account`, {
  • Replace it with a version that creates an AbortController with a 10-second timeout:
// Abort the request if Payaza doesn't respond within 10 seconds
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);

try {
  const response = await fetch(`${BASE_URL}/merchant-collection/merchant/virtual_account/generate_virtual_account`, {
    signal: controller.signal,
  • Then find the closing of the fetch call (the line with return response.json();) and wrap it in the try/finally to clear the timeout:
  const data = await response.json();
  return data;
} finally {
  clearTimeout(timeout);
}

What does this code do?

The AbortController starts a 10-second countdown. If Payaza hasn't responded by then, the controller aborts the fetch request and throws an AbortError. This prevents your server from holding connections open indefinitely.

The finally block clears the timeout if the request completes normally, preventing a memory leak from accumulated timers.

  • Save src/services/payaza.js.
  • Restart your server and test the virtual account creation again to confirm the timeout doesn't interfere with normal requests (Payaza typically responds in 1-3 seconds):
node src/server.js

Your previous curl command for creating a virtual account should still work. The timeout only fires if the response takes longer than 10 seconds.

Why 10 seconds?

Payment gateways typically respond in 1-5 seconds. A 10-second timeout gives room for network jitter without hanging forever. In production, you would tune this per endpoint. Account creation might tolerate 10 seconds, but a status check might timeout at 5.

Create the Transaction model and collection endpoint

Every payment operation needs a record in your database. The Transaction model tracks each payment's lifecycle from PENDING through SUCCESS or FAILED, giving you a full audit trail.

  • Create a new file at src/models/Transaction.js and add the schema definition with the identity and state fields:
const mongoose = require('mongoose');

const transactionSchema = new mongoose.Schema({
  transactionReference: {
    type: String,
    required: true,
    unique: true
  },
  type: {
    type: String,
    enum: ['COLLECTION', 'PAYOUT'],
    required: true
  },
  status: {
    type: String,
    enum: ['PENDING', 'SUCCESS', 'FAILED'],
    default: 'PENDING'
  },
  amount: {
    type: Number,
    required: true
  },
  • Below the amount field, add the remaining context fields, close the schema, and export the model:
  currency: {
    type: String,
    default: 'NGN'
  },
  customerEmail: {
    type: String
  },
  metadata: {
    type: mongoose.Schema.Types.Mixed
  },
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  }
}, { timestamps: true });

module.exports = mongoose.model('Transaction', transactionSchema);

What does this schema do?

  • transactionReference is unique so you can never accidentally create duplicate payment records.
  • type distinguishes between money coming in (COLLECTION) and money going out (PAYOUT).
  • status defaults to PENDING and updates when Payaza confirms the payment succeeded or failed.
  • metadata stores the full Payaza response so you have a complete record of what the gateway returned.
  • userId links every transaction to the authenticated user who initiated it.
  • Save src/models/Transaction.js.
  • Confirm both new files exist by running:
ls src/services src/models

You should see payaza.js listed under src/services and both Transaction.js and User.js listed under src/models.

Now wire the service and model into your payments route. The endpoint will validate incoming requests with Zod, call Payaza to generate the virtual account, and save a transaction record.

  • Open src/routes/payments.js.
  • Find these lines at the top of the file:
const authenticate = require('../middleware/auth');
const validate = require('../middleware/validate');
  • Add these two new require lines directly below them, so the file now has all four imports together:
const Transaction = require('../models/Transaction');
const payaza = require('../services/payaza');
  • Below the /health route's closing });, add the Zod validation schema and the first part of the virtual account route:
const virtualAccountSchema = z.object({
  amount: z.number().positive(),
  customerEmail: z.string().email(),
  customerName: z.string().min(2)
});

router.post('/virtual-account', validate(virtualAccountSchema), async (req, res) => {
  try {
    const { amount, customerEmail, customerName } = req.validatedBody;
    const transactionReference = `COL-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
  • Directly below the transactionReference line, add the Payaza API call, transaction save, and the response:
    const payazaResponse = await payaza.createVirtualAccount({
      amount,
      customerEmail,
      customerName,
      transactionReference
    });

    const transaction = new Transaction({
      transactionReference,
      type: 'COLLECTION',
      status: 'PENDING',
      amount,
      currency: 'NGN',
      customerEmail,
      metadata: payazaResponse,
      userId: req.user.userId
    });
    await transaction.save();

    res.status(201).json({
      transactionReference,
      virtualAccount: payazaResponse,
      status: 'PENDING'
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to create virtual account' });
  }
});

What does this route do?

  • The Zod schema rejects requests missing a valid amount, email, or customer name before they reach Payaza.
  • The transaction reference uses a COL- prefix plus a timestamp and random string to guarantee uniqueness.
  • The transaction is saved as PENDING immediately. Its status will update later when Payaza sends a webhook confirming payment.
  • The full Payaza response (including the generated account number) is stored in metadata for auditing.
  • Save src/routes/payments.js.
  • Restart your server by running:
node src/server.js

You should see "Connected to MongoDB" and "Server running on port 3000" with no errors. This confirms all your new imports resolve correctly.

Seeing a module not found error?

  • If the error mentions ../models/Transaction, confirm the file is saved at exactly src/models/Transaction.js with a capital T.
  • If the error mentions ../services/payaza, confirm the file is saved at exactly src/services/payaza.js with a lowercase p.
  • Double-check that the require paths in payments.js use ../ (going up one directory from routes/ to src/).

Help me fix a module not found error in my payments route

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Since each file is long, scroll up and confirm the following chunks are present and in order:

  • src/services/payaza.js: BASE_URL constant, then getAuthHeaders() function, then createVirtualAccount() function, then module.exports.
  • src/models/Transaction.js: mongoose require, then the schema with fields transactionReference, type, status, amount, currency, customerEmail, metadata, userId, then timestamps: true, then module.exports.
  • src/routes/payments.js: four require lines (express, zod, auth, validate, Transaction, payaza), then router.use(authenticate), then the /health route, then virtualAccountSchema, then the POST /virtual-account route, then module.exports.
Test the virtual account creation

Everything is wired up. Time to see if Payaza actually returns a virtual account number when you hit your new endpoint.

  • First, get a fresh JWT token by logging in. Run this in a new terminal:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'

Copy the token value from the response. You will use it in the next request.

  • Before you run the next command, what do you expect to get back from Payaza's test environment? Think about what a virtual account response would include.
  • Send a POST request to create a virtual account by running this command (replace YOUR_TOKEN with the token you just copied):
curl -X POST http://localhost:3000/api/payments/virtual-account \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"amount": 1000, "customerEmail": "customer@example.com", "customerName": "John Doe"}'

You should receive a JSON response containing a transactionReference (starting with COL-), the virtualAccount object from Payaza (which includes an account_number and bank_name), and a status of PENDING.

What is the PENDING status?

The transaction starts as PENDING because the virtual account has been created, but no one has paid into it yet. The status will update to SUCCESS later when a customer actually transfers money and Payaza sends a webhook notification to confirm it.

  • Verify the transaction was persisted by checking your MongoDB Atlas cluster. Open the Atlas dashboard, navigate to your cluster, click Browse Collections, and select the transactions collection.

You should see a document with type: "COLLECTION", status: "PENDING", and the full Payaza response stored in the metadata field.

Getting a 401 Unauthorized from Payaza?

  • The most common cause is an incorrectly formatted Authorization header. Payaza requires Payaza <key> format, not Bearer. Confirm your PAYAZA_PUBLIC_KEY in .env is the raw key from your dashboard (the code base64-encodes it for you).
  • Make sure you are using your Test mode key, not a Live key. Check that you switched to Test Mode in the Payaza dashboard before copying your keys.
  • If you see a 500 error from your own server instead, check the terminal for the full error message. A missing PAYAZA_PUBLIC_KEY environment variable will cause Buffer.from(undefined) to throw.

Help me debug a Payaza authentication error when creating a virtual account

Your API can now generate virtual accounts on demand. Customers have a unique bank account to pay into, and every transaction is tracked in MongoDB. Next up, you'll handle what happens when money actually arrives by building a webhook endpoint with cryptographic signature verification.

Handle Webhooks with Signature Verification

When a customer pays into a virtual account, Payaza sends a webhook notification to your server with the payment details. This is how your API learns that money has arrived in real time.

The problem is that right now, anyone could POST fake payment data to your webhook URL and mark transactions as paid. You need to verify an HMAC SHA-512 signature to cryptographically prove each notification actually came from Payaza.

In this step, get ready to:
  • Create a webhook endpoint that processes payment notifications from Payaza.
  • Add HMAC SHA-512 signature verification to reject forged requests.
  • Test that valid signatures update transactions and invalid ones get rejected.
Create the webhook endpoint (the security hole)

You will first build the webhook handler without any signature verification. This intentionally creates a security hole so you can see the problem before solving it.

  • Create a new file at src/routes/webhooks.js and add the following route with status-mapping logic:
const express = require('express');
const Transaction = require('../models/Transaction');

const router = express.Router();

// Naive webhook handler - NO signature verification
router.post('/payaza', async (req, res) => {
  try {
    const payload = req.body;
    const { transaction_reference, transaction_status } = payload;

    // Map Payaza status strings to internal status values
    let newStatus;
    if (transaction_status === 'Funds Received' || transaction_status === 'NIP_SUCCESS') {
      newStatus = 'SUCCESS';
    } else if (transaction_status === 'Transaction Failed' || transaction_status === 'NIP_FAILURE') {
      newStatus = 'FAILED';
    } else {
      newStatus = 'PENDING';
    }

What does this code do?

The route handler reads transaction_reference and transaction_status from the incoming webhook payload.

It maps Payaza's status strings (like Funds Received or NIP_FAILURE) to your internal enum values (SUCCESS, FAILED, PENDING).

  • Still in src/routes/webhooks.js, add the database update logic and module export directly below the status-mapping code:
    // Find the matching transaction and update its status
    const transaction = await Transaction.findOneAndUpdate(
      { transactionReference: transaction_reference },
      {
        status: newStatus,
        $set: { 'metadata.webhookPayload': payload }
      },
      { new: true }
    );

    if (!transaction) {
      return res.status(404).json({ error: 'Transaction not found' });
    }

    res.status(200).json({ received: true, status: newStatus });
  } catch (err) {
    res.status(500).json({ error: 'Webhook processing failed' });
  }
});

module.exports = router;

What does this code do?

It uses findOneAndUpdate to find the transaction matching the webhook's reference and update its status in one atomic operation.

The webhook payload is also stored in the transaction's metadata field for audit purposes.

  • Save src/routes/webhooks.js.

Now you need to mount this route in your server and test the security hole.

  • In src/server.js, find this line near the top of your imports:
const paymentRoutes = require('./routes/payments');
  • Add the webhook import directly below it:
const webhookRoutes = require('./routes/webhooks');
  • Then find the line where payment routes are mounted:
app.use('/api/payments', paymentRoutes);
  • Add the webhook route mount directly below it:
app.use('/api/webhooks', webhookRoutes);
  • Save src/server.js and restart your server by running:
node src/server.js

Before you send the next request, ask yourself: should a random POST request with no credentials be able to mark a payment as successful?

  • Open a second terminal and send a fake webhook notification by running:
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -d '{"transaction_reference":"FAKE-123","transaction_status":"Funds Received"}'

You receive a 404 response because no transaction matches that reference. The endpoint processed the request with zero questions asked.

That is the security hole. Anyone on the internet can POST to this URL and your server will attempt to update transaction records. If an attacker guesses a real reference, they could mark a payment as successful without any money moving.

This is intentional

The endpoint accepts requests from anyone with no authentication or verification. In the next substep, you will close this hole with cryptographic signature verification.

Add HMAC SHA-512 signature verification

Payaza signs every webhook notification by computing an HMAC SHA-512 hash of the request body using your secret key. Your middleware will recompute the same hash and compare it to the signature in the request header.

What is HMAC SHA-512?

HMAC (Hash-based Message Authentication Code) combines a secret key with a hash function to produce a unique fingerprint of a message. Only someone who knows your secret key can produce the correct fingerprint.

If the fingerprint in the x-payaza-signature header matches what you compute locally, the request genuinely came from Payaza.

  • Create a new file at src/middleware/verifyPayazaSignature.js and add the following code:
const crypto = require('crypto');

function verifyPayazaSignature(req, res, next) {
  // Check for the signature header
  const signature = req.headers['x-payaza-signature'];
  if (!signature) {
    return res.status(401).json({ error: 'Missing webhook signature' });
  }

  // Read the raw request body captured by Express
  const rawBody = req.rawBody;
  if (!rawBody) {
    return res.status(400).json({ error: 'Unable to read request body' });
  }

  // Recompute the HMAC using your secret key
  const secretKey = process.env.PAYAZA_SECRET_KEY;
  const computedSignature = crypto
    .createHmac('sha512', secretKey)
    .update(rawBody, 'utf8')
    .digest('base64');

  // Reject if signatures don't match
  if (computedSignature !== signature) {
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  next();
}

module.exports = verifyPayazaSignature;

What does this middleware do?

  • It reads the x-payaza-signature header from the incoming request. If missing, it immediately returns 401.
  • It reads req.rawBody (the original byte-for-byte request body) and computes an HMAC SHA-512 hash using your PAYAZA_SECRET_KEY.
  • It encodes the hash as base64 and compares it to the header value. A mismatch means the request was forged.
  • Save src/middleware/verifyPayazaSignature.js.

Now apply this middleware to the webhook route and configure Express to capture the raw request body for signature computation.

  • In src/routes/webhooks.js, find this line at the top:
const Transaction = require('../models/Transaction');
  • Add the middleware import directly above it, so your imports look like this:
const express = require('express');
const verifyPayazaSignature = require('../middleware/verifyPayazaSignature');
const Transaction = require('../models/Transaction');
  • Then find the route definition line:
router.post('/payaza', async (req, res) => {
  • Add verifyPayazaSignature as middleware before the handler, so the line now looks like this:
router.post('/payaza', verifyPayazaSignature, async (req, res) => {
  • Save src/routes/webhooks.js.

Why does the middleware need req.rawBody?

When Express parses JSON, it converts the request body into a JavaScript object. If you call JSON.stringify(req.body) to recompute the hash, the byte order might differ from the original request.

The HMAC must be computed on the exact bytes Payaza sent. You need the raw, unparsed body string for an accurate comparison.

  • In src/server.js, find the line:
app.use(express.json());
  • Replace it with the following two blocks. The first applies a special JSON parser to webhook routes that captures the raw body. The second is the general parser for all other routes:
// Capture raw body for webhook signature verification
app.use('/api/webhooks', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

// General JSON parser for all other routes
app.use(express.json());

What does this code do?

The verify callback fires during JSON parsing and gives you access to the raw buffer before Express transforms it. You store it on req.rawBody so the signature middleware can use it later.

This path-specific parser only runs for requests to /api/webhooks. All other routes use the standard express.json() without the overhead of capturing raw bytes.

  • Save src/server.js and restart your server:
node src/server.js

Before you test, predict: what should happen when you send the same fake request as before, now that signature verification is active?

  • In your second terminal, send the same unverified request:
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -d '{"transaction_reference":"FAKE-123","transaction_status":"Funds Received"}'

You now receive a 401 response with {"error":"Missing webhook signature"}. The security hole is closed.

Still getting a 200 or 404 instead of 401?

  • Make sure you added verifyPayazaSignature as the second argument in the router.post call inside src/routes/webhooks.js.
  • Confirm the path-specific express.json with the verify callback is mounted BEFORE the general app.use(express.json()) in server.js.
  • Restart the server after saving changes to ensure the latest code is loaded.

Help me debug my webhook signature verification.

Test the secured webhook with a valid signature

To prove the webhook works with legitimate notifications, you need to compute the correct HMAC signature for a test payload and send it in the header.

  • Compute the HMAC signature for a test payload by running:
node -e "require('dotenv').config(); const crypto = require('crypto'); const payload = JSON.stringify({transaction_reference:'FAKE-123',transaction_status:'Funds Received'}); const sig = crypto.createHmac('sha512', process.env.PAYAZA_SECRET_KEY).update(payload, 'utf8').digest('base64'); console.log(sig);"

What does this command do?

It runs an inline Node.js script that loads your .env variables, builds the exact JSON payload you plan to send, and computes its HMAC SHA-512 hash using your secret key. The printed base64 string is the signature you will include in the next request.

  • Copy the signature string that was printed.
  • Send the webhook request with the valid signature by running the following command, replacing YOUR_COMPUTED_SIGNATURE with the value you just copied:
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -H "x-payaza-signature: YOUR_COMPUTED_SIGNATURE" \
  -d '{"transaction_reference":"FAKE-123","transaction_status":"Funds Received"}'

Make sure the payload is identical

The JSON string you send in the -d flag must be byte-for-byte identical to what you used to compute the signature. Any difference in spacing, key order, or quoting produces a different hash and a 401 rejection.

Because no real transaction with reference FAKE-123 exists in your database, you receive a 404 response. The important difference from before is that the request passed signature verification and reached the handler logic.

To see a full end-to-end success, you need to use a real transaction reference from your database.

  • Create a virtual account to get a real PENDING transaction (using your auth token from Step 2):
curl -X POST http://localhost:3000/api/payments/virtual-account \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"amount":5000,"customerEmail":"test@example.com","customerName":"Test User"}'
  • Copy the transactionReference value from the response.
  • Compute the signature for a webhook payload using that reference by running:
node -e "require('dotenv').config(); const crypto = require('crypto'); const payload = JSON.stringify({transaction_reference:'YOUR_TRANSACTION_REFERENCE',transaction_status:'Funds Received'}); const sig = crypto.createHmac('sha512', process.env.PAYAZA_SECRET_KEY).update(payload, 'utf8').digest('base64'); console.log(sig);"
  • Send the webhook with the valid signature and your real transaction reference:
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -H "x-payaza-signature: YOUR_COMPUTED_SIGNATURE" \
  -d '{"transaction_reference":"YOUR_TRANSACTION_REFERENCE","transaction_status":"Funds Received"}'

You should see {"received":true,"status":"SUCCESS"}. The transaction in MongoDB has been updated from PENDING to SUCCESS through a verified webhook.

  • Finally, confirm that a wrong signature still gets rejected by sending a request with a garbage signature:
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -H "x-payaza-signature: dGhpc2lzYWZha2VzaWduYXR1cmU=" \
  -d '{"transaction_reference":"FAKE-123","transaction_status":"Funds Received"}'

You receive {"error":"Invalid webhook signature"} with a 401 status. Invalid signatures are rejected before any database operations run.

Getting 401 even with your computed signature?

  • Verify that PAYAZA_SECRET_KEY in your .env file matches the key you used to compute the HMAC. The test script reads from your environment directly.
  • Ensure the JSON in the -d flag is exactly the same string you used when computing the signature. Even a single extra space changes the hash.
  • Confirm your server was restarted after adding the verify callback to express.json in server.js.

Help me troubleshoot my webhook HMAC verification.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: src/routes/webhooks.js, src/middleware/verifyPayazaSignature.js, and src/server.js.

ⓧ I'd like to double check the full code

Your step has three changed files. Scroll up and confirm each one matches the following references.

Here is the full src/middleware/verifyPayazaSignature.js:

const crypto = require('crypto');

function verifyPayazaSignature(req, res, next) {
  const signature = req.headers['x-payaza-signature'];
  if (!signature) {
    return res.status(401).json({ error: 'Missing webhook signature' });
  }

  const rawBody = req.rawBody;
  if (!rawBody) {
    return res.status(400).json({ error: 'Unable to read request body' });
  }

  const secretKey = process.env.PAYAZA_SECRET_KEY;
  const computedSignature = crypto
    .createHmac('sha512', secretKey)
    .update(rawBody, 'utf8')
    .digest('base64');

  if (computedSignature !== signature) {
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  next();
}

module.exports = verifyPayazaSignature;

Here is the full src/routes/webhooks.js. Confirm the verifyPayazaSignature import and middleware are in place:

const express = require('express');
const verifyPayazaSignature = require('../middleware/verifyPayazaSignature');
const Transaction = require('../models/Transaction');

const router = express.Router();

router.post('/payaza', verifyPayazaSignature, async (req, res) => {
  try {
    const payload = req.body;
    const { transaction_reference, transaction_status } = payload;

    let newStatus;
    if (transaction_status === 'Funds Received' || transaction_status === 'NIP_SUCCESS') {
      newStatus = 'SUCCESS';
    } else if (transaction_status === 'Transaction Failed' || transaction_status === 'NIP_FAILURE') {
      newStatus = 'FAILED';
    } else {
      newStatus = 'PENDING';
    }

    const transaction = await Transaction.findOneAndUpdate(
      { transactionReference: transaction_reference },
      {
        status: newStatus,
        $set: { 'metadata.webhookPayload': payload }
      },
      { new: true }
    );

    if (!transaction) {
      return res.status(404).json({ error: 'Transaction not found' });
    }

    res.status(200).json({ received: true, status: newStatus });
  } catch (err) {
    res.status(500).json({ error: 'Webhook processing failed' });
  }
});

module.exports = router;

Here is the full src/server.js. Confirm the webhook-specific express.json with verify comes BEFORE the general express.json():

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const authRoutes = require('./routes/auth');
const paymentRoutes = require('./routes/payments');
const webhookRoutes = require('./routes/webhooks');

const app = express();

app.use('/api/webhooks', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.use(express.json());

app.use('/api/auth', authRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/webhooks', webhookRoutes);
const PORT = process.env.PORT || 3000;

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });
Add webhook deduplication

Payaza may send the same webhook multiple times if your server is slow to respond or if there is a network retry. Processing the same payment notification twice could corrupt your transaction state. You need to track which webhooks you have already processed and skip duplicates.

  • Create a new model at src/models/ProcessedWebhook.js to track processed webhook IDs:
const mongoose = require('mongoose');

// Track processed webhooks to prevent duplicate processing
const processedWebhookSchema = new mongoose.Schema({
  webhookId: {
    type: String,
    required: true,
    unique: true
  },
  processedAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('ProcessedWebhook', processedWebhookSchema);

What does this model do?

Each webhook gets a unique identifier (Payaza includes a session_id in every payload). Before processing a webhook, you check if that ID already exists in this collection. If it does, you return 200 without processing again.

The unique: true index handles the race condition where two identical webhooks arrive simultaneously. The second insert attempt throws a duplicate key error, which you catch gracefully.

  • Save src/models/ProcessedWebhook.js.
  • Now update src/routes/webhooks.js to check for duplicates. Find this import line at the top:
const Transaction = require('../models/Transaction');
  • Add the ProcessedWebhook import directly below it:
const ProcessedWebhook = require('../models/ProcessedWebhook');
  • Inside the route handler, find this line:
    const payload = req.body;
    const { transaction_reference, transaction_status } = payload;
  • Add the deduplication check directly below those two lines, before the status-mapping logic:
    // Deduplicate: skip if we already processed this webhook
    const webhookId = payload.session_id || transaction_reference;
    const alreadyProcessed = await ProcessedWebhook.findOne({ webhookId });
    if (alreadyProcessed) {
      return res.status(200).json({ received: true, duplicate: true });
    }
  • Then find the line that sends the success response:
    res.status(200).json({ received: true, status: newStatus });
  • Add a line directly above it to record the webhook as processed:
    // Record this webhook as processed
    await ProcessedWebhook.create({ webhookId });

    res.status(200).json({ received: true, status: newStatus });
  • Save src/routes/webhooks.js and restart your server:
node src/server.js
  • Test deduplication by sending the same valid webhook twice. First compute a signature for a test payload and send it:
node -e "require('dotenv').config(); const crypto = require('crypto'); const payload = JSON.stringify({transaction_reference:'DEDUP-TEST',transaction_status:'Funds Received',session_id:'sess-001'}); const sig = crypto.createHmac('sha512', process.env.PAYAZA_SECRET_KEY).update(payload, 'utf8').digest('base64'); console.log(sig);"
  • Send the webhook with the computed signature (replace YOUR_SIG):
curl -X POST http://localhost:3000/api/webhooks/payaza \
  -H "Content-Type: application/json" \
  -H "x-payaza-signature: YOUR_SIG" \
  -d '{"transaction_reference":"DEDUP-TEST","transaction_status":"Funds Received","session_id":"sess-001"}'

The first request returns a 404 (no matching transaction, which is fine for this test). Now send the exact same request again with the same signature.

The second request returns {"received":true,"duplicate":true}. Your webhook handler detected the duplicate and returned early without re-processing.

Why is webhook deduplication critical?

Payment gateways guarantee at-least-once delivery, not exactly-once. Network timeouts, retries, and load-balancer replays mean your server will receive the same webhook multiple times in production. Without deduplication, you could credit a customer's balance twice or send duplicate notifications.

Your webhook endpoint is now cryptographically secured. No one can forge a payment notification without knowing your secret key. Next up, you will build the payout side of the money flow: verifying recipient accounts and initiating bank transfers.

Initiate Payouts with Account Verification

Your webhook system is in place and payment collection is working. Money can flow in, and your server verifies each notification cryptographically before updating the transaction status.

Now you need the other half of the money flow: sending money out. Sending funds to a wrong bank account is irreversible. Payaza enforces a safeguard here by requiring you to verify the recipient's account name before initiating any transfer.

In this step, get ready to:
  • Add an account name verification endpoint that confirms the recipient before sending money.
  • Build a transfer initiation endpoint that sends payouts through Payaza.
  • Create a transfer status endpoint to check whether a payout succeeded or failed.
Add account name verification endpoint

Before sending money, you must confirm that a bank account number actually belongs to the person you intend to pay. Payaza's name enquiry API resolves an account number into the registered account holder's name.

  • In src/services/payaza.js, add the following verifyAccountName function above the module.exports line:
async function verifyAccountName({ bankCode, accountNumber }) {
  const response = await fetch(`${BASE_URL}/payaza-account/api/v1/mainaccounts/merchant/provider/enquiry`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      service_payload: {
        currency: 'NGN',
        bank_code: bankCode,
        account_number: accountNumber
      }
    })
  });

  return response.json();
}

What does this function do?

It sends the bank code and account number to Payaza's enquiry endpoint. Payaza checks with the bank and returns the registered name for that account. This lets you confirm the recipient's identity before any money moves.

  • Now update the module.exports at the bottom of src/services/payaza.js. Find this line:
module.exports = {
  createVirtualAccount
};
  • Replace it with this to export the new function:
module.exports = {
  createVirtualAccount,
  verifyAccountName
};
  • Save src/services/payaza.js.

Next you need a route that calls this service function and validates the incoming request.

  • In src/routes/payments.js, add the following code below the virtual-account route's closing });:
const verifyAccountSchema = z.object({
  bankCode: z.string().min(3),
  accountNumber: z.string().min(10).max(10)
});

router.post('/verify-account', validate(verifyAccountSchema), async (req, res) => {
  try {
    const { bankCode, accountNumber } = req.validatedBody;
    const result = await payaza.verifyAccountName({ bankCode, accountNumber });
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: 'Account verification failed' });
  }
});

What does this route do?

The Zod schema enforces that bankCode is at least 3 characters and accountNumber is exactly 10 digits (Nigerian NUBAN format). If validation passes, the route calls your Payaza service and returns the resolved account name.

  • Save src/routes/payments.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.js
  • Open a second terminal and test the verify-account endpoint by running:
curl -X POST http://localhost:3000/api/payments/verify-account \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-jwt-token-here" \
  -d '{"bankCode": "058", "accountNumber": "0123456789"}'

Replace your-jwt-token-here with the JWT token you received from the login endpoint in an earlier step.

You should see a JSON response from Payaza containing account name details. In test mode, the resolved name may be a test placeholder.

Where does the bank code come from in production?

You are using 058 (GTBank) as a hardcoded test bank code. In a production API, you would never hardcode this. Instead, you would call Payaza's bank list retrieval endpoint to fetch all supported banks dynamically and let the client select from that list.

This is a standard industry pattern: payment providers expose a GET /banks endpoint that returns codes, names, and sometimes logos. Your frontend populates a dropdown from this data, and the user's selection flows through to the account enquiry and transfer calls. This eliminates hardcoded values and keeps your integration current as banks are added or codes change.

🙋‍♀️ Seeing a 401 or 500 error?

  • A 401 means your JWT token has expired. Log in again at POST /api/auth/login to get a fresh token.
  • A 500 with "Account verification failed" usually means your PAYAZA_PUBLIC_KEY in .env is incorrect. Double-check it matches the test key from your Payaza dashboard.
  • help me debug my verify-account endpoint error
Add transfer initiation endpoint

With account verification in place, you can now build the endpoint that actually moves money. The transfer endpoint will call Payaza's payout API with the recipient's verified details.

  • In src/services/payaza.js, add the initiateTransfer function above the module.exports line:
async function initiateTransfer({ amount, accountNumber, accountName, bankCode, narration, transactionReference }) {
  const response = await fetch(`${BASE_URL}/payout-receptor/payout`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      transaction_type: 'nuban',
      service_payload: {
        payout_amount: amount,
        transaction_pin: parseInt(process.env.PAYAZA_TRANSACTION_PIN),
        account_reference: process.env.PAYAZA_ACCOUNT_REFERENCE,
        currency: 'NGN',
        country: 'NGA',
        payout_beneficiaries: [
          {
            credit_amount: amount,
            account_number: accountNumber,
            account_name: accountName,
            bank_code: bankCode,
            narration: narration,
            transaction_reference: transactionReference,
            sender: {
              sender_name: 'Fintech App',
              sender_id: '',
              sender_phone_number: '08000000000',
              sender_address: 'Lagos, Nigeria'
            }
          }
        ]
      }
    })
  });

  return response.json();
}

What does this function do?

This function builds the full payout payload that Payaza expects. The transaction_type is set to nuban for Nigerian bank transfers. It pulls your transaction PIN and account reference from environment variables so credentials stay out of the code.

The payout_beneficiaries array contains the recipient details. Each beneficiary includes the amount, verified account info, a narration (transfer description), and sender metadata.

  • Update the module.exports at the bottom of src/services/payaza.js. Find this line:
module.exports = {
  createVirtualAccount,
  verifyAccountName
};
  • Replace it with:
module.exports = {
  createVirtualAccount,
  verifyAccountName,
  initiateTransfer
};
  • Save src/services/payaza.js.

Now add the route that validates the transfer request and calls this service function.

  • In src/routes/payments.js, add the transfer schema below the verify-account route's closing });:
const transferSchema = z.object({
  amount: z.number().positive(),
  accountNumber: z.string().min(10).max(10),
  accountName: z.string().min(2),
  bankCode: z.string().min(3),
  narration: z.string().min(1).max(100)
});
  • Directly below the schema, add the transfer route handler:
router.post('/transfer', validate(transferSchema), async (req, res) => {
  try {
    const { amount, accountNumber, accountName, bankCode, narration } = req.validatedBody;
    const transactionReference = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;

    const payazaResponse = await payaza.initiateTransfer({
      amount,
      accountNumber,
      accountName,
      bankCode,
      narration,
      transactionReference
    });

    const transaction = new Transaction({
      transactionReference,
      type: 'PAYOUT',
      status: 'PENDING',
      amount,
      currency: 'NGN',
      metadata: payazaResponse,
      userId: req.user.userId
    });
    await transaction.save();

    res.status(201).json({
      transactionReference,
      payazaResponse,
      status: 'PENDING'
    });
  } catch (err) {
    res.status(500).json({ error: 'Transfer initiation failed' });
  }
});

What does this route do?

The schema enforces strict validation: the account number must be exactly 10 digits, narration is capped at 100 characters, and the amount must be positive. This catches malformed requests before they reach Payaza.

The handler generates a unique reference prefixed with PAY-, calls Payaza, saves the transaction as PENDING in MongoDB, and returns the reference so the client can track it.

  • Save src/routes/payments.js.
  • Restart your server (Ctrl+C, then node src/server.js).
  • In your second terminal, test the transfer endpoint by running:
curl -X POST http://localhost:3000/api/payments/transfer \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-jwt-token-here" \
  -d '{"amount": 100, "accountNumber": "0123456789", "accountName": "John Doe", "bankCode": "058", "narration": "Test payout"}'

You should see a JSON response containing a transactionReference starting with PAY- and Payaza's response with a status like TRANSACTION_INITIATED.

Getting a 500 error on the transfer?

  • Check that PAYAZA_TRANSACTION_PIN and PAYAZA_ACCOUNT_REFERENCE are set in your .env file. The transfer function reads both from environment variables.
  • If Payaza returns an authorization error, confirm your test public key is correct and that you are using test mode keys (not live).
  • help me debug my Payaza transfer endpoint
Add transfer status checking

Bank transfers are asynchronous. After you initiate a payout, Payaza queues it for processing. You need an endpoint to poll the current status so your frontend or client can display whether the transfer succeeded.

  • In src/services/payaza.js, add the checkTransferStatus function above the module.exports line:
async function checkTransferStatus(transactionReference) {
  const response = await fetch(`${BASE_URL}/payaza-account/api/v1/mainaccounts/transaction/status?transaction_reference=${transactionReference}`, {
    method: 'GET',
    headers: getAuthHeaders()
  });

  return response.json();
}

What does this function do?

It queries Payaza's transaction status endpoint with the reference you generated during the transfer. Payaza returns statuses like TRANSACTION_INITIATED, NIP_SUCCESS, NIP_PENDING, or NIP_FAILURE.

  • Update the module.exports one final time. Find:
module.exports = {
  createVirtualAccount,
  verifyAccountName,
  initiateTransfer
};
  • Replace it with the final version:
module.exports = {
  createVirtualAccount,
  verifyAccountName,
  initiateTransfer,
  checkTransferStatus
};
  • Save src/services/payaza.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/services/payaza.js with all four exported functions.

ⓧ I'd like to double check the full code

Your src/services/payaza.js should contain these sections in order:

  • The BASE_URL constant and getAuthHeaders() helper function at the top.
  • The createVirtualAccount function (from Step 3).
  • The verifyAccountName function you added in this step.
  • The initiateTransfer function you added in this step.
  • The checkTransferStatus function you just added.
  • The module.exports exporting all four functions.

Now add the status route to your payments file.

  • In src/routes/payments.js, add the following route below the transfer route's closing });:
router.get('/transfer/:reference/status', async (req, res) => {
  try {
    const { reference } = req.params;
    const result = await payaza.checkTransferStatus(reference);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: 'Status check failed' });
  }
});

What does this route do?

It reads the transaction reference from the URL parameter and queries Payaza for the current status. This lets clients poll for updates after initiating a transfer.

  • Save src/routes/payments.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/routes/payments.js with all the new routes.

ⓧ I'd like to double check the full code

Your src/routes/payments.js should contain these sections in order:

  • Imports (express, zod, authenticate, validate, Transaction, payaza) and router setup with router.use(authenticate).
  • The /health GET route.
  • The virtualAccountSchema and POST /virtual-account route (from Step 3).
  • The verifyAccountSchema and POST /verify-account route.
  • The transferSchema and POST /transfer route.
  • The GET /transfer/:reference/status route.
  • The module.exports = router at the bottom.
  • Restart your server (Ctrl+C, then node src/server.js).

Before you test, think about this: you just sent a transfer to Payaza. What status do you expect back when you check it immediately after?

  • Test the status endpoint using the transactionReference from your earlier transfer test. Run:
curl http://localhost:3000/api/payments/transfer/your-transaction-reference-here/status \
  -H "Authorization: Bearer your-jwt-token-here"

Replace your-transaction-reference-here with the PAY-... reference you received from the transfer response. You should see Payaza return the transaction's current status.

Why this approach?

Payaza processes transfers asynchronously. You could wait for a webhook to update the status (like you did for collections), but the status endpoint gives your client a way to actively check progress without waiting. Both patterns are useful in production.

🙋‍♀️ Getting an empty or error response?

  • Make sure you are using the exact transactionReference value from the transfer response, not a made-up reference.
  • If you get a 401, your JWT token may have expired. Log in again to get a fresh one.
  • help me debug my transfer status endpoint
Add transaction history with pagination

Production APIs never return all records at once. A fintech app might have thousands of transactions per user. You need cursor-based pagination so clients can fetch results in manageable pages without overwhelming the database or the network.

  • In src/routes/payments.js, add the transaction history route below the /transfer/:reference/status route's closing });:
router.get('/transactions', async (req, res) => {
  try {
    const { type, status, limit = 20, cursor } = req.query;

    // Build filter from query params
    const filter = { userId: req.user.userId };
    if (type) filter.type = type.toUpperCase();
    if (status) filter.status = status.toUpperCase();

    // Cursor-based pagination: fetch records older than cursor
    if (cursor) {
      filter._id = { $lt: cursor };
    }

    const pageSize = Math.min(parseInt(limit) || 20, 100);

    const transactions = await Transaction.find(filter)
      .sort({ _id: -1 })
      .limit(pageSize + 1);

    // Determine if there are more pages
    const hasMore = transactions.length > pageSize;
    const results = hasMore ? transactions.slice(0, pageSize) : transactions;
    const nextCursor = hasMore ? results[results.length - 1]._id : null;

    res.json({
      transactions: results,
      pagination: { hasMore, nextCursor, pageSize }
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch transactions' });
  }
});

What does this route do?

It fetches transactions belonging to the authenticated user, optionally filtered by type (COLLECTION or PAYOUT) and status (PENDING, SUCCESS, FAILED). Results are sorted newest-first.

Cursor-based pagination works by fetching one extra record. If that extra record exists, there are more pages. The client passes the last _id as the cursor query param on the next request. This is more efficient than offset-based pagination for large datasets because it uses the index directly.

  • Save src/routes/payments.js and restart your server:
node src/server.js
  • Test the transaction history endpoint by running:
curl http://localhost:3000/api/payments/transactions?limit=5 \
  -H "Authorization: Bearer your-jwt-token-here"

You should see a JSON response with a transactions array (containing the transactions you created in previous steps) and a pagination object with hasMore, nextCursor, and pageSize.

  • Try filtering by type to see only collections:
curl "http://localhost:3000/api/payments/transactions?type=collection&limit=5" \
  -H "Authorization: Bearer your-jwt-token-here"

Only transactions with type: "COLLECTION" appear in the results.

Why cursor pagination instead of offset?

Offset pagination (?page=5&limit=20) requires the database to skip records, which gets slower as the offset grows. Cursor pagination uses an indexed field (_id) to jump directly to the right position. For financial data that grows continuously, cursor pagination stays fast regardless of table size.

Add retry with exponential backoff

Network calls fail. Payaza might return a 503 during a deploy, or a DNS hiccup could drop your connection. Naive retries that fire immediately can overwhelm a recovering service. Exponential backoff spaces out retries with increasing delays, giving the external service time to recover.

  • Create a new file at src/utils/retry.js and add the retry utility:
// Retry a function with exponential backoff and jitter
async function retryWithBackoff(fn, options = {}) {
  const { maxRetries = 3, baseDelay = 1000 } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries) throw err;

      // Exponential backoff: 1s, 2s, 4s + random jitter
      const delay = baseDelay * Math.pow(2, attempt);
      const jitter = Math.random() * 500;
      const waitTime = delay + jitter;

      console.log(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(waitTime)}ms`);
      await new Promise((resolve) => setTimeout(resolve, waitTime));
    }
  }
}

module.exports = retryWithBackoff;

What does this code do?

It takes any async function and retries it up to 3 times if it throws an error. Each retry waits exponentially longer: roughly 1 second, then 2 seconds, then 4 seconds.

The random jitter (0-500ms added to each delay) prevents the thundering herd problem. If 100 servers all retry at the exact same moment, they slam the recovering service again. Jitter spreads retries across time.

  • Save src/utils/retry.js.
  • Now use the retry utility in the transfer status check. Open src/services/payaza.js and add this require at the top of the file:
const retryWithBackoff = require('../utils/retry');
  • Find the checkTransferStatus function and wrap the fetch call with the retry utility:
async function checkTransferStatus(transactionReference) {
  return retryWithBackoff(async () => {
    const response = await fetch(`${BASE_URL}/payaza-account/api/v1/mainaccounts/transaction/status?transaction_reference=${transactionReference}`, {
      method: 'GET',
      headers: getAuthHeaders()
    });

    if (!response.ok && response.status >= 500) {
      throw new Error(`Payaza returned ${response.status}`);
    }

    return response.json();
  }, { maxRetries: 3, baseDelay: 1000 });
}

Why only retry on 5xx errors?

A 4xx error (bad request, unauthorized) means your request is wrong. Retrying a bad request produces the same error. A 5xx error means the server had a temporary problem. Retrying after a delay often succeeds because the server has recovered.

This is a critical distinction in payment systems. Retrying a failed transfer initiation could send money twice. But retrying a status check is always safe because it only reads data.

  • Save src/services/payaza.js and restart your server:
node src/server.js
  • Test the status endpoint with a transaction reference from your earlier transfer test:
curl http://localhost:3000/api/payments/transfer/your-transaction-reference-here/status \
  -H "Authorization: Bearer your-jwt-token-here"

The response works as before. The retry logic is invisible on success. It only activates when Payaza returns a 5xx error, in which case you will see the retry log messages in your server terminal.

When is retry dangerous?

Never retry operations that move money (transfer initiation, card charges). A timeout does not mean the operation failed. It might have succeeded but the response was lost. This is exactly why you built idempotency in the Secret Mission. Status checks and account verifications are safe to retry because they are read-only operations.

Your API now handles both sides of the money flow. Payments come in through virtual accounts and go out through verified bank transfers. Next up, you will tackle the Secret Mission: making your transfer endpoint idempotent so duplicate requests never send money twice.

Secret mission

Add Idempotency Enforcement

In fintech, network failures and retries are common. If a client sends the same transfer request twice, you must not send the money twice. Can you add idempotency enforcement so duplicate payment requests are safely rejected?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses a MongoDB Atlas free tier cluster (no ongoing costs) and Payaza test mode (no real money moves), so there is no financial risk in leaving things as they are.

Resources you used:

  • Local project folder (payaza-fintech-api on your Desktop).
  • MongoDB Atlas free tier cluster (payaza-fintech database).
  • Node.js dependencies installed locally (node_modules folder inside your project).
  • Payaza business account (test mode API keys).

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it soon.

  • Your Express server stays available whenever you run node src/server.js from your project folder.
  • Your MongoDB Atlas free cluster never expires and auto-pauses after 30 days of inactivity. No billing applies.
  • Your Payaza test keys remain valid for future API calls in test mode.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Your files and cloud resources stay intact for when you return.

  • If your server is still running in the terminal, press Ctrl+C to stop it.
  • Your MongoDB Atlas free cluster auto-pauses after 30 days of zero connections. No manual action needed.
  • Your local project folder and .env file remain on your Desktop for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Stop the server if it is running by pressing Ctrl+C in the terminal.
  • Delete your local project folder by running the command below for your operating system.

macOS

cd ~/Desktop
rm -rf payaza-fintech-api

Windows

cd ~\Desktop
rmdir /s /q payaza-fintech-api
  • Delete your MongoDB Atlas cluster:
  • Go to https://cloud.mongodb.com and sign in.
  • Select your project, then click Database in the left sidebar.
  • Click the three-dot menu next to your cluster and select Terminate.
  • Type the cluster name to confirm, then click Terminate.
  • Revoke your Payaza test API keys:
  • Go to https://business.payaza.africa and sign in.
  • Navigate to Settings, then Developers, then API Keys.
  • Delete or regenerate your test keys so the old values no longer authenticate.

Nice Work!

Nice Work!

You just built a production-grade fintech payment processing API that handles both sides of the money flow. Your API collects payments through virtual accounts, verifies webhook signatures cryptographically, and initiates bank transfers with full audit trails.

You've learned how to:

  • Build a Node.js/Express fintech API that creates dynamic virtual accounts for payment collection, verifies recipient bank accounts, and initiates payouts through Payaza's transfer API.
  • Secure payment endpoints with JWT authentication and validate every financial request using Zod schemas that reject malformed data before it reaches the payment gateway.
  • Implement HMAC SHA-512 webhook signature verification that rejects forged payment notifications, and track all transactions through a state machine (PENDING to SUCCESS/FAILED) in MongoDB.
  • Secret Mission: Added idempotency enforcement that safely rejects duplicate payment operations using MongoDB's unique index and the X-Idempotent-Replay header to distinguish cached results from new transactions.

Ready to quiz yourself?