Who this is for
If you're a developer, this guide gives you a production-shaped Paystack integration for the Next.js App Router: environment setup, a checkout component, and — the part almost every tutorial skips — a webhook handler that actually verifies the payment happened.
If you're building with AI tools and don't write every line yourself, read the "Why this matters" callouts. They explain the parts that are easy for an AI assistant to quietly get wrong, and the ones that cost real money when they do.
Either way, by the end you'll have a checkout button that takes real Naira, and a backend that only marks an order as paid when Paystack itself confirms it — not when the browser says so.
The one idea to understand before you write any code
A Paystack payment has two halves, and they are not the same event:
- The client-side popup closes with "success." This happens in the customer's browser. It tells you the user experience finished.
- Paystack's servers confirm the money actually moved. This happens between Paystack and your server, over a webhook, independent of the customer's browser.
Why this matters: A customer can close the success popup, get a callback that says
status: success, and still not have paid — their card could have been declined a second later, the request could be replayed by someone who captured the response, or the browser could simply be lying (yes, this happens with browser extensions and proxies). If you mark an order as "paid" the moment the client-side callback fires, you will eventually ship a product to someone who didn't pay for it. The webhook is not optional — it's the actual source of truth.
Prerequisites
- A Paystack account — the free Test keys are enough for everything in this guide
- A Next.js project (App Router — this guide assumes
src/app) - Your Paystack Secret Key and Public Key from Settings → API Keys & Webhooks
Get your test keys before you start. They look like pk_test_... and sk_test_... — the test in the middle is how you know you won't accidentally charge a real card while you build.
Step 1: Set up your environment variables
Create (or open) .env.local in your project root:
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxx
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
Why this matters: Only the key prefixed
NEXT_PUBLIC_is allowed anywhere near browser code. The secret key must never appear in a component, a client bundle, or aconsole.logyou forget to remove — it can move money out of your Paystack balance. If an AI assistant generates code that importsPAYSTACK_SECRET_KEYinto a file with'use client'at the top, that's a bug to fix immediately, not a style preference.
Make sure .env.local is in your .gitignore (Next.js adds this by default — just don't remove it).
Step 2: Install the checkout library
npm install react-paystack
This gives you a React hook that opens Paystack's hosted checkout popup — you don't build the card-entry form yourself, which is exactly what you want (your servers never touch raw card numbers, and PCI compliance stays Paystack's problem, not yours).
Step 3: Build the checkout button
Create src/components/PaystackCheckout.tsx:
'use client';
import { usePaystackPayment } from 'react-paystack';
type Props = {
email: string;
amountInNaira: number;
onSuccess: (reference: string) => void;
};
export default function PaystackCheckout({ email, amountInNaira, onSuccess }: Props) {
const config = {
reference: crypto.randomUUID(),
email,
amount: Math.round(amountInNaira * 100), // Paystack expects kobo, not naira
publicKey: process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY!,
};
const initializePayment = usePaystackPayment(config);
return (
<button
onClick={() =>
initializePayment({
onSuccess: (response) => {
// This confirms the POPUP closed successfully — NOT that
// the payment is verified. Treat it as "go check the order status,"
// not "the order is paid." The webhook in Step 4 is the real signal.
onSuccess(response.reference);
},
onClose: () => {
console.log('Checkout closed before completing payment');
},
})
}
className="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-colors"
>
Pay ₦{amountInNaira.toLocaleString()}
</button>
);
}
A few details worth calling out:
amountis in kobo, always. Forgetting the* 100is the single most common Paystack bug — it either charges customers 100x too little or fails validation entirely.referencemust be unique per attempt.crypto.randomUUID()is available in modern browsers and Node without an import. If you generate the reference on the server instead (recommended for anything beyond a demo), you can tie it to an order ID from the start.onSuccesshere is a UI signal, not a payment confirmation. Use it to show a "confirming your payment..." state and redirect to an order page — not to unlock the product or ship the order.
Step 4: Verify the payment on your server (the part that actually matters)
There are two ways to confirm a payment happened: polling Paystack's Verify endpoint after the client-side callback, and listening for their webhook. Use both — the client callback tells you when to check, the webhook is what you should actually trust.
Create the webhook route at src/app/api/paystack-webhook/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-paystack-signature');
const expectedHash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY!)
.update(rawBody)
.digest('hex');
// ── Verify this request actually came from Paystack ──
// Anyone can POST to a public URL. Only the signature proves it's real.
if (expectedHash !== signature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
switch (event.event) {
case 'charge.success': {
const { reference, amount, customer } = event.data;
// TODO: look up the order by `reference`, confirm `amount` (in kobo)
// matches what you expected, and mark it paid — idempotently.
// Paystack can and will retry this webhook. If your handler isn't
// safe to run twice for the same reference, you'll double-fulfill orders.
await markOrderAsPaid(reference, amount, customer.email);
break;
}
default:
// Ignore events you don't handle yet — return 200 anyway.
break;
}
// Paystack expects a fast 2xx response. Do slow work (emails, etc.)
// after responding, or in a background job.
return NextResponse.json({ received: true });
}
async function markOrderAsPaid(reference: string, amountKobo: number, email: string) {
// Your database logic here. Guard against re-processing the same
// reference — e.g. `UPDATE orders SET status = 'paid' WHERE reference = $1 AND status != 'paid'`.
}
Why this matters: The signature check is not boilerplate you can skip in a hurry. Without it, anyone who finds your webhook URL — which is not secret, it's just a public POST endpoint — can send a fake
charge.successevent and get free product. This is the single highest-value security check in this entire guide.
Register this URL in your Paystack dashboard under Settings → API Keys & Webhooks → Webhook URL: https://yourdomain.com/api/paystack-webhook. It has to be a publicly reachable HTTPS URL, so this step only works once you've deployed (or you can test it locally with a tool like ngrok).
Step 5: Test before you touch a live key
Paystack's test mode uses fake cards that behave like real ones:
| Card number | Result |
|---|---|
4084 0840 8408 4081 | Successful charge |
5060 6666 6666 6666 666 | Declined (insufficient funds) |
Use any future expiry date, any 3-digit CVV, and PIN 1234 / OTP 123456 when prompted. Run a full flow — success and a decline — before you ever request live keys. A vibe-coded checkout that's only ever been tested on the happy path will fail the first time a real customer's card is declined, usually by showing them a broken UI instead of a clear "try another card" message.
Common mistakes
- Trusting
onSuccessfrom the client as proof of payment. Covered above, but worth repeating — this is the mistake that actually loses money. - Forgetting the kobo conversion.
amount: 20000charges ₦200, not ₦20,000. - Putting the secret key in client code. If your bundle analyzer or browser devtools can find
sk_test_orsk_live_anywhere in the shipped JS, rotate that key immediately. - No idempotency on the webhook. Paystack retries webhooks that don't respond fast enough or return a non-2xx. If a retry re-runs your fulfillment logic, customers get double-charged emails, duplicate inventory decrements, or double-sent products.
- Testing only the success path. Declines, timeouts, and closed popups are the normal case for a meaningful fraction of real traffic — handle
onCloseand failed verifications explicitly.
Before you go live
- Webhook signature verification is in place and tested with an invalid signature (it should reject)
- Webhook handler is idempotent — running it twice for the same reference doesn't double-fulfill
- Amount from the webhook is checked against the expected order amount, not just trusted
- Secret key lives only in server-side environment variables, never in client code
- You've tested a declined card, not just a successful one
- Live keys are only added after all of the above pass in test mode
Key takeaway
The checkout button is the easy 20% — any AI assistant or tutorial can generate it. The webhook, the signature check, and the idempotency guard are the 80% that determines whether your payment flow is a real feature or a liability waiting to happen. Build the button first if you need a demo today, but don't consider payments "done" until the webhook is verified and tested.
Ready to start building?
Explore the most comprehensive directory of APIs for Nigerian developers and find exactly what you need.
Browse the API Directory