To receive Paystack webhooks on AWS Lambda, configure API Gateway to pass the raw request body to your Lambda function by enabling binary media type passthrough or using Lambda proxy integration. In your Lambda handler, compute the HMAC-SHA512 hash of the raw body using your Paystack webhook secret, compare it to the X-Paystack-Signature header, and return a 200 response immediately after verification.
AWS Lambda does not directly expose an HTTP endpoint. You need API Gateway (or a Function URL) in front of it to receive HTTP requests from the internet. When Paystack sends a webhook, the request hits API Gateway first, which then invokes your Lambda function with the request data.
The typical architecture looks like this:
The danger is in step 2. API Gateway can modify the request body during transformation. If it parses the JSON, reformats whitespace, or re-encodes characters, the body you receive in Lambda will not match the body Paystack signed. Your HMAC verification will fail, and you will reject every single webhook.
You have two options to prevent this: Lambda proxy integration (recommended) or raw body passthrough with a mapping template. We will cover both, starting with the simpler approach.
Lambda proxy integration (also called "Use Lambda Proxy integration" in the API Gateway console) is the easiest way to get the raw body into your Lambda function. When enabled, API Gateway passes the entire HTTP request to Lambda as a structured JSON object without modifying the body.
The event object your Lambda receives looks like this:
{
"httpMethod": "POST",
"headers": {
"x-paystack-signature": "abc123...",
"content-type": "application/json"
},
"body": "{"event":"charge.success","data":{...}}",
"isBase64Encoded": false
}
The body field is a string containing the exact bytes Paystack sent. The isBase64Encoded field tells you whether the body has been Base64-encoded by API Gateway. For JSON payloads, this is typically false.
To set up Lambda proxy integration in the AWS Console:
/webhooks/paystack).If you are using AWS CDK, SAM, or the Serverless Framework, proxy integration is usually the default. For example, in SAM:
Resources:
PaystackWebhookFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs20.x
Events:
Webhook:
Type: Api
Properties:
Path: /webhooks/paystack
Method: post
SAM uses Lambda proxy integration by default when you define an Api event, so the raw body reaches your handler without modification.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
If you do not need the extra features of API Gateway (custom domains, request throttling, usage plans), you can skip it entirely and use Lambda Function URLs. This feature, introduced in 2022, gives your Lambda function a direct HTTPS endpoint without any intermediate service.
To enable a Function URL:
https://abc123.lambda-url.us-east-1.on.aws.The event object from a Function URL is slightly different from API Gateway's proxy integration, but the important fields are the same: body contains the raw request body, headers contains the HTTP headers, and isBase64Encoded tells you the encoding.
Function URLs are simpler but have limitations. You cannot use a custom domain without putting CloudFront in front of it. You cannot set up request validation or throttling at the gateway level. For a webhook endpoint that only needs to receive POSTs and verify signatures, these limitations rarely matter.
One important note: when you set auth type to "NONE," anyone on the internet can call your Function URL. This is fine because your signature verification ensures only Paystack-signed requests are processed. Unsigned requests get a 401 and are discarded.
Here is a production-ready Lambda handler for Paystack webhooks. It works with both API Gateway proxy integration and Lambda Function URLs:
// index.js (or index.mjs for ES modules)
const crypto = require('crypto');
exports.handler = async (event) => {
// Only accept POST requests
const method = event.httpMethod || event.requestContext?.http?.method;
if (method !== 'POST') {
return {
statusCode: 405,
body: JSON.stringify({ error: 'Method not allowed' }),
};
}
// Get the webhook secret
const secret = process.env.PAYSTACK_WEBHOOK_SECRET;
if (!secret) {
console.error('PAYSTACK_WEBHOOK_SECRET not configured');
return {
statusCode: 500,
body: JSON.stringify({ error: 'Server misconfigured' }),
};
}
// Get the signature header
// API Gateway lowercases headers; Function URLs preserve case
const headers = event.headers || {};
const signature =
headers['x-paystack-signature'] ||
headers['X-Paystack-Signature'];
if (!signature) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Missing signature' }),
};
}
// Get the raw body, handling Base64 encoding
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 signature received');
return {
statusCode: 401,
body: JSON.stringify({ error: 'Invalid signature' }),
};
}
// Parse the verified event
const payload = JSON.parse(rawBody);
console.log('Verified Paystack event: ' + payload.event);
// Process the event
try {
switch (payload.event) {
case 'charge.success':
await handleChargeSuccess(payload.data);
break;
case 'transfer.success':
await handleTransferSuccess(payload.data);
break;
case 'transfer.failed':
await handleTransferFailed(payload.data);
break;
default:
console.log('Unhandled event type: ' + payload.event);
}
} catch (err) {
console.error(
'Error processing event ' + payload.event + ':',
err
);
// Still return 200 to prevent Paystack retries
// Log the error for investigation
}
return {
statusCode: 200,
body: JSON.stringify({ received: true }),
};
};
async function handleChargeSuccess(data) {
console.log(
'Processing payment: ' + data.reference
+ ' Amount: ' + data.amount
+ ' Currency: ' + data.currency
);
// Your business logic: update order, send receipt, etc.
}
async function handleTransferSuccess(data) {
console.log(
'Transfer completed: ' + data.transfer_code
);
}
async function handleTransferFailed(data) {
console.log(
'Transfer failed: ' + data.transfer_code
+ ' Reason: ' + data.reason
);
}
Deploy this function with the PAYSTACK_WEBHOOK_SECRET environment variable set. The handler works regardless of whether the body arrives as raw UTF-8 or Base64-encoded, and it handles the header case differences between API Gateway and Function URLs.
If you are not using proxy integration (perhaps because you have an existing API Gateway setup with mapping templates), you need to configure binary media type passthrough to ensure the raw body reaches your Lambda function unchanged.
By default, API Gateway treats incoming requests as text and may modify the body during transformation. To force it to pass the body through without modification:
application/json.With this setting, API Gateway treats JSON payloads as binary data and Base64-encodes them before passing them to Lambda. Your event.body will be a Base64 string, and event.isBase64Encoded will be true. You decode it in your handler:
let rawBody = event.body;
if (event.isBase64Encoded) {
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
}
// rawBody is now the exact string Paystack sent
This approach is more complex than proxy integration and easier to misconfigure. If you are setting up a new API for Paystack webhooks, use proxy integration instead. Binary passthrough is primarily useful when you are adding a webhook endpoint to an existing API Gateway that already uses custom mapping templates for other routes.
For HTTP APIs (the newer, cheaper version of API Gateway), proxy integration is the only option, and the body always arrives as-is. If you have the choice, use an HTTP API instead of a REST API for webhook endpoints. HTTP APIs are cheaper, faster, and simpler to configure.
The most robust pattern for production Paystack webhooks on AWS is to separate receipt from processing. Your webhook Lambda does nothing but verify the signature and push the event to an SQS queue. A second Lambda (triggered by the queue) handles the actual business logic.
const crypto = require('crypto');
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({});
const QUEUE_URL = process.env.WEBHOOK_QUEUE_URL;
exports.handler = async (event) => {
const headers = event.headers || {};
const signature =
headers['x-paystack-signature'] ||
headers['X-Paystack-Signature'];
const secret = process.env.PAYSTACK_WEBHOOK_SECRET;
let rawBody = event.body || '';
if (event.isBase64Encoded) {
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
}
// Verify signature
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
return { statusCode: 401, body: 'Invalid signature' };
}
// Push to SQS
await sqs.send(
new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: rawBody,
MessageAttributes: {
EventType: {
DataType: 'String',
StringValue: JSON.parse(rawBody).event,
},
},
})
);
return {
statusCode: 200,
body: JSON.stringify({ received: true }),
};
};
This pattern has several advantages. The webhook handler is fast (typically under 100ms), so Paystack always gets a 200. SQS guarantees at-least-once delivery, so you will not lose events even if your processing Lambda crashes. You can set up a dead-letter queue on SQS for events that fail processing repeatedly. And you can scale the processing Lambda independently from the webhook receiver.
The downside is additional AWS services to configure and pay for. For a startup processing fewer than 1,000 transactions per day, the simple "process in the webhook handler" approach is fine. Move to SQS when you need the reliability guarantees or when your processing takes more than a few seconds.
Putting your Paystack webhook secret in a Lambda environment variable works, but it is not the most secure option. Anyone with access to your Lambda configuration in the AWS Console can see the secret in plaintext. For production systems handling real money, use AWS Secrets Manager or Systems Manager Parameter Store.
With Parameter Store (the cheaper option):
// Store the secret (run once, via AWS CLI)
// aws ssm put-parameter --name /paystack/webhook-secret
// --value "your_secret" --type SecureString
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const ssm = new SSMClient({});
let cachedSecret = null;
async function getWebhookSecret() {
if (cachedSecret) return cachedSecret;
const result = await ssm.send(
new GetParameterCommand({
Name: '/paystack/webhook-secret',
WithDecryption: true,
})
);
cachedSecret = result.Parameter.Value;
return cachedSecret;
}
The secret is cached in the Lambda execution context so you only fetch it once per cold start, not on every invocation. This adds a few milliseconds to the first invocation but nothing on subsequent ones.
Make sure your Lambda's execution role has ssm:GetParameter permission for the parameter path, and kms:Decrypt permission if you used a custom KMS key for encryption.
Lambda automatically sends logs to CloudWatch. Every console.log and console.error in your handler appears in the log group /aws/lambda/your-function-name. For webhook debugging, log these things at minimum:
Do not log the full raw body in production. It contains customer data (email, possibly card details) that you should not store in logs. Log only the fields you need for debugging.
Set up a CloudWatch Alarm for Lambda errors. If your webhook handler starts throwing errors, you want to know immediately, not when customers start complaining about unfulfilled orders. A simple alarm on the "Errors" metric with a threshold of 1 error in 5 minutes is a good starting point.
If you are using API Gateway, also monitor the 4XX and 5XX error rates at the gateway level. A spike in 4XX errors might mean Paystack changed something about their request format, or your secret rotated without updating the Lambda environment. A spike in 5XX errors usually means your Lambda is crashing.
For deeper observability, add AWS X-Ray tracing to your Lambda function. X-Ray shows you exactly how long each step takes (API Gateway latency, Lambda cold start, database calls, SQS operations) and helps you pinpoint bottlenecks.
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