Bonaventure OgetoBy Bonaventure Ogeto|

Verify Transaction: The Paystack Call You Must Never Skip

After every Paystack payment, your server must call GET /transaction/verify/:reference using your secret key. Check that data.status is "success", that data.amount matches what you expected, and that data.currency matches too. Only then grant value. Skipping this call means anyone can fake a payment by triggering your frontend callback manually.

Why Verification Is Mandatory

Your frontend checkout fires a callback when the customer finishes paying. The popup closes, your JavaScript runs, and everything looks good. But here is the problem: that callback runs in the browser. The browser is the customer's machine. They control it completely.

An attacker can open DevTools, find your callback function, and call it with a fake reference. Or they can intercept the redirect URL and visit it directly without paying. If your application trusts the frontend callback and grants value immediately, you just gave away your product for free.

This is not a theoretical attack. It happens to African startups running on Paystack every month. The fix is simple: never trust the client. After every payment, your server calls Paystack directly to confirm the transaction actually succeeded.

The endpoint is GET /transaction/verify/:reference. It returns the full transaction object, including the actual status, amount, currency, and fees. Your server compares these against what it expected and only grants value if everything matches.

For the full verification and reconciliation pipeline, see the complete verification and reconciliation guide.

The Verify Endpoint: Request and Response

The endpoint takes one parameter: the transaction reference you used when initializing the transaction. Here is the full Node.js code:

// verify-transaction.js
const https = require('https');

async function verifyPaystackTransaction(reference) {
  const encodedRef = encodeURIComponent(reference);

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

  if (!response.ok) {
    throw new Error('Paystack API returned ' + response.status);
  }

  return response.json();
}

A successful verification response looks like this:

{
  "status": true,
  "message": "Verification successful",
  "data": {
    "id": 1234567890,
    "status": "success",
    "reference": "order_abc123_1689012345",
    "amount": 500000,
    "currency": "NGN",
    "channel": "card",
    "paid_at": "2026-07-20T10:30:00.000Z",
    "fees": 7500,
    "customer": {
      "email": "customer@example.com",
      "customer_code": "CUS_abc123"
    },
    "authorization": {
      "authorization_code": "AUTH_abc123",
      "card_type": "visa",
      "last4": "4081",
      "bank": "First Bank"
    },
    "metadata": {
      "order_id": "abc123"
    }
  }
}

There are two "status" fields in this response. The top-level "status": true is a boolean that tells you the API call itself succeeded. The nested "data.status": "success" is a string that tells you whether the payment went through. You need to check both.

The Reference Parameter

The reference you pass to the verify endpoint is the same one you provided when initializing the transaction. It is not the Paystack transaction ID (the numeric id field). It is not the access code. It is your reference string.

If you let Paystack auto-generate the reference (by not passing one during initialization), Paystack returns it in the initialization response. You need to capture and store that reference before the customer pays, because you will need it for verification.

Always URL-encode the reference before using it in the URL. If your reference contains special characters (slashes, spaces, plus signs), a raw insertion will break the URL:

// Always encode the reference
const url = 'https://api.paystack.co/transaction/verify/'
  + encodeURIComponent(reference);

// Bad: if reference contains '/' or '+', URL breaks
// const url = 'https://api.paystack.co/transaction/verify/' + reference;

A good convention is to generate references that only contain alphanumeric characters, hyphens, and underscores. Something like order_abc123_1689012345. This avoids encoding issues entirely. See the transaction references design patterns guide for more on this.

A Complete Verification Handler

Here is a production-grade Express.js endpoint that handles the full verification flow. It looks up the expected values from the database, calls Paystack, compares, and grants value only if everything matches:

// routes/verify-payment.js
const express = require('express');
const router = express.Router();
const db = require('../db');

router.get('/api/verify-payment', async (req, res) => {
  var reference = req.query.reference;

  if (!reference) {
    return res.status(400).json({ error: 'Missing reference' });
  }

  // 1. Look up the order we created before checkout
  var order = await db.query(
    'SELECT id, expected_amount, expected_currency, status FROM orders WHERE paystack_reference = $1',
    [reference]
  );

  if (order.rows.length === 0) {
    return res.status(404).json({ error: 'Order not found' });
  }

  var row = order.rows[0];

  // 2. If already paid, skip verification
  if (row.status === 'paid') {
    return res.json({ status: 'already_paid', order_id: row.id });
  }

  // 3. Call Paystack to verify
  var paystackRes = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var payload = await paystackRes.json();

  if (!payload.status) {
    console.error('Paystack verify failed for ' + reference, payload.message);
    return res.status(502).json({ error: 'Verification failed' });
  }

  var txn = payload.data;

  // 4. Check transaction status
  if (txn.status !== 'success') {
    return res.json({ status: 'not_paid', paystack_status: txn.status });
  }

  // 5. Check amount (in smallest currency unit)
  if (txn.amount !== row.expected_amount) {
    console.error('Amount mismatch: expected ' + row.expected_amount + ', got ' + txn.amount);
    return res.status(400).json({ error: 'Amount mismatch' });
  }

  // 6. Check currency
  if (txn.currency !== row.expected_currency) {
    console.error('Currency mismatch: expected ' + row.expected_currency + ', got ' + txn.currency);
    return res.status(400).json({ error: 'Currency mismatch' });
  }

  // 7. All checks passed. Grant value.
  await db.query(
    'UPDATE orders SET status = $1, paid_at = $2, paystack_fees = $3, paystack_id = $4, channel = $5 WHERE id = $6',
    ['paid', txn.paid_at, txn.fees, txn.id, txn.channel, row.id]
  );

  return res.json({ status: 'paid', order_id: row.id });
});

