Verify Paystack Payments in Node.js and Express
To verify a Paystack payment in Express, make a GET request to https://api.paystack.co/transaction/verify/:reference with your secret key in the Authorization header. Check that data.data.status is "success", that the amount matches what you stored, and that the currency is correct before granting value.
Why Server-Side Verification Matters
When a customer pays through Paystack, you get notified in two ways: the redirect callback (the customer's browser comes back to your site) and the webhook (Paystack calls your server directly). Neither should be trusted blindly.
The redirect is just a URL with a reference parameter. Anyone could type it in. The webhook is more reliable, but you still need to confirm the amount and currency match what you expected. Verification is the step where you call the Paystack API yourself and get the truth directly from the source.
Without verification, a customer could initialize a transaction for 100 NGN but your system might grant them a product worth 10,000 NGN. Verification closes that gap.
Basic Verification Route
Create a route that takes a reference and calls the Paystack verify endpoint:
var PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
app.get('/api/verify', async function(req, res) {
var reference = req.query.reference;
if (!reference) {
return res.status(400).json({ error: 'Missing reference' });
}
var paystackRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + PAYSTACK_SECRET,
},
}
);
var data = await paystackRes.json();
if (!data.status) {
return res.status(400).json({ error: 'Verification request failed' });
}
if (data.data.status !== 'success') {
return res.status(400).json({
error: 'Payment not successful',
paystack_status: data.data.status,
});
}
var amountInKobo = data.data.amount;
var currency = data.data.currency;
var customerEmail = data.data.customer.email;
res.json({
success: true,
reference: data.data.reference,
amount: amountInKobo / 100,
currency: currency,
email: customerEmail,
});
});
This is the minimum verification. It confirms the payment happened, but it does not check whether the amount matches your expected price. The next section adds that critical check.
Amount and Currency Validation
The most common verification mistake is skipping the amount check. Here is the pattern with full validation:
app.get('/api/verify/:reference', async function(req, res) {
var reference = req.params.reference;
// 1. Look up the order in your database
var order = await db.orders.findOne({ reference: reference });
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
if (order.paid) {
return res.json({ success: true, message: 'Already fulfilled' });
}
// 2. Call Paystack verify
var paystackRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{ headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET } }
);
var data = await paystackRes.json();
// 3. Check status
if (!data.status || data.data.status !== 'success') {
return res.status(400).json({ error: 'Payment not successful' });
}
// 4. Check amount (stored in kobo)
if (data.data.amount !== order.amountInKobo) {
console.error(
'Amount mismatch: expected ' + order.amountInKobo +
' got ' + data.data.amount
);
return res.status(400).json({ error: 'Amount mismatch' });
}
// 5. Check currency
if (data.data.currency !== order.currency) {
return res.status(400).json({ error: 'Currency mismatch' });
}
// 6. Fulfill the order (idempotently)
await db.orders.updateOne(
{ reference: reference, paid: false },
{ $set: { paid: true, paidAt: new Date() } }
);
res.json({ success: true });
});
The { reference: reference, paid: false } filter in the update query ensures that if two requests arrive simultaneously (webhook and callback), only one of them sets paid: true. The other finds no matching document and does nothing.
Handling the Callback-Webhook Race
Both the redirect callback and the webhook can fire for the same successful transaction. They often arrive within milliseconds of each other. Your fulfillment logic must handle this gracefully.
The pattern:
- Both the callback route and the webhook handler call the same
fulfillOrderfunction. fulfillOrderuses an atomic database update with a condition: only update ifpaidis stillfalse.- If the update affected zero rows, the order was already fulfilled. Return success without doing anything else.
async function fulfillOrder(reference) {
// Atomic update: only one caller wins
var result = await db.orders.updateOne(
{ reference: reference, paid: false },
{ $set: { paid: true, paidAt: new Date() } }
);
if (result.modifiedCount === 0) {
// Already fulfilled by the other handler
return { alreadyFulfilled: true };
}
// This is the winning handler. Deliver value.
var order = await db.orders.findOne({ reference: reference });
await sendConfirmationEmail(order.email, order);
// Unlock content, ship product, etc.
return { alreadyFulfilled: false };
}
Call this function from both your callback route and your webhook handler. The first one to arrive wins. The second one is a no-op.
Retry Logic for Network Failures
If the fetch to Paystack's verify endpoint fails (network timeout, DNS issue, Paystack downtime), the transaction is still valid on Paystack's side. You can safely retry.
async function verifyWithRetry(reference, maxRetries) {
var retries = maxRetries || 3;
for (var attempt = 0; attempt < retries; attempt++) {
try {
var paystackRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{ headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET } }
);
if (paystackRes.ok) {
return await paystackRes.json();
}
} catch (err) {
console.error('Verify attempt ' + (attempt + 1) + ' failed:', err.message);
}
// Exponential backoff: 1s, 2s, 4s
if (attempt < retries - 1) {
await new Promise(function(resolve) {
setTimeout(resolve, Math.pow(2, attempt) * 1000);
});
}
}
return null;
}
If all retries fail, log the reference and process it manually or queue it for background retry. The webhook will independently deliver the event, so the order can still be fulfilled through that path.
Common Verification Mistakes
- Trusting the frontend amount. Never compare the Paystack amount against what the frontend sent. Compare it against what you stored in your database when you created the order.
- Skipping currency checks. A customer could pay in a different currency than expected. If you expect NGN 5000 and they pay USD 50, the amounts might look similar but the value is very different.
- Not URL-encoding the reference. If your reference contains special characters, the verify URL will break. Always use
encodeURIComponent. - Mixing test and live keys. If you initialize with
sk_test_but verify withsk_live_, Paystack returns "Transaction not found." Both must be in the same mode. - Granting value before checking the database. Always check if the order exists and has not already been fulfilled before delivering value.
Key Takeaways
- ✓Verification is a single GET request to the Paystack API. No SDK is needed. Use the built-in fetch in Node 18+.
- ✓Always check three things: transaction status equals "success", amount matches what you stored, and currency is correct.
- ✓The redirect callback is not proof of payment. Someone could type the callback URL manually with a fake reference.
- ✓Both the webhook and the redirect callback can arrive for the same transaction. Use a database flag to prevent double-granting.
- ✓Wrap your fulfillment logic in a database transaction or use an atomic update to avoid race conditions.
- ✓If verification fails due to a network error, retry with exponential backoff. The transaction state on Paystack does not change.
Frequently Asked Questions
- Can I verify a transaction multiple times?
- Yes. The verify endpoint is idempotent. It returns the same result every time for the same reference. Calling it multiple times does not change anything on Paystack side.
- What happens if I verify a failed transaction?
- Paystack returns the transaction data with a status of "failed" or "abandoned." Your code should check data.data.status and only fulfill when it equals "success."
- How long can I verify a transaction after payment?
- Paystack stores transaction data indefinitely. You can verify a transaction months or years after it was created. There is no expiration on the verify endpoint.
- Should I verify in the webhook handler too?
- Yes. Even though the webhook payload contains transaction data, you should call the verify endpoint to be certain. The webhook confirms the event happened, but verification gives you the authoritative transaction state directly from Paystack.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon