Bonaventure OgetoBy Bonaventure Ogeto|

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 completed
  • transfer.success - A transfer was completed
  • transfer.failed - A transfer failed
  • subscription.create - A new subscription was created
  • invoice.payment_failed - A recurring charge failed
  • refund.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.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.
  • 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:

  1. Read the raw request body as a string (not parsed JSON).
  2. Compute HMAC SHA512 of that exact string using your Paystack secret key.
  3. Compare the computed hash with the x-paystack-signature header value.
  4. 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:

  1. Vercel: Server routes become serverless functions automatically. Set PAYSTACK_SECRET_KEY in the Vercel environment variables.
  2. Netlify: Use the Nitro Netlify preset. Server routes become Netlify Functions.
  3. Node.js server: Deploy with node .output/server/index.mjs. Set environment variables on the host.
  4. Webhook URL: Update the webhook URL in the Paystack live dashboard to your production domain.
  5. 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

Ship Payments Faster

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