module.exports = router;

Notice that the expected amount and currency come from the database. They were set when the order was created, before the customer ever saw the checkout page. The client only sends the reference.

When to Call Verify

There are three moments when you should verify a transaction:

1. On the redirect callback. When the customer finishes paying, Paystack redirects them to your callback URL with the reference as a query parameter. Your callback handler calls verify immediately.

2. In your webhook handler. When you receive a charge.success event, you already have the reference. Call verify before granting value, even though the webhook itself tells you the payment succeeded. The webhook payload could theoretically be forged if your signature validation has a bug. The verify call is a second layer of defense.

3. In your sweeper job. If you suspect you missed a webhook, or if a customer claims they paid but your system does not show it, you can call verify with the reference to check. Since the endpoint is idempotent, calling it days or weeks later still works.

Some teams skip verification in the webhook handler because they trust the HMAC signature validation. This is reasonable if your signature check is solid. But the verify call is cheap (one GET request), and the cost of getting it wrong is high (giving away product for free or charging someone twice). Belt and suspenders.

Handling Verification Failures

The verify endpoint can fail for several reasons. Each requires a different response:

Network timeout or 5xx from Paystack. Paystack is temporarily down or your network is unstable. Do not grant value. Do not mark the transaction as failed. Instead, retry after a few seconds. If you are in a webhook handler, return a non-200 status so Paystack retries the webhook. If you are in a callback handler, show the customer a "we are confirming your payment" page and verify later via a background job.

// Retry logic for verify
async function verifyWithRetry(reference, maxRetries) {
  var attempts = 0;

  while (attempts < maxRetries) {
    try {
      var result = await verifyPaystackTransaction(reference);
      return result;
    } catch (err) {
      attempts++;
      if (attempts >= maxRetries) throw err;
      // Wait before retrying: 1s, 2s, 4s...
      await new Promise(function(resolve) {
        setTimeout(resolve, Math.pow(2, attempts) * 1000);
      });
    }
  }
}

404 or "Transaction not found". The reference does not exist in Paystack. Either the transaction was never initialized, the reference is wrong, or someone is probing your endpoint with fake references. Do not grant value. Log it and move on.

Transaction status is "abandoned" or "failed". The customer started but did not complete payment, or the payment was declined. Do not grant value. You can show a "payment did not go through" message and offer to retry.

Amount or currency mismatch. The transaction succeeded but for a different amount or currency than expected. This is either a price tampering attempt or a bug in your initialization code. Do not grant value. Log the mismatch with full details for investigation.

Logging Verification Responses

Log every verification response, including the full response body. When a customer disputes a charge six months from now, your logs are your evidence. When your accountant asks why revenue does not match settlements, your logs have the answer.

// Log every verification call
async function verifyAndLog(reference) {
  var startTime = Date.now();
  var result;

  try {
    result = await verifyPaystackTransaction(reference);

    console.log(JSON.stringify({
      event: 'paystack_verify',
      reference: reference,
      success: true,
      status: result.data ? result.data.status : null,
      amount: result.data ? result.data.amount : null,
      currency: result.data ? result.data.currency : null,
      duration_ms: Date.now() - startTime,
      timestamp: new Date().toISOString(),
    }));

    return result;
  } catch (err) {
    console.error(JSON.stringify({
      event: 'paystack_verify_error',
      reference: reference,
      error: err.message,
      duration_ms: Date.now() - startTime,
      timestamp: new Date().toISOString(),
    }));

    throw err;
  }
}

Structure your logs as JSON so they are searchable. Include the reference, the returned status, the amount, the currency, and how long the call took. If you use a logging service like Datadog or CloudWatch, you can set up alerts for verification failures or amount mismatches.

Keep these logs for at least 12 months. Chargeback windows can extend that long in some card networks. If you cannot prove the customer paid the amount they are disputing, you lose the chargeback.

