Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks Behind Nginx and a Reverse Proxy

When Paystack webhooks fail behind Nginx, the problem is almost always that Nginx modifies the request body (breaking HMAC verification), strips the X-Paystack-Signature header, or times out before your application responds. The fix is to configure Nginx to pass the body through unchanged with proxy_request_buffering on (the default), forward all headers including X-Paystack-Signature, and set proxy_read_timeout high enough for your handler to respond.

Why Reverse Proxies Break Webhook Signatures

When Paystack sends a webhook to your server, it computes an HMAC-SHA512 hash of the raw JSON body and puts it in the X-Paystack-Signature header. Your application receives the body, computes the same hash, and compares. If the body that reaches your application differs from the body Paystack signed, the hashes will not match and you will reject the webhook.

A reverse proxy sits between Paystack and your application. It receives the HTTP request, potentially modifies it, and forwards it to your upstream application. The modifications that cause signature failures include:

  • Body re-encoding: Some proxies decompress and recompress the body, changing the exact bytes. Nginx does not do this by default, but other proxies might.
  • Header stripping: The proxy might not forward the X-Paystack-Signature header, or it might change the header name to lowercase.
  • Chunked transfer encoding: The proxy might change how the body is transmitted (chunked vs content-length), which can confuse some application frameworks' body parsers.
  • Character set conversion: Rarely, a proxy might convert the body's character encoding, changing the raw bytes.

The good news is that Nginx, in its default configuration, does none of these destructive things. It passes the request body through byte-for-byte to the upstream. If your Paystack webhook signature is failing behind Nginx, the problem is usually in how Nginx is configured (not its defaults) or in how your application reads the body after Nginx forwards it.

Minimal Nginx Configuration for Paystack Webhooks

Here is a minimal, tested Nginx location block that correctly forwards Paystack webhook requests to a Node.js (or any other) backend:

server {
    listen 443 ssl;
    server_name api.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;

    location /webhooks/paystack {
        proxy_pass http://127.0.0.1:3000;

        # Forward all original headers (including X-Paystack-Signature)
        proxy_pass_request_headers on;

        # Pass the real client IP to the upstream
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;

        # Give the upstream time to respond
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;

        # Do not buffer the response (return upstream's response immediately)
        proxy_buffering off;
    }
}

This configuration does the following:

  1. proxy_pass_request_headers on: This is the default, but making it explicit ensures no other configuration block has turned it off. It forwards all headers from the original request, including X-Paystack-Signature.
  2. proxy_set_header X-Real-IP: Passes Paystack's real IP address to your upstream. Without this, your application sees 127.0.0.1 (the Nginx loopback address) as the client IP.
  3. proxy_read_timeout 30s: Gives your application 30 seconds to process the webhook and respond. The default is 60 seconds, which is usually fine, but explicitly setting it documents your intent.
  4. proxy_buffering off: Returns your application's response to Paystack immediately instead of buffering it in Nginx. Not strictly necessary for webhooks but reduces latency.

Request Body Buffering: What Actually Happens

Nginx has two relevant buffering settings: proxy_request_buffering (for the incoming request body) and proxy_buffering (for the upstream response). For webhooks, the request body buffering matters most.

proxy_request_buffering on (the default) means Nginx reads the entire request body from Paystack into memory (or to a temp file if it exceeds client_body_buffer_size), then sends it to your upstream in one piece. The body is not modified during this process. The bytes that enter Nginx are the same bytes that leave it.

proxy_request_buffering off means Nginx streams the body to your upstream as it receives it, chunk by chunk. This can be useful for large uploads but is unnecessary for Paystack webhooks (which are a few KB at most). It can also cause issues with some frameworks that expect the body to arrive all at once.

The key point: Nginx does not modify the request body during buffering. Buffering is just about when the body is forwarded (all at once vs streaming), not about changing its contents. If your HMAC verification is failing, buffering is not the culprit.

However, if your Nginx configuration sets an extremely small client_body_buffer_size (smaller than the webhook payload), Nginx will write the body to a temp file. On rare occasions, this can cause issues if the temp directory is on a filesystem with different encoding settings. The default buffer size is 8 KB on 32-bit systems and 16 KB on 64-bit systems, which is more than enough for any Paystack webhook payload. Leave it at the default unless you have a specific reason to change it.

Ensuring Headers Are Forwarded Correctly

Nginx forwards all request headers by default. However, there are two ways this can break:

1. Custom proxy_set_header directives override the defaults. When you add any proxy_set_header directive to a location block, Nginx stops inheriting the default header forwarding for that block. If you set proxy_set_header Host $host; without also preserving other headers, non-standard headers like X-Paystack-Signature might not be forwarded.

The fix is to make sure your location block does not accidentally suppress header forwarding. The proxy_pass_request_headers on; directive (which is the default) ensures all original headers are passed through. The proxy_set_header directives add or override specific headers on top of the originals; they do not remove other headers.

