Verify Paystack Payments in Next.js App Router
Create a Route Handler in your Next.js App Router project at app/api/paystack/verify/route.ts. Use the native fetch API to call GET https://api.paystack.co/transaction/verify/:reference with your secret key in the Authorization header. Check that data.status is "success" and that data.amount matches the expected amount in your database. Return the verification result to your client component, and always treat anything other than "success" as unpaid.
Why Server-Side Verification Is Mandatory
When a customer completes payment through Paystack Inline or the redirect flow, your frontend receives a callback with a transaction reference. This callback tells you the payment probably went through, but it is not proof. Anyone can fake a callback URL hit. Anyone can manipulate the client-side response object. If you grant value based on the frontend callback alone, you will eventually give away products and services for free.
Server-side verification is the only way to confirm a payment actually happened. You call Paystack's API from your server, using your secret key, and Paystack tells you the real status of that transaction. No middleman, no browser, no room for manipulation.
In Next.js App Router, Route Handlers are the natural place for this. They run on the server, have access to environment variables, and can talk to both Paystack and your database without exposing anything to the client.
Paystack themselves are clear about this: every integration must verify server-side. Their documentation states it, their support team will tell you, and their compliance reviews check for it. Skip verification and you are building a payment system that anyone with browser dev tools can exploit.
The Paystack Verify Endpoint
Paystack provides a single endpoint for transaction verification:
GET https://api.paystack.co/transaction/verify/:reference
The :reference is the transaction reference you generated when initializing the transaction, or the one Paystack assigned if you did not provide your own. You pass your secret key in the Authorization header as a Bearer token.
The response contains the full transaction object. The fields you care about most are:
- data.status - Either "success", "failed", "abandoned", or "pending"
- data.amount - The amount in the smallest currency unit (kobo for NGN, pesewas for GHS, cents for KES/ZAR/USD)
- data.currency - The currency code (NGN, GHS, KES, ZAR, USD)
- data.reference - The transaction reference, echoed back
- data.channel - The payment channel used (card, bank, ussd, mobile_money, bank_transfer, qr)
- data.paid_at - ISO timestamp of when payment was confirmed
- data.metadata - Any custom data you attached during initialization
You must check both the status and the amount. A "success" status only means the customer paid something. It does not mean they paid the right amount. If you initialized the transaction for 5000 kobo but the customer somehow paid 100 kobo, the status would still be "success". Your code must catch this.
Setting Up the Verify Route Handler
Create the file at app/api/paystack/verify/route.ts. This gives you a clean URL at /api/paystack/verify that your frontend can call after the customer returns from payment.
// app/api/paystack/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const reference = searchParams.get('reference');
if (!reference) {
return NextResponse.json(
{ error: 'Transaction reference is required' },
{ status: 400 }
);
}
if (!PAYSTACK_SECRET) {
console.error('PAYSTACK_SECRET_KEY is not set');
return NextResponse.json(
{ error: 'Payment verification is not configured' },
{ status: 500 }
);
}
try {
const paystackResponse = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
method: 'GET',
headers: {
Authorization: 'Bearer ' + PAYSTACK_SECRET,
'Content-Type': 'application/json',
},
}
);
const payload = await paystackResponse.json();
if (!paystackResponse.ok) {
return NextResponse.json(
{ error: payload.message || 'Verification failed' },
{ status: paystackResponse.status }
);
}
const { data } = payload;
// Look up the expected amount from your database
// Replace this with your actual database query
// const order = await db.order.findUnique({ where: { reference } });
// const expectedAmount = order.amount;
if (data.status === 'success') {
// TODO: Compare data.amount with your expected amount
// if (data.amount !== expectedAmount) {
// return NextResponse.json({ error: 'Amount mismatch' }, { status: 400 });
// }
// TODO: Update your database to mark the order as paid
// await db.order.update({ where: { reference }, data: { paid: true } });
return NextResponse.json({
status: 'success',
reference: data.reference,
amount: data.amount,
currency: data.currency,
channel: data.channel,
paidAt: data.paid_at,
});
}
return NextResponse.json({
status: data.status,
reference: data.reference,
message: 'Payment was not successful',
});
} catch (error) {
console.error('Paystack verification error:', error);
return NextResponse.json(
{ error: 'Failed to verify payment' },
{ status: 500 }
);
}
}
A few things to notice. The Route Handler uses the native fetch API, which is available in all Next.js App Router environments. The reference is URL-encoded before being inserted into the URL to prevent injection. The secret key comes from an environment variable that is only accessible on the server.
Calling the Verify Endpoint from a Client Component
After the customer completes payment through Paystack Inline, you get a callback with the transaction reference. Your client component should call your Route Handler to verify the payment before showing a success screen.
// app/checkout/PaymentVerifier.tsx
'use client';
import { useState, useEffect } from 'react';
interface VerificationResult {
status: string;
reference?: string;
amount?: number;
currency?: string;
channel?: string;
paidAt?: string;
error?: string;
message?: string;
}
export default function PaymentVerifier({ reference }: { reference: string }) {
const [result, setResult] = useState<VerificationResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function verify() {
try {
const response = await fetch(
'/api/paystack/verify?reference=' + encodeURIComponent(reference)
);
const data = await response.json();
setResult(data);
} catch (err) {
setResult({ status: 'error', error: 'Network error during verification' });
} finally {
setLoading(false);
}
}
verify();
}, [reference]);
if (loading) {
return <div>Verifying your payment...</div>;
}
if (!result) {
return <div>Unable to verify payment. Please contact support.</div>;
}
if (result.status === 'success') {
return (
<div>
<h2>Payment Successful</h2>
<p>Reference: {result.reference}</p>
<p>Amount: {result.currency} {(result.amount || 0) / 100}</p>
<p>Paid via: {result.channel}</p>
</div>
);
}
if (result.status === 'pending') {
return (
<div>
<h2>Payment Pending</h2>
<p>Your payment is still being processed. We will update your order once it completes.</p>
</div>
);
}
return (
<div>
<h2>Payment Failed</h2>
<p>{result.message || result.error || 'Please try again.'}</p>
</div>
);
}
The component mounts, calls the verify endpoint once, and renders based on the result. Notice it handles three states: success, pending, and everything else. The pending state matters because bank transfers and USSD payments can take a while to confirm.
Verifying the Amount Against Your Database
Status checking alone is not enough. You need to confirm the amount matches what you expected. Here is the pattern with a database lookup:
// Inside your Route Handler
import { prisma } from '@/lib/prisma'; // or your database client
// After getting the Paystack response
if (data.status === 'success') {
const order = await prisma.order.findUnique({
where: { paystackReference: reference },
});
if (!order) {
return NextResponse.json(
{ error: 'Order not found for this reference' },
{ status: 404 }
);
}
if (data.amount !== order.amountInKobo) {
console.error(
'Amount mismatch: expected ' + order.amountInKobo + ', got ' + data.amount +
' for reference ' + reference
);
return NextResponse.json(
{ error: 'Payment amount does not match order' },
{ status: 400 }
);
}
if (order.paid) {
// Already processed, return success without double-granting
return NextResponse.json({
status: 'success',
reference: data.reference,
amount: data.amount,
message: 'Payment already verified',
});
}
// Mark as paid
await prisma.order.update({
where: { paystackReference: reference },
data: {
paid: true,
paidAt: new Date(data.paid_at),
paymentChannel: data.channel,
},
});
return NextResponse.json({
status: 'success',
reference: data.reference,
amount: data.amount,
currency: data.currency,
});
}
The key points: look up the order by reference, compare amounts, check if already processed to prevent double-granting, then update the database. The idempotency check (is the order already paid?) protects you when both the webhook and the callback URL trigger verification for the same transaction.
Handling Pending and Failed Transactions
Not every transaction resolves immediately. Bank transfers, USSD, and mobile money payments can stay in "pending" status for minutes or even hours. Your Route Handler needs to handle this gracefully.
// Extended status handling in your Route Handler
switch (data.status) {
case 'success':
// Process the successful payment (as shown above)
break;
case 'pending':
// Do NOT grant value yet
// Inform the user and rely on webhooks for the final status
return NextResponse.json({
status: 'pending',
reference: data.reference,
message: 'Payment is still being processed. You will receive confirmation shortly.',
});
case 'failed':
return NextResponse.json({
status: 'failed',
reference: data.reference,
message: 'Payment failed. Please try again with a different payment method.',
});
case 'abandoned':
return NextResponse.json({
status: 'abandoned',
reference: data.reference,
message: 'Payment was not completed. You can retry from your orders page.',
});
default:
return NextResponse.json({
status: 'unknown',
reference: data.reference,
message: 'Unable to determine payment status. Contact support if you were charged.',
});
}
For pending transactions, never grant value. Instead, show a waiting message and let your webhook handler (charge.success) complete the flow when Paystack confirms the payment. The verify endpoint is for immediate feedback. The webhook is for eventual confirmation.
For abandoned transactions, consider sending a follow-up email with a link to retry payment. See Handling Abandoned Paystack Checkouts for that pattern.
Verification in a Server Component (Callback Page)
If you use Paystack's redirect flow instead of Inline, the customer returns to a callback URL with the reference as a query parameter. In App Router, you can verify directly in a Server Component:
// app/payment/callback/page.tsx
async function verifyPayment(reference: string) {
const response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
cache: 'no-store', // Always fetch fresh data
}
);
if (!response.ok) {
return null;
}
const payload = await response.json();
return payload.data;
}
export default async function PaymentCallbackPage({
searchParams,
}: {
searchParams: { reference?: string };
}) {
const reference = searchParams.reference;
if (!reference) {
return <div>No payment reference provided.</div>;
}
const transaction = await verifyPayment(reference);
if (!transaction) {
return <div>Unable to verify payment. Please contact support.</div>;
}
if (transaction.status === 'success') {
return (
<div>
<h1>Payment Confirmed</h1>
<p>Reference: {transaction.reference}</p>
<p>Amount: {transaction.currency} {transaction.amount / 100}</p>
</div>
);
}
return (
<div>
<h1>Payment {transaction.status}</h1>
<p>Your payment could not be confirmed. Status: {transaction.status}</p>
</div>
);
}
The cache: 'no-store' option is important. Without it, Next.js might cache the verification response, which means a pending transaction could appear as pending forever even after it succeeds. Always use no-store for payment verification.
Error Handling and Network Retries
Network requests to Paystack can fail. The Paystack API might be temporarily slow, your server might have connectivity issues, or there might be a transient DNS failure. Your verification code should handle these scenarios:
async function verifyWithRetry(
reference: string,
maxRetries: number = 3
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
signal: AbortSignal.timeout(10000), // 10 second timeout
}
);
if (response.status === 429) {
// Rate limited, wait before retrying
const waitMs = attempt * 2000;
await new Promise(resolve => setTimeout(resolve, waitMs));
continue;
}
return await response.json();
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
const waitMs = attempt * 1000;
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
}
throw lastError || new Error('Verification failed after ' + maxRetries + ' attempts');
}
Use AbortSignal.timeout() to prevent hanging requests. Implement exponential backoff for retries. Handle 429 (rate limit) responses by waiting longer between attempts. Log every failure so you can debug network issues in production.
Do not retry indefinitely. Three attempts is reasonable. If Paystack is genuinely down, your webhook will deliver the result later. The verify endpoint is for immediate confirmation, not guaranteed delivery.
Security Considerations
Payment verification is a security-critical path. Here are the things that matter:
Never expose your secret key. Route Handlers and Server Components in App Router run server-side, so your secret key stays on the server. But double-check that you are not accidentally importing verification logic into a client component marked with 'use client'.
Validate the reference format. Before hitting the Paystack API, check that the reference looks reasonable. A simple length and character check prevents unnecessary API calls:
function isValidReference(ref: string): boolean {
return ref.length > 0 && ref.length < 256 && /^[a-zA-Z0-9_-]+$/.test(ref);
}
Rate limit your verification endpoint. Without rate limiting, an attacker could use your endpoint to brute-force valid transaction references. Use middleware or a simple in-memory counter to limit requests per IP.
Log verification attempts. Every verification call should be logged with the reference, the result, and the IP address. This audit trail is valuable when investigating fraud or debugging customer complaints.
Use HTTPS everywhere. Next.js deployments on Vercel and similar platforms handle this automatically. If you self-host, make sure TLS is configured properly.
Key Takeaways
- ✓Always verify payments server-side in a Route Handler. Never trust the callback URL or client-side response from Paystack Inline.
- ✓Use the native fetch API in Route Handlers. No need for axios or node-fetch since Next.js App Router runs on Node 18+ with built-in fetch.
- ✓Check both data.status === "success" and data.amount against your stored expected amount. A successful status with a wrong amount is just as dangerous as a failed payment.
- ✓Store the transaction reference in your database before redirecting to payment. This lets your verify endpoint look up the expected amount.
- ✓Handle the "pending" status separately. Some payment channels like bank transfers and USSD can take minutes to resolve.
- ✓Return structured JSON from your Route Handler so your client component can display the right UI based on verification result.
- ✓Use environment variables through process.env for your Paystack secret key. Route Handlers run server-side, so secrets are never exposed to the browser.
Frequently Asked Questions
- Can I verify a Paystack payment from a Client Component?
- No. Client Components run in the browser, and you would need to expose your Paystack secret key to call the verify endpoint. Always verify from a Route Handler or Server Component, then return the result to the client.
- What happens if I call verify on a reference that does not exist?
- Paystack returns a 404 with a message like "Transaction not found". Your code should handle this as an error and tell the user the payment could not be verified. Log the reference for investigation.
- Should I verify on the callback URL or rely only on webhooks?
- Do both. Verify on the callback URL for immediate user feedback, and handle the charge.success webhook for reliable background processing. The webhook is your safety net for cases where the user closes their browser before the callback fires.
- How do I handle the case where both the webhook and callback verify the same transaction?
- Make your verification idempotent. Before granting value, check if the order is already marked as paid. If it is, return success without updating anything. This way, it does not matter if verification runs once or ten times.
- Does the Paystack verify endpoint have rate limits?
- Yes, Paystack applies rate limits across their API. For most integrations this is not a problem since you only verify once per transaction. If you are hitting rate limits, something is wrong with your flow, likely verifying in a loop or retrying too aggressively.
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