Integrating the Paystack API to accept payments on your website seems pretty straightforward on the surface. Call the initialize API endpoint when the customer clicks the pay button, redirect them to the checkout page to complete payment, and verify it before you deliver value.
However, the actual integration is anything but straightforward. You need to consider several factors and handle edge cases throughout the payment flow.
Paystack offers two ways to verify payments: listen for webhook events and call the Verify API. Each option has benefits and limitations.
With the webhook method, Paystack automatically calls your server when a customer completes a successful payment. This is the recommended option since you don’t have to call Paystack to get the transaction's final status. It simply pings your server, and you use the returned data to deliver value to your customer.
However, for transactions, Paystack only fires the charge.success event, i.e., it only calls your server when payment succeeds, which raises questions.
- What happens when a customer clicks the pay button and never completes the payment? Should it remain pending forever in your database?
- In cases where payment fails, how do you update your server?
- What if the webhook event doesn’t fire immediately even when a payment is successful? How do you verify it?
These are some of the limitations of the webhook option and where the verify API endpoint comes in.
Paystack’s verify API lets you call Paystack to get the current or final status of a transaction. Transactions can have the following statuses: abandoned, failed, ongoing, pending, queued, processing, reversed, and success, making it a more comprehensive option than the webhook.
However, the limitation is that you have to constantly call Paystack to get a transaction status, unlike webhooks that call your server instead.
So what do you do? You use both.
In this series, I will show you how to handle the full payment flow from initiation to verification.
What You Will Learn:
Series 1: How to Accept Payments and Create a Record for Every Transaction in Your Database
Series 2: How to Verify Payments with Webhooks and Handle Idempotency
Series 3: How to Handle Pending, Abandoned, and Expired Transactions Using the Paystack Verify API
Series 4: How to Handle Webhook Delays Using the Paystack Verify API as an Alternative
Requirements:
Before you proceed, ensure you have:
- A code editor such as VS Code
- Node 18+ installed on your local machine
- A Paystack business account and an active secret key
- A basic understanding of Express.js, MongoDB, and how to test APIs in Postman.
Set Up Your Express.js Project
Note: You can skip this section if you already know how to set up an Express.js project.
Step 1: Create a new folder in VS Code or your preferred IDE.
Step 2: Open your terminal and run npm init -y. This automatically creates a package.json file in your folder.
Step 3: Run npm install express nodemon to install the Express.js framework and nodemon package in your project.
Step 4: Open your package.json file and replace the “type” and "scripts" fields with this:
"type": "module" - This tells Node.js to treat all files as ES Modules.
"scripts": { "start": "nodemon index.js" } - This automatically restarts your app when you make changes to a .js file.
Step 5: Create an index.js file in your project folder and paste this code.
import express from 'express'
const app = express();
app.use(express.json())
const PORT = 5000
const start = ()=>{
app.listen(PORT,()=>{
console.log('Server is running...');
})
}
start();
This imports the Express package and starts your application on port 5000.
Step 6: Run npm start in your terminal.
You should see a “Server is running…” message in your terminal.
Initiate Payments With the Paystack Initialize Transaction API
Step 1: Install axios and dotenv to send requests to Paystack APIs and hide your secret keys.
npm install axios dotenv
Step 2: Create a .env file and paste your Paystack API secret key.
You can get this secret key from your Paystack dashboard.
PAYSTACK_SECRET = 'YOUR_SECRET-KEY'
Step 3: Create a controller folder and an initiate payment file.
“controller” > “initiate-payment.js”
Step 4: Create a function in your initiate-payment.js file to initiate payments.
import axios from "axios"
import 'dotenv/config'
import crypto from 'crypto'
const initiatePayment = async(req, res)=>{
const {email, name, amount} = req.body
if(!email || !name|| !Number.isFinite(amount)||amount<=0){
return res.status(400).json({
success: false,
data:{
msg:"Invalid email, name, or amount"
}
})
}
const reference = `ref-${crypto.randomUUID()}`
try {
const payload = {
"email": email,
"amount": Math.round(amount * 100), // amount in Kobo,
"reference": reference
}
const secretKey = process.env.PAYSTACK_SECRET
const url = 'https://api.paystack.co/transaction/initialize'
const config = {
headers: {
"Authorization": `Bearer ${secretKey}`,
"Content-Type": "application/json"
}
}
const response = await axios.post(url, payload, config)
return res.status(200).json({
success: true,
data:{
auth_url: response.data.data.authorization_url,
reference: reference
}
})
} catch (error) {
return res.status(500).json({
success: false,
data:{
msg: error.response?.data?.message|| error.message|| "Could not initiate a payment"
}
})
}
}
export {
initiatePayment
}
In the initiatePayment function:
- Email, name, and amount are retrieved from the request body.
-
The first
ifblock checks whether the email, name, and amount are valid fields; otherwise, it returns an error. -
Reference: Generates a random string using
crypto.randomUUID - Payload: Holds all the required body parameters, including email, amount, and reference.
- SecretKey: Retrieves your API credentials from dotenv.
- URL: Holds the Paystack initialize transaction API endpoint.
-
Config: Configures the required headers to send a request to the Paystack API, including the
Authorizationheader andContent-Type. - Response: Sends a request to the Paystack API.
Then, the return field retrieves the authorization URL from the Paystack initialize API response using response.data.data.authorization_url and the reference. If the request fails, it retrieves the error message using error.response?.data?.message, error.message, or sends a “Could not initiate a payment” error message in the catch block.
Step 5: Create a route folder and an initiate-payment-route file.
“route” > “initiate-payment-route.js”
Step 6: Set up your initiate payment route.
import express from 'express'
import { initiatePayment } from '../controller/initiate-payment.js';
const initiatePaymentRoute = express.Router();
initiatePaymentRoute.post('/initiate/payment', initiatePayment);
export {
initiatePaymentRoute
}
This code imports the initiatePayment function from your controller file and uses express.Router() to create a mountable route handler. Then, it creates a POST URL using initiatePaymentRoute.post('/initiate/payment', initiatePayment);
Step 7: Import the initiatePaymentRoute at the top of your index.js file and set the route.
import express from 'express'
import { initiatePaymentRoute } from './route/initiate-payment-route.js';
const app = express();
app.use(express.json())
app.use('/api/v1',initiatePaymentRoute) // set the route.
const PORT = 5000
const start = ()=>{
app.listen(PORT,()=>{
console.log('Server is running...');
})
}
start();
On line 5, it adds api/v1 to your initiate payment route, so the URL becomes api/v1/initiate/payment.
The next step is to test this route in Postman by initiating a transaction.
Test the Initiate Payment Route in Postman
Step 1: Open Postman on your device and log in to your account.
Step 2: Create a blank collection and add a new request.
Step 3: Paste http://localhost:5000/api/v1/initiate/payment or your exact initiate payment route into the URL field and set the method to POST.
Step 4: Click the “Body” tab, select “raw,” then choose “JSON” from the dropdown list.
Step 5: Paste the code below to add email, amount, and name as your body parameters.
Note: The “amount” field must be a valid number.
{
"email": "customer@example.com",
"name":"customer",
"amount": 100
}
Step 6: Click the “Send” button to send your request.
Ensure you start your application by entering
npm startin your terminal before sending the request.
If the request is successful, you should receive the auth_url and reference. This is the URL you will direct users to complete their payments. If the request fails, you’ll receive the exact error message so you can easily fix it.
Store Payment Initiation Details in Your Database
After initiating a payment, redirect customers to the auth_url, which is the checkout page where they can complete the payment. However, you need to store and track payment details from initiation to verification in the database.
Here is what the flow looks like:
→ When a customer initiates a payment, create a transaction record and set its status to "pending."
→ If the customer completes the payment successfully, the transaction status is set to “success.”
→ If the customer is unable to complete the payment due to an error, the transaction status is set to “failed.”
→ If the customer abandons the payment for a while, the transaction status is set to “abandoned.”
How to Implement the Transaction Flow
To implement the transaction flow, create a record in your database and set its status to pending right before you call the Paystack API. This ensures you create a record in your database before initiating a payment.
If Paystack rejects the initiation request, update its status to “failed.” Otherwise, redirect the customer to the checkout page to complete the payment. Then update the transaction status later based on whether the customer completes the payment successfully, encounters an error, or abandons it.
Notes:
- This tutorial uses MongoDB; however, you can use any other database of your choice. The logic stays the same.
- Ensure you have an active MongoDB account and have created at least one cluster.
Step 1: Set up your database.
i: Install Mongoose in your application.
npm install mongoose
ii: Install the validator package. This is to ensure users only enter a valid email address.
npm install validator
iii: Create a “model” folder and a “payment-initiation.js” file inside it. This is where you write your database schema and model.
Note: The database schema represents the shape of the transaction data you want to store (e.g., amount, transaction status, reference, etc.), while the model represents the class built from the schema, and it provides an interface that lets you interact with the database.
iv: Set up your database schema and model.
import mongoose from 'mongoose'
import validator from 'validator'
const paymentInitiationSchema = mongoose.Schema({
"name":{
type : String,
required: [true, "name is a required field"]
},
"email":{
type: String,
required: [true, "Email is a required field"],
validate:{
validator: function(value){
return validator.isEmail(value)
},
message: (props)=>`${props.value} is not a valid email address.`
}
},
"amount":{
type: Number,
required: [true, "Amount is a required field"]
},
"reference":{
type: String,
unique: true,
required: [true, "Reference is a required field"]
},
"status":{
type: String,
required : [true, "Status is a required field"]
},
"createdAt":{
type: Date,
default: Date.now
},
"updatedAt":{
type: Date
}
})
const paymentInitiationModel = mongoose.model('PaymentInitiation', paymentInitiationSchema)
export {
paymentInitiationModel
}
This code creates your database structure, including the name, email, amount, reference, status, createdAt, and updatedAt fields.
v: Create a new “database” folder and a “connectString.js” file to create a function that connects your application to the MongoDB database.
import mongoose from "mongoose";
export const connectString = async (uri)=>{
await mongoose.connect(uri)
}
vi: Get your connection string in MongoDB.
→ Log in to your MongoDB account, click the “Connect” button in the Cluster section, select “Drivers” from the list, and copy your connection string.

