Paystack webhooks are HTTP POST requests that Paystack sends to a URL you configure whenever an event happens on your account. Each request carries a JSON payload describing the event and an x-paystack-signature header containing an HMAC SHA512 hash of the body, signed with your secret key. Your server must verify this signature, return 200 immediately, and then process the event.
Paystack webhooks are event-driven notifications. When something happens on your Paystack account (a charge succeeds, a transfer fails, a subscription renews), Paystack makes an HTTP POST request to a URL you have registered. The request body is JSON. The content type is application/json.
This is the opposite of polling. Instead of your server repeatedly asking Paystack "did anything happen yet?", Paystack tells you the moment something does. That distinction matters because payment events are time-sensitive. A customer who just paid expects their account to update within seconds, not whenever your next polling cycle runs.
Every webhook request includes:
event field (like charge.success) and a data object containing the full transaction, transfer, or subscription details.x-paystack-signature header containing an HMAC SHA512 hash of the request body, signed with your Paystack secret key.Here is what a typical webhook payload looks like for a successful charge:
{
"event": "charge.success",
"data": {
"id": 1234567890,
"domain": "live",
"status": "success",
"reference": "your-unique-ref-abc123",
"amount": 50000,
"currency": "NGN",
"channel": "card",
"customer": {
"email": "customer@example.com",
"customer_code": "CUS_xxxxxxxxx"
},
"paid_at": "2026-07-20T10:30:00.000Z",
"metadata": {
"order_id": "ORD-789"
}
}
}
The amount field is always in the smallest currency unit. For NGN, that means kobo. 50000 kobo = 500 Naira. For GHS, it is pesewas. For KES, it is cents. For ZAR, it is cents. This catches people off guard, so check it early.
If you are coming from a callback-based flow and wondering why webhooks matter, the short version: callbacks depend on the customer's browser staying open and redirecting back to your site. Webhooks come server-to-server. The customer can close their browser, lose their connection, or throw their phone into Lake Victoria. The webhook still arrives. Read more about this in why webhooks beat callbacks for Paystack payments.
This guide is part of the complete Paystack engineering guide.
You configure your webhook URL in the Paystack Dashboard under Settings > API Keys & Webhooks. There is one URL field. Every event type goes to that single URL.
Requirements:
A few practical notes:
"domain": "test" in the payload, while live events carry "domain": "live".For local development, you will need a tunnel to expose your machine to the internet. We cover that in the testing section below.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
This is the most important part of your webhook handler. The x-paystack-signature header proves the request actually came from Paystack and was not tampered with in transit. If you skip this check, anyone who discovers your webhook URL can send fake payment confirmations and your system will happily grant value for payments that never happened.
The verification process:
x-paystack-signature header.Here is the Node.js (Express) implementation:
const crypto = require('crypto');
const express = require('express');
const app = express();
// You MUST read the raw body before any JSON parsing middleware.
// Use express.raw() on just the webhook route.
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body) // req.body is a Buffer here, not a parsed object
.digest('hex');
if (hash !== signature) {
// Signature mismatch. This request did not come from Paystack.
return res.status(400).send('Invalid signature');
}
// Signature valid. Parse the body and process the event.
const event = JSON.parse(req.body.toString());
console.log('Received event:', event.event);
// Return 200 immediately. Do heavy work in a queue.
res.status(200).send('OK');
// Then queue the event for processing
// processWebhookEvent(event);
}
);
And the Python (Flask) version:
import hmac
import hashlib
import json
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/paystack', methods=['POST'])
def paystack_webhook():
secret = os.environ['PAYSTACK_SECRET_KEY'].encode('utf-8')
signature = request.headers.get('X-Paystack-Signature', '')
computed = hmac.new(
secret,
msg=request.get_data(), # raw bytes, not parsed JSON
digestmod=hashlib.sha512
).hexdigest()
if computed != signature:
return 'Invalid signature', 400
event = json.loads(request.get_data())
print(f"Received event: {event['event']}")
return 'OK', 200
The critical detail in both examples: you compute the hash over the raw bytes, not a re-serialized JSON object. If your framework parses the JSON body and you then JSON.stringify it back, the whitespace or key ordering might differ from what Paystack sent, and the signature will not match. This is the single most common webhook bug. See the raw body problem for a deeper explanation and framework-specific fixes.
Paystack sends many event types. Most applications only need a few. Here are the ones you are most likely to handle:
Payment events
charge.success — A payment was completed. This is the primary event for most integrations. When you receive this, verify the amount and currency, then grant value. See charge.success: the only event most apps actually need.Transfer events
transfer.success — A transfer (payout) to a bank account was completed.transfer.failed — A transfer failed. The money stays in your Paystack balance.transfer.reversed — A transfer that initially succeeded was reversed by the bank.If your app sends money out (vendor payouts, withdrawals), you need all three. See handling transfer events.
Subscription events
subscription.create — A new subscription was created.subscription.disable — A subscription was cancelled.subscription.not_renew — An upcoming renewal will not be charged (the customer cancelled before the next cycle).invoice.create — An invoice was generated for an upcoming subscription charge.invoice.payment_failed — A subscription renewal charge failed.invoice.update — An invoice was updated (usually after a successful charge).See handling subscription and invoice events.
Refund events
refund.processed — A refund was completed.refund.failed — A refund attempt failed.Other events you may need
dedicatedaccount.assign.success — A dedicated virtual account was assigned to a customer. See handling dedicated account events.customeridentification.success / customeridentification.failed — KYC identity verification completed or failed. See handling customer identification events.dispute.create / dispute.remind — A chargeback dispute was opened or a reminder was sent. See handling dispute events.For a full reference of every event type Paystack can send, see every Paystack webhook event explained.
Your webhook endpoint must return a 200 status code quickly. Paystack expects a response within a few seconds. If your endpoint takes too long or returns a non-2xx status, Paystack treats the delivery as failed and will retry.
This means you should never do heavy processing inside the webhook handler itself. Do not send emails, update multiple database tables, call third-party APIs, or generate PDFs before responding. All of that takes time, and if any of it fails or times out, Paystack thinks you did not receive the event.
The correct pattern:
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature (same as above, omitted for brevity)
// 2. Return 200 FIRST
res.status(200).send('OK');
// 3. Queue the work
const event = JSON.parse(req.body.toString());
await webhookQueue.add('paystack-event', {
event: event.event,
data: event.data,
receivedAt: new Date().toISOString(),
});
}
);
If you cannot set up a queue right away, at minimum write the raw payload to a database row and have a separate process pick it up. The point is: acknowledge receipt before doing anything that could fail or take time.
For a deeper look at why this matters and what happens when you break this rule, see why you must return 200 immediately. For queue-specific guides, see the BullMQ, Celery, and Laravel Queues articles linked in the cluster overview below.
Paystack can send the same event more than once. This happens during retries, network hiccups, or internal recovery processes. If your handler grants value every time it receives a charge.success for the same reference, you will credit a customer's account twice (or more) for a single payment.
The fix is idempotency: make your handler produce the same result whether it processes an event once or five times.
The simplest approach: use the transaction reference as a deduplication key.
async function processChargeSuccess(data) {
const { reference, amount, currency } = data;
// Check if we already processed this reference
const existing = await db.query(
'SELECT id FROM processed_webhooks WHERE reference = $1',
[reference]
);
if (existing.rows.length > 0) {
console.log(`Already processed reference ${reference}, skipping.`);
return;
}
// Process the payment (credit the user, fulfill the order, etc.)
await db.query('BEGIN');
try {
await db.query(
'INSERT INTO processed_webhooks (reference, event, processed_at) VALUES ($1, $2, NOW())',
[reference, 'charge.success']
);
await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE payment_reference = $2',
['paid', reference]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
The key detail: the insert into processed_webhooks and the business logic update happen in the same database transaction. If the order update fails, the deduplication record also rolls back, so a retry will correctly try again. If both succeed, a duplicate event will hit the early return.
You can also add a unique constraint on the reference column in processed_webhooks so that a duplicate insert fails at the database level even if your application-level check has a race condition.
For the full pattern with edge cases, see idempotent Paystack webhook handlers.
Your local machine is not reachable from the internet. Paystack cannot POST to http://localhost:3000. You need a tunnel that gives your local server a public URL.
Option 1: ngrok
# Install ngrok, then:
ngrok http 3000
# Output:
# Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
Copy the https:// URL and paste it into the Paystack Dashboard as your webhook URL (add your path, e.g., https://abc123.ngrok-free.app/webhooks/paystack). See testing with ngrok for the full walkthrough.
Option 2: Cloudflare Tunnel
# Install cloudflared, then:
cloudflared tunnel --url http://localhost:3000
# Output:
# Your quick tunnel: https://random-words.trycloudflare.com
Same idea. Copy the URL, set it in the dashboard. No account required for quick tunnels. See testing with Cloudflare Tunnel.
Option 3: Simulate locally without a tunnel
If you do not want to route live Paystack events to your machine, you can simulate webhook requests yourself using curl or a test script:
SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000,"currency":"NGN","status":"success"}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/paystack \
-H "Content-Type: application/json" \
-H "x-paystack-signature: $SIGNATURE" \
-d "$BODY"
This generates a valid signature for your test secret key, so your verification code will pass. It is useful for unit testing and CI pipelines where you do not have a public URL at all. See simulating webhook events for automated tests and testing in CI without a public URL for the full approach.
For more general testing strategies, see the Paystack test mode guide.
When your webhook stops working, it is almost always one of these problems:
1. Signature verification fails because the body was parsed
This is the most common bug by far. Frameworks like Express (with express.json()), Django, Laravel, and Rails parse the request body into an object automatically. When you try to verify the signature, you are hashing a re-serialized version of the body, not the original bytes Paystack sent. The hashes will never match.
The fix: read the raw body before any middleware parses it. In Express, use express.raw() on the webhook route. In Django, use request.body. In Laravel, use file_get_contents('php://input'). See the raw body problem for framework-specific solutions.
2. Reverse proxy modifies the body
If you are behind Nginx, Apache, or a CDN, the proxy might modify the request body (gzip re-encoding, character set conversion, or adding/removing whitespace). The body that reaches your application is different from what Paystack signed. The fix depends on your proxy config. See webhooks behind Nginx and a reverse proxy.
3. CSRF protection blocks the POST
Django, Laravel, and Rails all have CSRF protection enabled by default. Paystack's webhook POST will not carry a CSRF token, so the framework rejects it with a 403 or 419 before your handler even runs.
@csrf_exempt. See webhooks and CSRF in Django.$except array in VerifyCsrfToken middleware. See webhooks and CSRF in Laravel.verify_authenticity_token callback for the webhook action. See webhooks and CSRF in Rails.4. The endpoint returns 200 but does not process the event
Your handler runs, returns 200, and silently fails somewhere in the processing logic. The payment goes through but the user's account is not credited. This is a silent failure, and it is worse than a loud error because Paystack thinks the delivery succeeded and will not retry. The fix: log every event you receive, and build alerts for events received but not processed. See monitoring and alerting on webhook failures.
5. Serverless function cold starts exceed the timeout
On Vercel, Netlify, or AWS Lambda, a cold start can add several seconds to your response time. If the total time exceeds Paystack's timeout window, it treats the delivery as failed. Keep your webhook handler function lightweight and minimize dependencies to reduce cold start times. See the platform-specific guides: Vercel, Netlify, AWS Lambda, Cloudflare Workers.
For a wider view of Paystack errors and debugging, see the errors and troubleshooting reference.
This article is the hub for the webhooks cluster. The spoke articles go deeper on every topic mentioned above:
Fundamentals
Event handling
Reliability patterns
Testing
Platform-specific deployment
Framework-specific CSRF fixes
Monitoring and operations
McTaba Academy courses at academy.mctaba.com. Pick the skill you need, learn on your schedule.
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