Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in Next.js Pages Router

Create an API route at pages/api/paystack/verify.ts that accepts a transaction reference as a query parameter, calls GET https://api.paystack.co/transaction/verify/:reference with your Paystack secret key, and returns the verification result. Check that data.status is "success" and that data.amount matches the expected amount stored in your database. For callback pages, use getServerSideProps to verify before the page renders.

Why Server-Side Verification Matters

When a customer finishes paying through Paystack Inline or returns from the Paystack redirect checkout, your frontend receives a transaction reference. This reference is just a string. It proves nothing. A malicious user can craft a fake callback URL with any reference they want. If your application grants access or ships products based on that reference without verifying it server-side, you will lose money.

Server-side verification means calling Paystack's API from your backend, using your secret key, and checking the actual transaction status. In Next.js Pages Router, API routes give you a server-side environment that runs in Node.js, has access to your environment variables, and can talk to both Paystack and your database securely.

This is not optional. Paystack's documentation requires server-side verification for every integration. Skip it and you are building a system that trusts the browser, which is the same as trusting strangers.

Understanding the Paystack Verify API

The verification endpoint is straightforward:

GET https://api.paystack.co/transaction/verify/:reference

You send your secret key in the Authorization header as a Bearer token. Paystack returns the full transaction object. The critical fields:

  • data.status - "success", "failed", "abandoned", or "pending"
  • data.amount - Amount in the smallest currency unit (kobo, pesewas, or cents)
  • data.currency - The currency code (NGN, GHS, KES, ZAR, USD)
  • data.reference - The transaction reference
  • data.channel - Payment channel used (card, bank, ussd, mobile_money, bank_transfer, qr)
  • data.paid_at - ISO timestamp of payment confirmation
  • data.metadata - Custom data you attached during initialization

Two checks are essential. First, data.status must be "success". Second, data.amount must match the amount you expected for this order. A successful payment of the wrong amount is not a valid payment.

Creating the Verification API Route

Create the file at pages/api/paystack/verify.ts:

// pages/api/paystack/verify.ts
import type { NextApiRequest, NextApiResponse } from 'next';

const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const { reference } = req.query;

  if (!reference || typeof reference !== 'string') {
    return res.status(400).json({ error: 'Transaction reference is required' });
  }

  if (!PAYSTACK_SECRET) {
    console.error('PAYSTACK_SECRET_KEY is not configured');
    return res.status(500).json({ error: 'Payment verification is not configured' });
  }

  try {
    const paystackRes = await fetch(
      'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
      {
        method: 'GET',
        headers: {
          Authorization: 'Bearer ' + PAYSTACK_SECRET,
          'Content-Type': 'application/json',
        },
      }
    );

    const payload = await paystackRes.json();

    if (!paystackRes.ok) {
      return res.status(paystackRes.status).json({
        error: payload.message || 'Verification request failed',
      });
    }

    const { data } = payload;

    if (data.status === 'success') {
      // TODO: Look up the expected amount from your database
      // const order = await db.findOrder(reference);
      // if (data.amount !== order.expectedAmount) {
      //   return res.status(400).json({ error: 'Amount mismatch' });
      // }

      // TODO: Mark the order as paid (idempotently)
      // if (!order.paid) {
      //   await db.markOrderPaid(reference, data);
      // }

      return res.status(200).json({
        status: 'success',
        reference: data.reference,
        amount: data.amount,
        currency: data.currency,
        channel: data.channel,
        paidAt: data.paid_at,
      });
    }

    return res.status(200).json({
      status: data.status,
      reference: data.reference,
      message: 'Payment was not successful. Status: ' + data.status,
    });
  } catch (error) {
    console.error('Paystack verification error:', error);
    return res.status(500).json({ error: 'Failed to verify payment' });
  }
}

This API route only accepts GET requests, validates the reference parameter, calls Paystack, and returns a structured response. The TODO comments mark where you should add your database logic.

Verifying in getServerSideProps for Callback Pages

