Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks on Netlify Functions

To receive Paystack webhooks on Netlify Functions, create a function in your netlify/functions directory that reads event.body (the raw request body string), verifies the X-Paystack-Signature header using HMAC-SHA512 with your webhook secret, returns a 200 status code immediately, and optionally offloads heavy processing to a Netlify Background Function.

Why Netlify Functions Work for Paystack Webhooks

Netlify Functions are AWS Lambda functions with a simpler deployment model. You drop a file in your netlify/functions directory, push to Git, and Netlify deploys it as a serverless endpoint. No AWS console, no CloudFormation templates, no API Gateway configuration. The function gets a URL like https://your-site.netlify.app/.netlify/functions/paystack-webhook, and that URL can receive POST requests from Paystack.

For Paystack webhook handling, Netlify Functions have a particular advantage: the event.body property gives you the raw request body as a string by default. This means you do not need to disable body parsing or jump through hoops to get the untouched payload for signature verification. The body comes in exactly as Paystack sent it.

The main constraints to know about:

  • Standard Netlify Functions have a 10-second execution timeout on the free tier and up to 26 seconds on paid plans.
  • Background Functions (available on paid plans) can run for up to 15 minutes, which gives you room for complex processing.
  • You get 125,000 function invocations per month on the free tier. For most African startups processing payments, that is more than enough.

Project Setup and File Structure

Netlify Functions live in the netlify/functions directory at the root of your project (or you can configure a different path in netlify.toml). Each file in that directory becomes a function endpoint.

Your project structure should look like this:

your-project/
  netlify/
    functions/
      paystack-webhook.ts
  netlify.toml
  package.json

In your netlify.toml, you can optionally configure the functions directory and node version:

[build]
  functions = "netlify/functions"

[functions]
  node_bundler = "esbuild"

Install the Netlify Functions types for TypeScript support:

npm install --save-dev @netlify/functions

The function file name determines the URL path. A file named paystack-webhook.ts becomes available at /.netlify/functions/paystack-webhook. This is the URL you will register in your Paystack dashboard as your webhook endpoint.

If you want a cleaner URL, you can use Netlify redirects. Add this to your netlify.toml:

[[redirects]]
  from = "/api/webhooks/paystack"
  to = "/.netlify/functions/paystack-webhook"
  status = 200

Now Paystack can send webhooks to https://your-site.netlify.app/api/webhooks/paystack, which is easier to read in your dashboard.

Understanding event.body in Netlify Functions

Every Netlify Function receives an event object as its first argument. This object contains the HTTP request data. The properties you care about for webhook handling are:

  • event.body: The request body as a string. This is the raw payload Paystack sent.
  • event.headers: An object containing all HTTP headers. Header names are lowercased by Netlify.
  • event.httpMethod: The HTTP method (GET, POST, etc.).
  • event.isBase64Encoded: Boolean indicating whether the body is Base64-encoded. For Paystack webhooks, this is typically false.

The critical detail: event.body is already a string. You do not need to convert it from a Buffer or stream. You do not need to disable any body parser. Netlify hands you the raw payload as-is. This is exactly what you need for HMAC signature verification.

However, there is one edge case. If event.isBase64Encoded is true, you need to decode the body before hashing it:

function getBody(event: any): string {
  if (event.isBase64Encoded && event.body) {
    return Buffer.from(event.body, 'base64').toString('utf8');
  }
  return event.body || '';
}

In practice, Paystack sends JSON with a Content-Type: application/json header, and Netlify does not Base64-encode JSON bodies. But handling the edge case costs you two lines of code and prevents a subtle bug if Netlify's behavior ever changes.

Complete Webhook Handler

Here is a production-ready Paystack webhook handler for Netlify Functions. Copy it into your project, set the environment variable, and deploy:

// netlify/functions/paystack-webhook.ts
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions';
import crypto from 'crypto';

