Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks on AWS Lambda and API Gateway

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.

How Lambda and API Gateway Fit Together for Webhooks

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:

  1. Paystack sends a POST request to your API Gateway URL.
  2. API Gateway receives the request and transforms it into a Lambda event object.
  3. Lambda receives the event, verifies the signature, processes the webhook, and returns a response.
  4. API Gateway translates the Lambda response back into an HTTP response for Paystack.

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: The Simple Path

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:

  1. Create a new REST API in API Gateway (or use an existing one).
  2. Create a new resource (e.g., /webhooks/paystack).
  3. Create a POST method on that resource.
  4. Set the integration type to "Lambda Function."
  5. Check the "Use Lambda Proxy integration" checkbox.
  6. Select your Lambda function.
  7. Deploy the API to a stage (e.g., "prod").

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.

Alternative: Lambda Function URLs (No API Gateway Needed)

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:

  1. Go to your Lambda function in the AWS Console.
  2. Click "Configuration" then "Function URL."
  3. Click "Create function URL."
  4. Set the auth type to "NONE" (Paystack cannot authenticate with IAM).
  5. Save. You get a URL like 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.

Complete Lambda Webhook Handler

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.

Binary Passthrough for Raw Body (API Gateway REST API)

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:

  1. In the API Gateway console, go to your API's Settings.
  2. Under "Binary Media Types," add application/json.
  3. Save and redeploy your API.

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.

Async Processing: Push to SQS for Reliability

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.

Storing Your Webhook Secret Securely on AWS

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.

Monitoring and Troubleshooting

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:

  • The event type received (e.g., "charge.success")
  • The transaction reference (for correlating with your database)
  • Whether signature verification passed or failed
  • Any errors during processing

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.

Key Takeaways

  • API Gateway can silently modify the request body before it reaches your Lambda function. You must use proxy integration or configure binary passthrough to preserve the raw body for signature verification.
  • Lambda proxy integration is the simplest setup. It passes the full HTTP request (headers, body, method) to your Lambda as a structured event object with the body as a string.
  • If the body arrives Base64-encoded (isBase64Encoded is true), decode it before computing the HMAC hash. This happens when API Gateway is configured for binary passthrough.
  • AWS Lambda has a 15-minute maximum execution time, far more generous than most serverless platforms. But you should still return 200 fast and process asynchronously.
  • For production systems, invoke a second Lambda asynchronously or push to SQS for processing. This separates webhook receipt from business logic.
  • Store your Paystack webhook secret in AWS Systems Manager Parameter Store or Secrets Manager, not in environment variables in plaintext.

Frequently Asked Questions

Should I use API Gateway REST API or HTTP API for Paystack webhooks?
Use HTTP API if you are starting fresh. HTTP APIs are cheaper (up to 71% less), faster (lower latency), and simpler to configure. They always use proxy integration, which means the raw body reaches your Lambda without modification. REST APIs are only necessary if you need features like request validation, usage plans, or API keys, which are not relevant for a webhook endpoint.
Can I use a Lambda Function URL instead of API Gateway?
Yes. Lambda Function URLs give your function a direct HTTPS endpoint without API Gateway. This is the simplest setup for a webhook endpoint. The main limitation is that you cannot use a custom domain without putting CloudFront in front of it. If you are fine with the auto-generated URL, Function URLs work perfectly for Paystack webhooks.
Why does my signature verification fail on Lambda but work locally?
The most common cause is API Gateway modifying the request body before it reaches Lambda. If you are not using proxy integration, the body may be re-serialized or transformed by a mapping template. Switch to proxy integration or Lambda Function URLs to ensure the raw body arrives unchanged. Also check that event.isBase64Encoded is handled correctly in your code.
What is the maximum payload size Lambda can receive?
With API Gateway, the maximum payload size is 10 MB for REST APIs and 10 MB for HTTP APIs. Lambda Function URLs support up to 6 MB. Paystack webhook payloads are typically a few kilobytes, so you will never hit these limits with normal payment events.
How do I handle Lambda cold starts for webhooks?
Lambda cold starts add 100ms to a few seconds of latency on the first invocation after an idle period. For webhooks, this is usually not a problem because Paystack waits several seconds for a response before retrying. If you want to eliminate cold starts entirely, use Lambda Provisioned Concurrency, but this adds cost. For most Paystack integrations, cold starts are not worth worrying about.

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