When using Paystack's redirect flow, the customer returns to your callback URL with a ?reference=xxx query parameter. Instead of making a separate API call from the browser, you can verify in getServerSideProps so the page renders with the result already available:

// pages/payment/callback.tsx
import type { GetServerSideProps } from 'next';

interface CallbackProps {
  status: string;
  reference: string;
  amount?: number;
  currency?: string;
  channel?: string;
  error?: string;
}

export const getServerSideProps: GetServerSideProps<CallbackProps> = async (context) => {
  const reference = context.query.reference as string;

  if (!reference) {
    return {
      props: {
        status: 'error',
        reference: '',
        error: 'No payment reference provided',
      },
    };
  }

  try {
    const response = await fetch(
      'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
      {
        headers: {
          Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        },
      }
    );

    const payload = await response.json();

    if (!response.ok || !payload.data) {
      return {
        props: {
          status: 'error',
          reference,
          error: payload.message || 'Verification failed',
        },
      };
    }

    const { data } = payload;

    // TODO: Verify amount against your database and update order status

    return {
      props: {
        status: data.status,
        reference: data.reference,
        amount: data.amount,
        currency: data.currency,
        channel: data.channel || null,
      },
    };
  } catch (error) {
    return {
      props: {
        status: 'error',
        reference,
        error: 'Could not reach payment verification service',
      },
    };
  }
};

export default function PaymentCallbackPage(props: CallbackProps) {
  if (props.status === 'success') {
    return (
      <div>
        <h1>Payment Confirmed</h1>
        <p>Reference: {props.reference}</p>
        <p>Amount: {props.currency} {(props.amount || 0) / 100}</p>
        <p>Paid via: {props.channel}</p>
      </div>
    );
  }

  if (props.status === 'pending') {
    return (
      <div>
        <h1>Payment Pending</h1>
        <p>Your payment is being processed. We will notify you once it completes.</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Payment Not Completed</h1>
      <p>{props.error || 'Status: ' + props.status}</p>
    </div>
  );
}

The advantage of this approach is that there is no loading spinner on the callback page. The payment is verified before the HTML is sent to the browser, so the user sees the result immediately. The downside is slightly slower page load time since the server waits for the Paystack API call before responding.

Client-Side Flow After Paystack Inline

If you use Paystack Inline (the popup), the payment completes without a page redirect. Your frontend gets the reference in the onSuccess callback. Call your API route to verify:

// In your checkout page component
import { useState } from 'react';

export default function CheckoutPage() {
  const [verifying, setVerifying] = useState(false);
  const [result, setResult] = useState(null);

  function handlePaystackSuccess(response) {
    // response.reference is the transaction reference
    setVerifying(true);

    fetch('/api/paystack/verify?reference=' + encodeURIComponent(response.reference))
      .then(res => res.json())
      .then(data => {
        setResult(data);
        setVerifying(false);
      })
      .catch(() => {
        setResult({ status: 'error', error: 'Verification failed' });
        setVerifying(false);
      });
  }

  // Your Paystack Inline setup would call handlePaystackSuccess on completion

  if (verifying) return <p>Verifying payment...</p>;
  if (result && result.status === 'success') return <p>Payment verified</p>;
  if (result) return <p>{result.error || 'Payment was not successful'}</p>;

  return <button>Pay Now</button>;
}

Keep the UI simple. Show "Verifying payment..." while the API call is in progress. Do not show a success message until your server confirms it. The Paystack Inline popup might close with a "success" callback, but that only means the popup flow completed. Server-side verification is the real confirmation.

Edge Cases and How to Handle Them

Several edge cases will come up in production. Handle them upfront:

Pending transactions. Bank transfer and USSD payments can stay pending for minutes. When your verify call returns "pending", do not grant value. Show a message telling the customer their payment is being processed and that they will receive confirmation. Let your webhook handler (charge.success) update the order when payment clears.

Double verification. Both the callback URL and your webhook might trigger verification for the same transaction. Make your database update idempotent by checking if the order is already paid before modifying it. A simple if (order.paid) return prevents double-granting.

Network timeouts. If the Paystack API is slow, your API route might time out. Set a reasonable timeout (10 seconds) on the fetch call. If verification fails due to a timeout, tell the user their payment is being confirmed and rely on the webhook:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
  const response = await fetch(url, {
    headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET },
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  // process response
} catch (error) {
  clearTimeout(timeoutId);
  if (error.name === 'AbortError') {
    return res.status(504).json({
      status: 'timeout',
      message: 'Verification timed out. Your payment will be confirmed shortly.',
    });
  }
  throw error;
}

Reference not found. Paystack returns a 404 if the reference does not exist. This can happen if the reference is mistyped or if the transaction was never initialized. Return a clear error message and log the incident.

Stale references. If a customer bookmarks the callback URL and revisits it days later, the verification will still work (Paystack stores transactions indefinitely). Make sure your UI handles this gracefully. The order might already be fulfilled.

Building a Reusable Verification Utility

If you have multiple pages or API routes that need to verify payments, extract the verification logic into a shared utility:

// lib/paystack.ts
export interface PaystackVerifyResult {
  success: boolean;
  status: string;
  amount: number;
  currency: string;
  reference: string;
  channel: string;
  paidAt: string | null;
  metadata: Record<string, any> | null;
  error?: string;
}

export async function verifyPaystackTransaction(
  reference: string
): Promise<PaystackVerifyResult> {
  const secret = process.env.PAYSTACK_SECRET_KEY;
  if (!secret) {
    throw new Error('PAYSTACK_SECRET_KEY is not configured');
  }

  const response = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + secret,
      },
    }
  );

  const payload = await response.json();

  if (!response.ok) {
    return {
      success: false,
      status: 'error',
      amount: 0,
      currency: '',
      reference,
      channel: '',
      paidAt: null,
      metadata: null,
      error: payload.message || 'Verification failed',
    };
  }

  const { data } = payload;

  return {
    success: data.status === 'success',
    status: data.status,
    amount: data.amount,
    currency: data.currency,
    reference: data.reference,
    channel: data.channel,
    paidAt: data.paid_at || null,
    metadata: data.metadata || null,
  };
}