const handler: Handler = async (
  event: HandlerEvent,
  context: HandlerContext
) => {
  // Only accept POST requests
  if (event.httpMethod !== 'POST') {
    return {
      statusCode: 405,
      body: JSON.stringify({ error: 'Method not allowed' }),
    };
  }

  const secret = process.env.PAYSTACK_WEBHOOK_SECRET;
  if (!secret) {
    console.error('PAYSTACK_WEBHOOK_SECRET is not set');
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Server misconfigured' }),
    };
  }

  // Get signature from headers (Netlify lowercases header names)
  const signature = event.headers['x-paystack-signature'];
  if (!signature) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Missing signature' }),
    };
  }

  // Get the raw body
  let rawBody = event.body || '';
  if (event.isBase64Encoded) {
    rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
  }

  // Verify HMAC-SHA512 signature
  const hash = crypto
    .createHmac('sha512', secret)
    .update(rawBody)
    .digest('hex');

  if (hash !== signature) {
    console.warn('Invalid Paystack webhook signature');
    return {
      statusCode: 401,
      body: JSON.stringify({ error: 'Invalid signature' }),
    };
  }

  // Parse the verified payload
  const payload = JSON.parse(rawBody);
  const eventType = payload.event;
  const data = payload.data;

  console.log('Received Paystack event: ' + eventType);

  // Handle the event
  switch (eventType) {
    case 'charge.success':
      console.log(
        'Payment successful: ' + data.reference
        + ' Amount: ' + data.amount
      );
      // Your payment fulfillment logic here
      break;

    case 'transfer.success':
      console.log(
        'Transfer completed: ' + data.transfer_code
      );
      break;

    case 'transfer.failed':
      console.log(
        'Transfer failed: ' + data.transfer_code
      );
      break;

    case 'refund.processed':
      console.log(
        'Refund processed: ' + data.transaction_reference
      );
      break;

    default:
      console.log('Unhandled event type: ' + eventType);
  }

  return {
    statusCode: 200,
    body: JSON.stringify({ received: true }),
  };
};

export { handler };

Set the environment variable in the Netlify dashboard: go to Site Settings, then Environment Variables, and add PAYSTACK_WEBHOOK_SECRET with your webhook secret from the Paystack dashboard.

Using Background Functions for Heavy Processing

If your webhook handler needs to do more than a quick database write (for example, sending emails, updating multiple tables, calling external APIs, or generating invoices), you risk hitting the 10-second timeout. Netlify Background Functions solve this problem.

A Background Function is a Netlify Function that returns a 202 response immediately and continues processing in the background for up to 15 minutes. The catch: the caller (Paystack, in this case) gets a 202 instead of a 200. Paystack expects a 200, so you cannot make your webhook handler itself a Background Function.

The pattern is: create a standard function for the webhook that does signature verification and returns 200, then have it invoke a background function for the heavy work.

// netlify/functions/paystack-webhook.ts
import type { Handler } from '@netlify/functions';
import crypto from 'crypto';
import fetch from 'node-fetch';

const handler: Handler = async (event) => {
  if (event.httpMethod !== 'POST') {
    return { statusCode: 405, body: 'Method not allowed' };
  }

  const secret = process.env.PAYSTACK_WEBHOOK_SECRET;
  const signature = event.headers['x-paystack-signature'];
  const rawBody = event.body || '';

  if (!secret || !signature) {
    return { statusCode: 400, body: 'Bad request' };
  }

  const hash = crypto
    .createHmac('sha512', secret)
    .update(rawBody)
    .digest('hex');

  if (hash !== signature) {
    return { statusCode: 401, body: 'Invalid signature' };
  }

  // Invoke the background function
  const siteUrl = process.env.URL || 'http://localhost:8888';
  fetch(
    siteUrl + '/.netlify/functions/process-payment-background',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: rawBody,
    }
  ).catch((err) =>
    console.error('Failed to invoke background function:', err)
  );

  // Return 200 immediately
  return {
    statusCode: 200,
    body: JSON.stringify({ received: true }),
  };
};

export { handler };
// netlify/functions/process-payment-background.ts
// Note: suffix "-background" makes this a Background Function
import type { Handler } from '@netlify/functions';

const handler: Handler = async (event) => {
  const payload = JSON.parse(event.body || '{}');

  console.log('Processing event in background: ' + payload.event);

  // Do heavy processing here:
  // - Update database
  // - Send confirmation email
  // - Generate invoice
  // - Notify external systems

  return { statusCode: 200, body: 'Processed' };
};

export { handler };

The file naming convention matters. Any function file ending in -background (like process-payment-background.ts) is automatically treated as a Background Function by Netlify. No additional configuration needed.

Background Functions are available on Netlify's Pro plan and above. On the free plan, you will need to keep all processing within the standard function timeout.

Testing Locally with Netlify CLI

Netlify provides a CLI that emulates the serverless function environment locally. Install it globally:

npm install -g netlify-cli

Then start the development server:

netlify dev

This starts a local server (usually on port 8888) that serves your functions at the same paths they will have in production. Your webhook handler will be available at http://localhost:8888/.netlify/functions/paystack-webhook.

To test signature verification locally, you need to simulate a signed Paystack webhook. Here is a quick script that generates a properly signed payload and sends it to your local function:

