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.
Paystack verification follows a simple request chain:
GET /transaction/verify/:reference.The secret key never leaves the server. The client only sees the sanitized response from your 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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
Checking status === "success" is necessary but not sufficient. You must also verify the amount. Here is the problem scenario:
amount: 50000 (500 NGN) to your server route.amount: 100 (1 NGN).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' });
}
Three things can confirm the same payment:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course