2. The underscores_in_headers directive. By default, Nginx silently drops headers with underscores in their names. The X-Paystack-Signature header uses hyphens, not underscores, so this is not a problem for Paystack specifically. But if you are debugging missing headers and some of your custom headers have underscores, add this to your http or server block:

underscores_in_headers on;

To verify that Nginx is forwarding the X-Paystack-Signature header correctly, add temporary debug logging in your application. Log all incoming headers and check that X-Paystack-Signature appears with the correct value. In Node.js:

app.post('/webhooks/paystack', (req, res) => {
  console.log('All headers:', JSON.stringify(req.headers));
  console.log(
    'Paystack signature:',
    req.headers['x-paystack-signature']
  );
  // ... rest of handler
});

Note that Node.js lowercases all header names. The header arrives as x-paystack-signature regardless of how Paystack or Nginx sent it. This is normal HTTP behavior and does not affect the signature value.

Timeout Configuration

Nginx has three timeout settings that can affect webhook delivery:

  • proxy_connect_timeout: How long Nginx waits to establish a connection to your upstream (default: 60s). If your application is not running, this timeout triggers and Paystack gets a 502.
  • proxy_send_timeout: How long Nginx waits when sending the request body to your upstream (default: 60s). For small webhook payloads, this is irrelevant.
  • proxy_read_timeout: How long Nginx waits for a response from your upstream (default: 60s). This is the one that matters most for webhooks.

If your webhook handler takes longer than proxy_read_timeout to respond, Nginx closes the connection and returns a 504 Gateway Timeout to Paystack. Paystack treats this as a delivery failure and will retry.

The fix depends on your handler's design:

  1. If your handler is fast (under 5 seconds): The default 60-second timeout is more than enough. No changes needed.
  2. If your handler does heavy processing: Either increase proxy_read_timeout or (better) redesign your handler to return 200 immediately and process asynchronously. See Why You Must Return 200 Immediately.
  3. If your upstream is occasionally slow to start (cold starts): Increase proxy_connect_timeout to account for startup time.
  4. location /webhooks/paystack {
        proxy_pass http://127.0.0.1:3000;
        proxy_connect_timeout 10s;
        proxy_send_timeout 10s;
        proxy_read_timeout 30s;
    }

    Set these values based on your actual handler performance. Do not set proxy_read_timeout to something enormous like 300 seconds "just in case." If your handler is taking that long, you have a design problem, not a timeout problem.

X-Forwarded Headers and IP Allowlisting

When your application sits behind Nginx, every incoming request appears to come from Nginx's IP address (usually 127.0.0.1 or the Docker network IP), not from Paystack's actual IP. If you want to implement IP allowlisting as an additional security layer, you need Nginx to forward Paystack's real IP address.

The standard approach uses X-Forwarded-For and X-Real-IP headers:

