Accepting Paystack Payments in Next.js Without Losing Money / Oct 27, 2026
AduraMart, a faith-goods marketplace I work on, takes payment through Paystack. A first test payment is the easy part. Making sure a platform never charges the wrong amount, never fulfils an order twice and never loses a payment is the real work.
Most Paystack tutorials stop at the first test payment. This post is about the rest. It uses the
Next.js App Router and plain fetch, so there is no SDK to keep up to date.
Rule 1: the server sets the amount
The most common mistake is letting the browser say how much to charge. If your checkout
button sends amount: 5000 to your API, anyone can change it to amount: 50 in the
browser's dev tools.
The browser should only say which order it is paying for. The server loads that order, reads the total from the database and starts the transaction itself:
// lib/paystack.js
const API = "https://api.paystack.co";
async function paystack(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
const body = await res.json();
if (!res.ok || !body.status) {
throw new Error(`Paystack ${path} failed: ${body.message}`);
}
return body.data;
}
export const initializeTransaction = (payload) =>
paystack("/transaction/initialize", { method: "POST", body: JSON.stringify(payload) });
export const verifyTransaction = (reference) =>
paystack(`/transaction/verify/${encodeURIComponent(reference)}`);
// app/api/checkout/route.js
import { initializeTransaction } from "@/lib/paystack";
import { getPendingOrderForUser } from "@/lib/orders";
import { nairaToKobo } from "@/lib/money";
export async function POST(request) {
const { orderId } = await request.json();
const order = await getPendingOrderForUser(orderId, request);
if (!order) return Response.json({ error: "Order not found" }, { status: 404 });
const { authorization_url } = await initializeTransaction({
email: order.email,
amount: String(nairaToKobo(order.total)),
currency: "NGN",
reference: order.paystackReference,
callback_url: `${process.env.APP_URL}/checkout/complete`,
metadata: { orderId: order.id },
});
return Response.json({ url: authorization_url });
}
The browser then sends the customer to url, which is Paystack's hosted checkout. When
they finish, Paystack sends them back to your callback_url with the reference in the
query string. If you would rather keep them on your page, Paystack's
InlineJS can open the same
transaction in a popup with resumeTransaction(accessCode), using the access_code that
initialize also returns. Either way, the amount was decided on the server.
Two details in that call:
- You choose the reference. Generate a unique reference when you create the order and save it on the order row. Every later step looks the order up by that reference.
- The secret key stays on the server. It never goes in a
NEXT_PUBLIC_variable.
Rule 2: keep money exact, and convert at the edge
Paystack takes amounts in kobo. The tempting fix is amount * 100 in JavaScript, and that
is where floating point gets in. 19.99 * 100 is 1998.9999999999998, not 1999.
On AduraMart every money column is NUMERIC(12,2) in naira, there are 22 of them, and kobo
only exists at the Paystack boundary. Postgres returns NUMERIC to Node as a string, so
the conversion can be done without a float ever touching the value:
// lib/money.js
// "12500.5" -> 1250050. Throws on anything that is not a plain naira amount.
export function nairaToKobo(naira) {
const [whole, frac = ""] = String(naira).split(".");
if (!/^\d+$/.test(whole) || !/^\d{0,2}$/.test(frac)) {
throw new Error(`Not a naira amount: ${naira}`);
}
return Number(whole) * 100 + Number(frac.padEnd(2, "0"));
}
NUMERIC(12,2) tops out below ten billion naira, so the result stays well inside the range
where JavaScript integers are exact. The same thinking applies to quantities. AduraMart
sells fabric by the yard, so quantity is a decimal too.
Rule 3: never trust the redirect
When the customer lands on /checkout/complete?reference=..., all you know is that a
browser visited a URL. Anyone can type that URL. So the callback page does not mark
anything as paid. It asks Paystack.
And when you ask, check that it is the payment you expected, not just "a successful payment":
- the status is
success, - the currency is
NGN, - the amount in kobo matches the order total,
- the reference belongs to an order that is still pending.
That last check matters as much as the first three, because of rule 4.
Rule 4: the same payment will arrive more than once
There are at least two ways you hear about a payment: the customer coming back through the redirect, and Paystack's webhook. They can arrive in either order. The customer might never come back at all, because people close tabs, and bank transfer or USSD payments can complete minutes later. Paystack will also retry a webhook it thinks failed.
So build one function that both paths call, and make it safe to call any number of times. The rule we hold AduraMart to is simple: a replayed webhook changes nothing and fires nothing twice.
// lib/payments.js
import { pool } from "@/lib/db";
import { verifyTransaction } from "@/lib/paystack";
import { queueReceipt } from "@/lib/receipts";
export async function markPaid(reference) {
// Ask Paystack what happened. Never take the caller's word for it.
const tx = await verifyTransaction(reference);
if (tx.status !== "success" || tx.currency !== "NGN") return { paid: false };
// Only one caller can move an order from pending to paid.
const { rows } = await pool.query(
`UPDATE orders
SET status = 'paid', paid_at = now()
WHERE paystack_reference = $1
AND status = 'pending'
AND (total * 100)::bigint = $2
RETURNING id`,
[reference, tx.amount],
);
if (rows.length === 1) {
await queueReceipt(rows[0].id); // reached once per order, by whoever won the update
return { paid: true };
}
// Nothing changed. Either it was already paid (a replay), or the amount did not match.
const { rows: found } = await pool.query(
"SELECT status FROM orders WHERE paystack_reference = $1",
[reference],
);
if (found[0]?.status !== "paid") {
console.error(`Payment ${reference} did not match its order. Review it by hand.`);
}
return { paid: found[0]?.status === "paid" };
}
The UPDATE ... WHERE status = 'pending' is doing the real work. Postgres will only let
one statement flip that row, so if the webhook and the redirect arrive at the same moment,
one of them updates the order and the other changes nothing. The amount check lives in the
same statement, and the comparison happens in exact decimals inside the database.
One honest caveat: if queueReceipt fails after the update commits, a retry will not send
the receipt, because the order is already paid. In production, write the receipt job into
an outbox table in the same transaction as the update, and let a worker send it.
The callback page then just reports the result:
// app/checkout/complete/page.jsx
import { markPaid } from "@/lib/payments";
export const dynamic = "force-dynamic";
export default async function CheckoutComplete({ searchParams }) {
const { reference } = await searchParams;
const { paid } = reference ? await markPaid(reference) : { paid: false };
return paid ? (
<p>Payment received. Your receipt is on its way.</p>
) : (
<p>We are confirming your payment. You will get an email as soon as it clears.</p>
);
}
Rule 5: verify the webhook, and let failures fail
Paystack signs every webhook. The x-paystack-signature header is an HMAC SHA512 of the
raw request body, keyed with your secret key. Two mistakes turn up in a lot of example
code: parsing the JSON before checking the signature, and returning 200 even when the
handler's own processing failed.
// app/api/paystack/webhook/route.js
import crypto from "node:crypto";
import { markPaid } from "@/lib/payments";
export async function POST(request) {
const raw = await request.text(); // the exact bytes Paystack signed
const signature = request.headers.get("x-paystack-signature") ?? "";
const expected = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
.update(raw)
.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(raw);
if (event.event === "charge.success") {
await markPaid(event.data.reference);
}
return new Response("ok");
}
Notice there is no try/catch that returns 200. If markPaid throws because the database
is down, the request fails with a 500, and that is what you want. Paystack treats anything
other than a 200 as a failed delivery and tries again. In live mode it retries every 3
minutes for the first 4 attempts, then hourly for up to 72 hours. A handler that swallows
the error and returns 200 turns a five-minute outage into a payment you never record.
Also notice the webhook does not trust its own body for the amount. It passes the reference
to markPaid, which asks Paystack. The signature proves the message came from Paystack.
The verify call tells you the current state of the transaction.
Test the edges, not just the happy path
Paystack's test keys and test cards cover the happy path. These are the tests that catch the money bugs:
- Change the amount in the browser. The charge should still be the order total.
- Open the callback URL with a made-up reference. Nothing should change.
- Replay a webhook. Paystack's dashboard lets you resend a delivery. The order should stay paid, and no second receipt should go out.
- Send a webhook with a bad signature. It should get a 401 and change nothing.
- Take the database down mid-webhook. The endpoint should return an error, and the retry should complete the order once the database is back.
If all five pass, you have done the part most tutorials skip.
The short version
- The server sets the amount, from the order, never the browser.
- Money is an exact decimal. Kobo only exists in the request to Paystack.
- The redirect and the webhook both call one function that asks Paystack and flips the order once.
- The webhook checks its signature against the raw body and lets real failures return an error so Paystack retries.
Further reading: Paystack's docs on accepting payments, webhooks and the transaction API.
Frequently asked questions
- Does Paystack take amounts in naira or kobo?
- In the currency's subunit, so kobo for NGN. ₦12,500.50 is sent as 1250050. Convert at the moment you call Paystack and keep naira everywhere else, stored as an exact decimal rather than a float.
- How do I verify a Paystack webhook?
- Paystack sends an x-paystack-signature header: an HMAC SHA512 of the raw request body, keyed with your secret key. Compute the same HMAC over the exact bytes you received, compare the two in constant time, and reject the request if they differ.
- What happens if my webhook endpoint fails?
- Paystack treats anything other than a 200 as a failed delivery and retries. In live mode that is every 3 minutes for the first 4 attempts, then hourly for up to 72 hours. So return an error when your own processing fails, rather than a 200 that hides it.
- Should I fulfil an order from the redirect or the webhook?
- Either can arrive first, so both should call the same idempotent function that asks Paystack what happened and flips the order from pending to paid once. Never fulfil on the browser's word alone.