// test-webhook.js
const crypto = require('crypto');

const secret = 'your_test_webhook_secret';
const payload = JSON.stringify({
  event: 'charge.success',
  data: {
    id: 123456789,
    reference: 'test_ref_' + Date.now(),
    amount: 500000,
    currency: 'NGN',
    status: 'success',
    customer: {
      email: 'customer@example.com',
    },
  },
});

const hash = crypto
  .createHmac('sha512', secret)
  .update(payload)
  .digest('hex');

fetch('http://localhost:8888/.netlify/functions/paystack-webhook', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-paystack-signature': hash,
  },
  body: payload,
})
  .then((res) => res.json())
  .then((data) => console.log('Response:', data))
  .catch((err) => console.error('Error:', err));

For testing with actual Paystack events, use ngrok or Cloudflare Tunnel to expose your local Netlify dev server to the internet, then register that tunnel URL in your Paystack test mode dashboard.

Deployment Checklist

Before going live with Paystack webhooks on Netlify, verify each of these:

  1. Environment variable set. PAYSTACK_WEBHOOK_SECRET is configured in Netlify's Site Settings > Environment Variables for the production context.
  2. Function deploys successfully. Check the Netlify deploy log for your function. It should appear in the Functions tab of your Netlify dashboard after deployment.
  3. URL is correct in Paystack. Register the full URL including the /.netlify/functions/ prefix (or your redirect path) in the Paystack dashboard.
  4. Test with a real Paystack event. Make a test-mode payment and verify the webhook is received. Check Netlify's Function Logs for the event.
  5. Error handling is solid. Your handler should never throw an unhandled exception. Wrap all processing in try/catch blocks. A thrown exception returns a 500, which triggers Paystack retries.
  6. Idempotency is implemented. Paystack may send the same event more than once. Your handler should check whether it has already processed a given transaction reference before granting value. See Idempotent Webhook Handlers for the pattern.

Netlify's Function Logs show the last 24 hours of function invocations with their output. For longer retention, consider logging to an external service like Datadog, LogDNA, or even a simple append-only database table.

Key Takeaways

  • Netlify Functions give you the raw request body directly in event.body as a string. Unlike some other serverless platforms, there is no auto-parsing problem to work around.
  • Signature verification uses the same HMAC-SHA512 approach on every platform. On Netlify, you use the Node.js crypto module against event.body.
  • Netlify Background Functions let you run processing for up to 15 minutes without blocking the webhook response. This is ideal for payment processing that involves multiple API calls.
  • The function must return a 200 status code within 10 seconds (standard functions) or 26 seconds (Netlify Edge Functions). Paystack will retry if it does not get a 200.
  • Environment variables in Netlify are set through the site dashboard under Site Settings > Environment Variables. Never commit your webhook secret to source code.
  • Netlify Functions support TypeScript out of the box. No extra build configuration is needed.

Frequently Asked Questions

Does Netlify give me the raw request body or does it parse JSON automatically?
Netlify gives you the raw body as a string in event.body. It does not automatically parse JSON into a JavaScript object. This is actually an advantage for webhook handling because you need the raw string for HMAC signature verification. You parse it yourself with JSON.parse after verifying the signature.
Can I use Netlify Edge Functions for Paystack webhooks?
Netlify Edge Functions run on Deno, not Node.js. They do not have access to the Node.js crypto module. You would need to use the Web Crypto API for HMAC-SHA512 verification, which is more verbose. Standard Netlify Functions (which run on Node.js via AWS Lambda) are a better fit for Paystack webhooks.
What happens if my Netlify function times out?
Paystack receives no response (or a 502/504 error from Netlify). Paystack treats this as a failure and will retry the webhook. If retries keep failing, Paystack may eventually disable your webhook URL. The fix is to keep your handler fast: verify the signature, do a quick database write, return 200, and offload heavy processing to a Background Function or queue.
How many Netlify Function invocations does a typical Paystack integration use?
Each webhook event from Paystack is one function invocation. If you process 100 payments per day and each payment triggers one charge.success event, that is about 3,000 invocations per month. The Netlify free tier gives you 125,000 invocations per month, so you would need to process over 4,000 payments per day to exceed the free tier. Most African startups are well within these limits.
Can I receive webhooks from multiple Paystack accounts on the same Netlify site?
Yes, but you need to verify signatures correctly. Each Paystack account has its own webhook secret. You can create separate webhook functions for each account, each with its own secret in a different environment variable. Alternatively, you can try verifying against multiple secrets in a single handler, but separate functions are cleaner.

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