Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in Nuxt

To verify Paystack payments in Nuxt, create a server route that calls the Paystack Verify Transaction API with your secret key from runtimeConfig. Call this route from your Vue page after the popup callback or on the redirect callback page using useFetch for SSR verification. Always check that data.data.status is "success" and that the amount matches your database record.

How Verification Works in Nuxt

Paystack verification follows a simple request chain:

  1. Customer completes payment (popup or redirect).
  2. Your Nuxt page gets the transaction reference.
  3. Your page calls a Nuxt server route with the reference.
  4. The server route calls Paystack's Verify Transaction API: GET /transaction/verify/:reference.
  5. Paystack returns the transaction details including status, amount, and currency.
  6. Your server route checks the status and amount, updates the database, and returns the result to the page.

The secret key never leaves the server. The client only sees the sanitized response from your server route.

Verification Server Route

// server/api/verify.get.ts
export default defineEventHandler(async (event) => {
  var config = useRuntimeConfig();
  var query = getQuery(event);
  var reference = String(query.reference || '');

  if (!reference) {
    throw createError({ statusCode: 400, message: 'Reference is required' });
  }

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

  var data = await response.json();

  if (!data.status) {
    throw createError({ statusCode: 400, message: data.message || 'Verification failed' });
  }

  var txn = data.data;

  // Look up expected amount from your database
  // var order = await db.orders.findOne({ reference: reference });
  // if (!order) throw createError({ statusCode: 404, message: 'Order not found' });
  // if (txn.amount !== order.expectedAmountInKobo) {
  //   throw createError({ statusCode: 400, message: 'Amount mismatch' });
  // }

  if (txn.status === 'success') {
    // Mark order as paid in database (idempotent)
    // await db.orders.updateOne({ reference }, { status: 'paid', paidAt: txn.paid_at });

    return {
      verified: true,
      amount: txn.amount / 100,
      currency: txn.currency,
      reference: txn.reference,
      channel: txn.channel,
      paidAt: txn.paid_at,
    };
  }

  throw createError({
    statusCode: 400,
    message: 'Payment status: ' + txn.status,
  });
});

The route returns a clean object with only the fields the frontend needs. It does not forward the entire Paystack response, which contains sensitive data like the customer's authorization object.

Verify After the Inline Popup

In your checkout page, call the verification route inside the popup's onSuccess callback:

<script setup>
var verifyState = ref('idle');
var verifyResult = ref(null);

async function onPaystackSuccess(transaction) {
  verifyState.value = 'verifying';

  try {
    var result = await $fetch('/api/verify', {
      params: { reference: transaction.reference },
    });

    verifyState.value = 'success';
    verifyResult.value = result;
  } catch (err) {
    verifyState.value = 'failed';
  }
}
</script>

<template>
  <div v-if="verifyState === 'verifying'">
    Verifying your payment...
  </div>
  <div v-else-if="verifyState === 'success'">
    <h2>Payment Confirmed</h2>
    <p>{{ verifyResult.currency }} {{ verifyResult.amount }}</p>
  </div>
  <div v-else-if="verifyState === 'failed'">
    <p>Could not verify. Please contact support.</p>
  </div>
</template>

Use $fetch (not useFetch) inside event handlers. $fetch runs immediately when called. useFetch is designed for setup-time data fetching.

Verify on the Redirect Callback Page

For redirect checkout, Paystack appends ?trxref=xxx&reference=xxx to your callback URL. Create a page that verifies during SSR:

<!-- pages/payment/callback.vue -->
<script setup>
var route = useRoute();
var reference = route.query.reference || route.query.trxref;

var { data, error, status } = await useFetch('/api/verify', {
  params: { reference: reference },
});
</script>

<template>
  <div v-if="status === 'pending'">
    <p>Verifying your payment...</p>
  </div>
  <div v-else-if="data">
    <h1>Payment Confirmed</h1>
    <p>Amount: {{ data.currency }} {{ data.amount }}</p>
    <p>Reference: {{ data.reference }}</p>
    <p>Paid via: {{ data.channel }}</p>
    <NuxtLink to="/">Back to Home</NuxtLink>
  </div>
  <div v-else>
    <h1>Verification Failed</h1>
    <p>We could not confirm your payment. If you were charged, please contact support with reference: {{ reference }}</p>
  </div>
</template>

Because useFetch runs during SSR, the verification happens on the server before the HTML is sent to the browser. The customer sees the result immediately. No client-side loading state needed for most users.

Amount Validation

Checking status === "success" is necessary but not sufficient. You must also verify the amount. Here is the problem scenario:

  1. Your checkout page sends amount: 50000 (500 NGN) to your server route.
  2. An attacker intercepts and changes it to amount: 100 (1 NGN).
  3. Paystack initializes a 1 NGN transaction. The attacker pays 1 NGN.
  4. Your verification route sees status: "success" and grants the product.

The fix: your server route must look up the expected price from your database, not trust the frontend. Compare the verified amount field against your stored record.

// In server/api/verify.get.ts
var order = await db.orders.findOne({ reference: reference });

if (!order) {
  throw createError({ statusCode: 404, message: 'Order not found' });
}

if (txn.amount !== order.amountInKobo || txn.currency !== order.currency) {
  // Log this as a potential fraud attempt
  throw createError({ statusCode: 400, message: 'Amount or currency mismatch' });
}

Idempotent Fulfillment

Three things can confirm the same payment:

  • The popup verification call
  • The redirect callback verification call
  • The webhook

If your fulfillment logic runs on each confirmation, the customer could get triple credit. Use a database flag to make fulfillment idempotent:

// In your verification or webhook handler
var order = await db.orders.findOne({ reference: reference });

if (order.status === 'paid') {
  // Already fulfilled, just return success
  return { verified: true, amount: order.amount };
}

// First confirmation, fulfill now
order.status = 'paid';
order.paidAt = txn.paid_at;
await order.save();
// Grant access, send email, etc.

This pattern works whether the verification call or the webhook arrives first.

Ship Payments Faster

Key Takeaways

  • Nuxt server routes handle verification securely. The Paystack secret key stays in runtimeConfig and never reaches the browser.
  • The Verify Transaction API checks the definitive status of a payment. Always call it before granting value.
  • Use useFetch on the redirect callback page for SSR verification. The customer sees the result instantly without a loading spinner.
  • Always validate the verified amount against your database. A successful status alone is not enough.
  • Both the popup verification and the webhook can confirm the same transaction. Use a database flag to prevent double fulfillment.
  • Return only the fields your frontend needs from the verification response. Do not expose the full Paystack response to the client.

Frequently Asked Questions

Should I use useFetch or $fetch to verify Paystack payments in Nuxt?
Use useFetch on the callback page so verification runs during SSR. Use $fetch inside event handlers like the popup onSuccess callback, where you need immediate execution after a user action.
What if the verification call fails but the customer was actually charged?
This is why webhooks exist. Your webhook handler will receive the charge.success event and can fulfill the order independently. The customer should see a message like "We are confirming your payment" and receive an email once the webhook processes.
Can I call the Paystack Verify API from the client side in Nuxt?
No. The Verify API requires your secret key in the Authorization header. Client-side code in Nuxt is visible in the browser. Always verify through a server route.
How many times can I verify the same transaction?
As many times as you want. The Verify API is idempotent and always returns the current state of the transaction. Paystack does not charge you for verification calls. You can verify a transaction days later if needed.
What does the Paystack Verify API return for failed transactions?
The API returns data.data.status as "failed" or "abandoned" depending on what happened. A failed transaction means the customer attempted to pay but the charge was declined. An abandoned transaction means the customer opened the checkout but never completed payment.

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