To handle Paystack webhooks in Nuxt 3, create a server route at server/api/webhooks/paystack.post.ts. Read the raw request body, compute the HMAC SHA512 hash using your secret key from runtimeConfig, and compare it to the x-paystack-signature header. Process the event and return a 200 response immediately.
When a payment is completed (or fails, or a subscription changes), Paystack sends an HTTP POST request to a URL you configure in your dashboard. This POST request contains a JSON body with the event type and details.
The webhook arrives from Paystack's servers to your server. It does not go through the customer's browser. This is a server-to-server call, which is why it works perfectly with Nuxt server routes.
Common webhook events:
charge.success - A payment was completedtransfer.success - A transfer was completedtransfer.failed - A transfer failedsubscription.create - A new subscription was createdinvoice.payment_failed - A recurring charge failedrefund.processed - A refund was processed// server/api/webhooks/paystack.post.ts
import crypto from 'crypto';
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var secret = config.paystackSecretKey;
// Read raw body for signature verification
var rawBody = await readRawBody(event);
if (!rawBody) {
throw createError({ statusCode: 400, message: 'Empty body' });
}
// Verify signature
var signature = getHeader(event, 'x-paystack-signature');
var hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
throw createError({ statusCode: 400, message: 'Invalid signature' });
}
// Parse the verified body
var payload = JSON.parse(rawBody);
var eventType = payload.event;
var data = payload.data;
// Process events
if (eventType === 'charge.success') {
var reference = data.reference;
var amount = data.amount;
var currency = data.currency;
var customerEmail = data.customer.email;
// Update order in database
// await db.orders.updateOne(
// { reference: reference, status: { $ne: 'paid' } },
// { status: 'paid', amount: amount, paidAt: data.paid_at }
// );
console.log('Payment confirmed:', reference, amount, currency);
}
if (eventType === 'transfer.success') {
// Handle successful transfer
console.log('Transfer completed:', data.reference);
}
if (eventType === 'transfer.failed') {
// Handle failed transfer, possibly retry or notify admin
console.log('Transfer failed:', data.reference, data.reason);
}
// Return 200 immediately
return { received: true };
});
Key points about this route:
.post.ts suffix ensures only POST requests reach this handler.readRawBody reads the body as a string before any JSON parsing, which is required for accurate HMAC verification.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Paystack signs every webhook with HMAC SHA512 using your secret key. The signature is sent in the x-paystack-signature header. You must verify this signature to confirm the webhook actually came from Paystack.
Without signature verification, anyone who discovers your webhook URL could send fake events and trick your application into granting access or processing refunds.
The verification process:
x-paystack-signature header value.A common mistake is parsing the body as JSON first, then re-stringifying it. JSON.stringify(JSON.parse(body)) can reorder keys or change formatting, producing a different hash. Always use the raw, unparsed body.
Paystack may deliver the same webhook event more than once. Network timeouts, retries, and infrastructure issues can cause duplicates. Your handler must be idempotent: processing the same event twice should produce the same result as processing it once.
// Idempotent order fulfillment
if (eventType === 'charge.success') {
var reference = data.reference;
// Only update if not already paid
// var result = await db.orders.updateOne(
// { reference: reference, status: 'pending' },
// { $set: { status: 'paid', paidAt: data.paid_at } }
// );
// if (result.modifiedCount === 0) {
// // Already processed, skip fulfillment
// return { received: true };
// }
// First time processing, send confirmation email, grant access, etc.
}
The status: 'pending' condition in the database query ensures the update only happens once. If the order is already marked as paid, the query matches zero documents and the fulfillment logic is skipped.
During development, Paystack cannot reach localhost. You need a tunnel:
# Using ngrok
ngrok http 3000
# Using Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000
Copy the HTTPS URL (like https://abc123.ngrok-free.app) and set it as your webhook URL in the Paystack test dashboard:
https://abc123.ngrok-free.app/api/webhooks/paystack
Make a test payment. Check your Nuxt server console for the logged output from your webhook handler. Once you confirm it works, update the URL for production.
When deploying your Nuxt app, ensure the server routes work correctly:
PAYSTACK_SECRET_KEY in the Vercel environment variables.node .output/server/index.mjs. Set environment variables on the host.Set your production webhook URL to: https://yourdomain.com/api/webhooks/paystack
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