vii: Paste your connection string in your .env file.
Note: Replace the placeholders with your valid database username, password, and project name.
MONGOURI = mongodb+srv://database-username:databasePassword@project-name.clustername.mongodb.net/PaystackGuide?appName=NodeExpressTutorial
viii: In your index.js file, call the connection string function you created in step v and pass your MongoDB connection string from your .env file as a parameter.
import express from 'express'
import 'dotenv/config' // loads your MongoDB string
import { initiatePaymentRoute } from './route/initiate-payment-route.js';
import { connectString } from './database/connectString.js';
const app = express();
app.use(express.json())
app.use('/api/v1',initiatePaymentRoute)
const PORT = process.env.PORT || 5000
const start = async ()=>{
await connectString(process.env.MONGOURI) // Your connection string function
app.listen(PORT,()=>{
console.log('Server is running...');
})
}
start();
Step 2: Create a pending transaction service function.
This is where you handle the pending transaction logic.
i. Create a “service” folder and a “pending-transaction.js” file.
ii. Create a pendingTransaction function that creates a transaction record immediately after a customer initiates a payment.
import { paymentInitiationModel } from "../model/payment-initiation.js"
const pendingTransaction = async (name, email, amount, reference, status, createdAt, updatedAt)=>{
try {
const createTransaction = await paymentInitiationModel.create({
"name": name,
"email": email,
"amount": amount,
"reference": reference,
"status": status,
"createdAt": createdAt,
})
console.log('New record created', createTransaction);
return createTransaction
} catch (error) {
console.log(error);
throw new Error(error.message)
}
}
export {
pendingTransaction
}
This code imports the transaction model and uses the Mongoose model.create() function to create a new document with the following details: name, email, amount, reference, status, createdAt, and updatedAt.
Step 3: Call the pending transaction service function immediately when a customer initiates a transaction.
i. Import your pendingTransaction service function and paymentInitiationModel at the top of your initiate-payment.js file.
ii. Create status and createdAt variables inside the initiatePayment function.
const status = "pending"
const createdAt = Date.now();
iii. Call the pendingTransaction function right before you call the Paystack API to create a transaction record. This ensures no payment is initiated before creating a record.
await pendingTransaction(name, email, amount, reference, status, createdAt)
iv. Replace the content of the catch block with this.
if(error.response){
try {
await paymentInitiationModel.findOneAndUpdate(
{ reference },
{ status: "failed", updatedAt: Date.now() }
)
} catch (dbError) {
console.log('Could not mark the transaction as failed', dbError.message);
}
}
return res.status(500).json({
success: false,
data:{
msg: error.response?.data?.message|| error.message|| "Could not initiate a payment"
}
})
This updates the database status to “failed” if Paystack returns a response error when a customer initiates a transaction. Itensures the record doesn’t stay pending forever.
Here is what the full initiate-payment.js file looks like.
import axios from "axios"
import 'dotenv/config'
import crypto from 'crypto'
import { pendingTransaction } from "../service/pending-transaction.js"
import { paymentInitiationModel } from "../model/payment-initiation.js"
const initiatePayment = async(req, res)=>{
const {email, name, amount} = req.body
if(!email || !name|| !Number.isFinite(amount)||amount<=0){
return res.status(400).json({
success: false,
data:{
msg:"Invalid email, name, or amount"
}
})
}
const status = "pending"
const reference = `ref-${crypto.randomUUID()}`
const createdAt = Date.now();
try {
await pendingTransaction(name, email, amount, reference, status, createdAt)
const payload = {
"email": email,
"amount": Math.round(amount * 100), // amount in Kobo
"reference": reference
}
const secretKey = process.env.PAYSTACK_SECRET
const url = 'https://api.paystack.co/transaction/initialize'
const config = {
headers: {
"Authorization": `Bearer ${secretKey}`,
"Content-Type": "application/json"
}
}
const response = await axios.post(url, payload, config)
return res.status(200).json({
success: true,
data:{
auth_url: response.data.data.authorization_url,
reference: reference
}
})
} catch (error) {
if(error.response){
try {
await paymentInitiationModel.findOneAndUpdate(
{ reference },
{ status: "failed", updatedAt: Date.now() }
)
} catch (dbError) {
console.log('Could not mark the transaction as failed', dbError.message);
}
}
return res.status(500).json({
success: false,
data:{
msg: error.response?.data?.message|| error.message|| "Could not initiate a payment"
}
})
}
}
export {
initiatePayment
}
The next step is to test the initiate payment route and confirm that a new record with transaction status set to “pending” is created immediately when a customer initiates a payment.
v. Test the initiate payment route in Postman.
If everything works as expected, the transaction record should be created automatically, and you should see the details in your terminal.
Troubleshooting Common Issues
1. I received an “Error: connect ECONNREFUSED 127.0.0.1:5000” error in Postman when trying to call the initiate payment route. How do I fix it?
Fix: Navigate to the index.js file to confirm your app is running on port 5000, and run npm start in your terminal to start your app before calling the initiate payment route in Postman.
2. I received an “invalid key” error even though my secret key is correct. What should I do?
Fix: Open your .env file and paste your secret key directly from the Paystack dashboard. Then restart your app to load the changes. Also confirm you did not paste the public key.
Next Step
Check the second series to verify payments with Paystack webhooks before delivering value.




Top comments (0)