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.
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:
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.
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:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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:
proxy_read_timeout or (better) redesign your handler to return 200 immediately and process asynchronously. See Why You Must Return 200 Immediately.proxy_connect_timeout to account for startup time.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.
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.
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.
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:
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).
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