Now your API routes and getServerSideProps functions can call verifyPaystackTransaction(reference) without duplicating the HTTP logic. Keep the database updates in the calling code since different pages might handle successful payments differently.

Key Takeaways

  • Use API routes (pages/api/) for payment verification. They run server-side and keep your Paystack secret key secure.
  • Call GET https://api.paystack.co/transaction/verify/:reference with your secret key in the Authorization header.
  • Always compare data.amount against the expected amount in your database. Status alone is not enough to confirm correct payment.
  • Use getServerSideProps on your callback page to verify the payment before rendering, giving you a cleaner user experience than client-side verification.
  • Handle pending transactions by showing a waiting state and relying on webhooks for the final confirmation.
  • Make verification idempotent. Check if the order is already marked as paid before updating your database.
  • Log every verification attempt with the reference and result for debugging and audit purposes.

Frequently Asked Questions

Should I use an API route or getServerSideProps for verification?
Use getServerSideProps when the customer is redirected to a callback page, so the page renders with the result already loaded. Use an API route when verifying after Paystack Inline (popup), since the client needs to make an async call after the popup closes.
Can I use fetch in Pages Router API routes?
Yes. Next.js Pages Router API routes run on Node.js 18+, which has built-in fetch. You do not need to install node-fetch or axios, though you can use them if you prefer.
What if the customer closes the browser before the callback fires?
The callback will never fire, but your webhook handler will still receive the charge.success event from Paystack. This is why webhooks are your safety net. Always implement both callback verification and webhook handling.
How do I prevent someone from hitting my verify endpoint repeatedly?
Add rate limiting to your API route. You can use a simple in-memory map that tracks requests per IP, or use a middleware package. Also validate the reference format before calling Paystack to reject obviously invalid references.
Is getServerSideProps safe for handling secret keys?
Yes. getServerSideProps runs entirely on the server. Environment variables accessed there are never sent to the browser. The props you return are serialized and sent to the client, so make sure you do not include your secret key in the returned props.

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