Handle Paystack Webhooks in Nuxt
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.
How Paystack Webhooks Work
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
The Webhook Server Route
// 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:
- The
.post.tssuffix ensures only POST requests reach this handler. readRawBodyreads the body as a string before any JSON parsing, which is required for accurate HMAC verification.- The signature check runs before any event processing. If the signature does not match, the handler rejects the request immediately.
Signature Verification Deep Dive
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:
- Read the raw request body as a string (not parsed JSON).
- Compute HMAC SHA512 of that exact string using your Paystack secret key.
- Compare the computed hash with the
x-paystack-signatureheader value. - If they match, the webhook is legitimate.
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.
Process Events Idempotently
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.
Testing Webhooks Locally
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.
Deployment Considerations
When deploying your Nuxt app, ensure the server routes work correctly:
- Vercel: Server routes become serverless functions automatically. Set
PAYSTACK_SECRET_KEYin the Vercel environment variables. - Netlify: Use the Nitro Netlify preset. Server routes become Netlify Functions.
- Node.js server: Deploy with
node .output/server/index.mjs. Set environment variables on the host. - Webhook URL: Update the webhook URL in the Paystack live dashboard to your production domain.
- Response time: Paystack expects a response within 30 seconds. If your event processing is slow, return 200 immediately and process asynchronously (use a queue or background job).
Set your production webhook URL to: https://yourdomain.com/api/webhooks/paystack
Key Takeaways
- ✓Nuxt server routes can receive Paystack webhooks. Create a .post.ts file to restrict to POST requests only.
- ✓Read the raw request body for signature verification. Do not parse it as JSON first.
- ✓Compute the HMAC SHA512 hash of the raw body using your Paystack secret key and compare it to the x-paystack-signature header.
- ✓Return 200 immediately after processing. Paystack retries if your endpoint returns anything other than 2xx within 30 seconds.
- ✓Process webhooks idempotently. Paystack may send the same event more than once.
- ✓In production, your Nuxt app needs a publicly accessible HTTPS URL for Paystack to reach your webhook endpoint.
Frequently Asked Questions
- Can I handle Paystack webhooks in Nuxt without a separate backend?
- Yes. Nuxt server routes run on the server and can receive HTTP POST requests from Paystack. You do not need Express, Django, or any separate backend. Everything lives in your Nuxt project.
- How do I read the raw body in a Nuxt server route?
- Use readRawBody(event) from the h3 library (included with Nuxt). This returns the body as a string before JSON parsing, which is required for HMAC signature verification.
- What happens if my webhook endpoint returns a 500 error?
- Paystack retries the webhook delivery. It makes multiple attempts with increasing intervals over several hours. Fix the error and the next retry will succeed. Your endpoint should always return 200 after processing to stop retries.
- Can I test Paystack webhooks on localhost?
- Not directly. Use ngrok or Cloudflare Tunnel to create a public HTTPS URL that tunnels to your local Nuxt dev server. Set that URL as the webhook URL in the Paystack test dashboard.
- Should I process webhook events synchronously or asynchronously?
- For simple operations like updating a database record, synchronous processing is fine. For slow operations like sending emails or generating PDFs, return 200 immediately and process the event in a background job or queue to avoid timeouts.
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