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.
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:
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
Before going live with Paystack webhooks on Netlify, verify each of these:
/.netlify/functions/ prefix (or your redirect path) in the Paystack dashboard.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.
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