How to integrate Paystack the right way in Nigeria
Server-side initialisation, webhook signatures, verification and idempotent fulfilment: the Paystack checklist that stops lost orders and double deliveries.
By Timothy Fadipe · · 7 min read
Paystack is easy to get working. It is much harder to get right. A checkout that works in a demo can still lose orders, deliver twice, or accept a payment that never happened.
Most of the payment bugs we're asked to fix come from the same handful of mistakes. Here's how to avoid them.
The golden rule: the browser is not proof of payment
When a customer completes checkout, Paystack redirects them to your callback_url with a reference. It's tempting to mark the order as paid right there.
Don't. The redirect only tells you the customer came back. They might have closed the tab before returning, refreshed, or crafted the URL by hand. Payment status must come from Paystack, server to server, via two mechanisms:
- Webhooks: Paystack calls your server when something happens (for example
charge.success). - Verification: your server calls
GET /transaction/verify/:referenceand checks the result.
Use the webhook as your primary trigger and verification as your confirmation and safety net.
1. Initialise transactions on the server
Create the transaction from your backend with POST /transaction/initialize, using your secret key, which never goes near the browser. Send:
amountin the lowest currency unit. For naira that's kobo, so ₦5,000 is500000. Getting this wrong is the most common Paystack bug we see.reference: generate your own unique reference and store it against the order before redirecting. This is what ties Paystack's records to yours.email, plus anymetadatayou'll want later (order ID, customer ID).
Then send the customer to the returned authorization_url, or use Paystack's inline popup with the access_code.
Calculating the amount server-side also matters: a price that comes from the browser can be edited.
2. Verify the webhook signature
Anyone can POST to your webhook URL. Paystack signs every event with an HMAC SHA-512 hash of the raw request body, using your secret key, and sends it in the x-paystack-signature header. Reject anything that doesn't match.
// app/api/paystack/webhook/route.ts
import crypto from "node:crypto"
export async function POST(req: Request) {
const body = await req.text() // the raw body, before any JSON parsing
const signature = req.headers.get("x-paystack-signature") ?? ""
const expected = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
.update(body)
.digest("hex")
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!valid) return new Response("Invalid signature", { status: 401 })
const event = JSON.parse(body)
if (event.event === "charge.success") {
await fulfilOrder(event.data.reference) // see step 3
}
return new Response(null, { status: 200 })
}
Two details that trip people up:
- Hash the raw body. If your framework parses JSON first and you re-serialise it, the hash won't match.
- Respond quickly with a 200. Paystack retries webhooks that don't get a 200 response. Do slow work (emails, inventory updates) in a queue or background job.
3. Make fulfilment idempotent
Because of retries, and because your verification and webhook paths may both fire, the same payment can reach your code more than once. Fulfilment must be safe to run twice.
The simplest pattern:
- Look up the order by
reference. - If it's already marked paid, stop and return success.
- Otherwise call
GET /transaction/verify/:referenceand check thatstatusis"success", theamountmatches what you expected, and thecurrencyis right. - Mark the order paid and fulfil it, ideally inside a database transaction, or with a unique constraint on the reference so two parallel requests can't both win.
Checking the amount is important. A payment of ₦50 against a ₦50,000 order is still a "successful" transaction.
4. Handle bank transfers and dedicated accounts
Many Nigerian customers would rather pay by bank transfer than by card. Paystack supports this through Pay with Transfer in checkout, and through dedicated virtual accounts for customers who pay you repeatedly (wallet top-ups, subscriptions, B2B invoices).
Transfers change your assumptions:
- Confirmation is asynchronous. The customer may have left your site by the time the money lands, so the webhook is what completes the order, not the redirect.
- Customers sometimes send the wrong amount. Decide in advance what happens with underpayments and overpayments, and make sure someone gets alerted.
5. Reconcile, don't just react
Webhooks can be delayed, and servers do go down. Run a scheduled job (hourly is usually plenty) that finds orders stuck in "pending" and verifies them against Paystack. It costs little to build and ends the "customer paid but nothing happened" support tickets.
Keep a log of every webhook you receive, including ones you rejected. When a customer disputes a payment, you'll want the evidence.
6. Separate test and live properly
Paystack gives you test and live keys. Keep them in environment variables, never in code, and make sure your staging environment can't accidentally use live keys. Before go-live, test the unhappy paths: a declined card, an abandoned checkout, a duplicated webhook, a transfer that never arrives.
A go-live checklist
- Transactions initialised server-side with amounts in kobo
- Your own unique reference stored before redirecting
- Webhook signature verified against the raw body
- Webhook responds 200 fast; slow work runs in the background
- Fulfilment is idempotent and checks amount and currency
- Transfer payments handled asynchronously, with wrong-amount alerts
- Scheduled reconciliation for pending orders
- Live secret key only in production environment variables
The same principles apply to Flutterwave and Monnify; only the endpoint names and signature headers change. Get them right once, and payments become the part of your product you never have to think about.
Need a payment integration reviewed or built? Talk to us.