Common Mistakes That Skip Verification

These are the patterns that lead to skipped verification in real codebases:

Trusting the onSuccess callback. The Paystack inline JS popup has an onSuccess callback. Some developers treat this as proof of payment and immediately update the UI and database. The callback is useful for UX (redirect the user, show a success message) but it is not proof. Always verify server-side.

Verifying in the frontend. Some developers call the verify endpoint from their React or Vue app. This means using the secret key in the frontend, which exposes it to anyone who views the page source. The secret key must never leave your server.

Checking only the top-level status. The verification response has "status": true at the top level. This means the API call succeeded, not that the payment succeeded. You must also check data.status === "success".

Not checking amount or currency. A common shortcut is to only check data.status and skip the amount and currency comparison. This leaves you open to price tampering attacks. See the amount and currency verification guide for the full pattern.

Not handling verify-after-grant. If your webhook handler grants value and then the redirect callback also calls verify and grants value, you get the double-grant bug. See the double-grant bug guide for the fix.

Verify in Different Server Frameworks

The verification logic is the same regardless of your framework. Here are the key points for popular Node.js frameworks:

Express.js: Use a route handler on a GET or POST endpoint. The example above covers this fully.

Next.js API Routes:

// pages/api/verify-payment.js (Next.js)
export default async function handler(req, res) {
  var reference = req.query.reference;

  if (!reference) {
    return res.status(400).json({ error: 'Missing reference' });
  }

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

  var payload = await paystackRes.json();

  if (!payload.status || payload.data.status !== 'success') {
    return res.status(400).json({ error: 'Payment not verified' });
  }

  // Compare amount and currency against your database
  // Then grant value

  return res.json({ status: 'verified' });
}

Fastify:

// Fastify route
fastify.get('/api/verify-payment', async (request, reply) => {
  var reference = request.query.reference;

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

  var payload = await paystackRes.json();
  // Same checks as above...

  return { status: 'verified' };
});

The pattern is always the same: receive reference from client, call Paystack with your secret key, check status plus amount plus currency, then grant value. The framework does not change the logic.

Testing Your Verification Flow

Use Paystack test mode for all verification testing. Test keys start with sk_test_. In test mode, you can use Paystack's test card numbers to simulate successful and failed payments.

Here are the scenarios to test:

  • Happy path: Customer pays the correct amount. Verify returns success. Value is granted.
  • Wrong amount: Initialize with one amount, but simulate a verify response with a different amount. Your handler should reject it.
  • Failed transaction: Use a test card that triggers a decline. Verify returns data.status: "failed". Value should not be granted.
  • Abandoned transaction: Start a transaction but do not complete it. Verify returns data.status: "abandoned". Value should not be granted.
  • Network failure: Mock the Paystack API to return a 500 or timeout. Your handler should retry or queue for later verification.
  • Duplicate verification: Call your verify endpoint twice with the same reference. Value should only be granted once.

For automated testing, mock the Paystack API response rather than hitting the live test server. This keeps your tests fast and deterministic. See the reconciliation failure modes guide for more test scenarios.

Key Takeaways

  • Every Paystack payment must be verified with GET /transaction/verify/:reference from your server before granting value.
  • Use your secret key for this call. It is a server-only endpoint. Never expose it in frontend code.
  • The response contains two different "status" fields. The top-level boolean tells you the API call worked. The nested data.status string tells you whether the payment succeeded.
  • Always compare amount and currency from the verification response against what your database expects. A status of "success" alone is not enough.
  • The verify endpoint is idempotent. Calling it multiple times for the same reference returns the same result, so it is safe to retry on network failures.
  • Log every verification response. When a dispute happens months later, these logs are your evidence.

Frequently Asked Questions

What happens if I call verify multiple times for the same reference?
The verify endpoint is idempotent. You get the same response every time. Paystack does not charge you for verify calls or rate-limit them aggressively. It is safe to call it as many times as you need.
Can I use the verify endpoint with my public key?
No. The verify endpoint requires your secret key. Public keys can only be used for initializing transactions from the frontend. Verification must happen on your server where the secret key is stored securely.
How long after a payment can I call verify?
Indefinitely. Transaction records persist in Paystack. You can verify a transaction from months ago and still get the correct response. This is useful for reconciliation and dispute resolution.
Should I verify in my webhook handler even though the webhook already tells me the payment succeeded?
It is a good practice. The webhook payload could theoretically be replayed or your signature check could have a bug. The verify call is a second layer of defense. It costs one HTTP request and can save you from granting value on a forged webhook.
What if the verify call times out?
Do not grant value and do not mark the transaction as failed. Retry the verification with exponential backoff. If you are in a webhook handler, return a non-200 status so Paystack retries the webhook later.

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