location /webhooks/paystack {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

$remote_addr is the IP address of the client connecting to Nginx, which in the case of a direct Paystack webhook is Paystack's server IP. $proxy_add_x_forwarded_for appends the client IP to any existing X-Forwarded-For header, which handles cases where there are multiple proxies in the chain.

In your application, read the X-Real-IP or X-Forwarded-For header instead of the direct connection IP:

function getClientIp(req) {
  return req.headers['x-real-ip']
    || (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
    || req.connection.remoteAddress;
}

Important: if your Nginx is behind a CDN (like Cloudflare) or a load balancer, the $remote_addr seen by Nginx is the CDN/load balancer IP, not Paystack's IP. In that case, you need to configure Nginx to trust the upstream proxy and extract the real client IP from the X-Forwarded-For chain. Use the set_real_ip_from and real_ip_header directives:

# Trust Cloudflare's IP ranges
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... (add all Cloudflare IP ranges)
real_ip_header X-Forwarded-For;
real_ip_recursive on;

For details on Paystack's IP ranges and the trade-offs of IP allowlisting, see Paystack Webhook IP Allowlisting.

Common Reverse Proxy Mistakes

Beyond the specific issues we have covered, here are mistakes that developers make when putting a webhook endpoint behind a reverse proxy:

Running a WAF that blocks Paystack requests. Web Application Firewalls (ModSecurity, Cloudflare WAF rules, AWS WAF) can flag Paystack's POST requests as suspicious. The JSON payload might match a SQL injection or XSS pattern. If you are using a WAF, add an exception for your webhook endpoint path so the WAF does not inspect or block webhook requests.

Rate limiting the webhook endpoint. If you have rate limiting configured at the Nginx level (using the limit_req module), make sure your webhook endpoint is excluded. Paystack can send multiple webhook events in quick succession (for example, during a bulk charge), and rate limiting could cause legitimate events to be rejected with 429 responses.

# Exclude webhook endpoint from rate limiting
location /webhooks/paystack {
    # No limit_req here
    proxy_pass http://127.0.0.1:3000;
}

location / {
    limit_req zone=general burst=20;
    proxy_pass http://127.0.0.1:3000;
}

SSL/TLS misconfiguration. Paystack sends webhooks over HTTPS. Your Nginx must have a valid SSL certificate that Paystack's servers can verify. Self-signed certificates will cause Paystack's request to fail before it even reaches your Nginx. Use Let's Encrypt for free, valid certificates. Certbot makes the process straightforward.

Serving the wrong server block. If you have multiple server blocks in your Nginx configuration, make sure the one handling your webhook domain has the correct server_name and SSL certificate. An incorrect server_name can cause Nginx to route the request to the default server block, which might not have the webhook location configured.

Testing the Full Proxy Chain

Before registering your webhook URL with Paystack, test the full chain from the outside to make sure everything works:

# 1. Generate a test signature
SECRET="your_webhook_secret"
BODY='{"event":"charge.success","data":{"reference":"test_123","amount":100000}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

# 2. Send a test request through your proxy
curl -v -X POST https://api.yourdomain.com/webhooks/paystack   -H "Content-Type: application/json"   -H "x-paystack-signature: $SIGNATURE"   -d "$BODY"

# The -v flag shows the full HTTP conversation,
# including any proxy-related headers in the response.

Check the following:

  • The response status is 200 (not 502, 504, or 403).
  • Your application logs show the event was received and the signature was verified.
  • The response comes back within a reasonable time (under 5 seconds).

If the signature verification fails, temporarily log the raw body and all headers in your application to see exactly what Nginx is forwarding. Compare the raw body in your application logs to the body you sent with curl. If they match, the proxy is working correctly and the problem is in your verification code. If they differ, something in the proxy chain is modifying the body.

Also check the Nginx error log (/var/log/nginx/error.log) and access log (/var/log/nginx/access.log) for any proxy-related errors. A 502 Bad Gateway in the error log usually means Nginx could not connect to your upstream application (it might not be running, or it might be listening on a different port).

Key Takeaways

  • Nginx passes the request body through unchanged by default. If your signature verification is failing, the problem is more likely in your application code (body parsing) than in Nginx itself.
  • The X-Paystack-Signature header must be forwarded to your upstream application. By default, Nginx forwards all headers, but custom proxy_set_header directives can accidentally suppress non-standard headers.
  • Set proxy_read_timeout to at least 30 seconds for your webhook location block. If Nginx times out before your app responds, Paystack gets a 504 and retries.
  • X-Forwarded-For and X-Real-IP headers help your application see Paystack's real IP address for IP allowlisting, since the connection comes from Nginx, not from Paystack directly.
  • Do not enable gzip compression on the proxy_pass to your webhook endpoint. Compressed responses are fine for browsers but can cause subtle issues with upstream body handling.
  • If you are using multiple Nginx instances or a CDN in front of Nginx, each layer can potentially modify headers or the body. Test the full chain, not just Nginx in isolation.

Frequently Asked Questions

Does Nginx modify the request body before forwarding it?
No. Nginx forwards the request body byte-for-byte to the upstream by default. The body buffering feature (proxy_request_buffering) controls when the body is forwarded (all at once vs streaming) but does not change the content. If your Paystack signature verification fails behind Nginx, the problem is almost always in your application code (body parsing) or in a non-default Nginx configuration, not in Nginx modifying the body.
Do I need to add X-Paystack-Signature to proxy_set_header?
No. Nginx forwards all request headers by default (proxy_pass_request_headers is on by default). You do not need to explicitly add X-Paystack-Signature to proxy_set_header. However, if you have custom proxy_set_header directives that override defaults, double-check that header forwarding is not being suppressed. Adding proxy_pass_request_headers on explicitly does not hurt.
What Nginx timeout should I set for webhook endpoints?
Set proxy_read_timeout to 30 seconds. This gives your application plenty of time to verify the signature and respond, even if it does a database write before responding. If your handler takes more than 30 seconds, you should redesign it to return 200 immediately and process asynchronously. The Nginx default of 60 seconds is also fine if you do not want to change it.
Can I use Nginx to reject webhook requests from non-Paystack IPs?
Yes. You can use the allow/deny directives in your webhook location block to restrict access to Paystack IP ranges. However, Paystack does not publish a guaranteed list of IP addresses, and their IPs may change without notice. Signature verification is the primary security mechanism; IP allowlisting is a defense-in-depth measure, not a replacement for signature verification.
Should I use Nginx stream module or http module for webhooks?
Use the http module. Paystack sends webhooks as standard HTTP POST requests, and the http module handles HTTP request/response semantics, header forwarding, and body buffering correctly. The stream module is for raw TCP/UDP proxying and does not understand HTTP semantics. Using stream for webhooks would strip all HTTP headers including X-Paystack-Signature.

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