A Paystack webhook simulator generates JSON payloads that match the real webhook format, computes the HMAC SHA512 signature using your test secret key, and delivers them to your handler via HTTP POST. This lets you test charge.success, transfer events, subscription lifecycle, refunds, and edge cases without making real transactions or exposing your server to the internet.
Real Paystack webhook events are useful for manual testing, but they are a poor fit for automated tests. Here is why:
transfer.failed or refund.failed event requires a real failure, which you cannot reliably trigger in test mode.A simulator removes all of these problems. You generate the exact payload you want, sign it correctly, and deliver it instantly to your handler running on localhost.
This article is part of the Paystack webhooks engineering guide. For CI-specific setup, see testing in CI without a public URL.
The simulator has three parts: a payload builder, a signer, and a sender.
// test/helpers/webhook-simulator.js
const crypto = require('crypto');
const http = require('http');
class PaystackWebhookSimulator {
constructor(options) {
this.secretKey = options.secretKey || process.env.PAYSTACK_SECRET_KEY;
this.targetUrl = options.targetUrl || 'http://localhost:3000/webhooks/paystack';
}
// Sign a JSON payload the same way Paystack does
sign(jsonString) {
return crypto
.createHmac('sha512', this.secretKey)
.update(jsonString)
.digest('hex');
}
// Send a signed webhook to the target URL
async send(payload) {
const body = JSON.stringify(payload);
const signature = this.sign(body);
const url = new URL(this.targetUrl);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-paystack-signature': signature,
'Content-Length': Buffer.byteLength(body),
},
};
return new Promise(function(resolve, reject) {
const req = http.request(options, function(res) {
let data = '';
res.on('data', function(chunk) { data += chunk; });
res.on('end', function() {
resolve({ status: res.statusCode, body: data });
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// Convenience methods for common events
async chargeSuccess(overrides) {
const payload = this.buildChargeSuccess(overrides);
return this.send(payload);
}
async transferSuccess(overrides) {
const payload = this.buildTransferSuccess(overrides);
return this.send(payload);
}
async transferFailed(overrides) {
const payload = this.buildTransferFailed(overrides);
return this.send(payload);
}
async refundProcessed(overrides) {
const payload = this.buildRefundProcessed(overrides);
return this.send(payload);
}
// Payload builders
buildChargeSuccess(overrides) {
return deepMerge({
event: 'charge.success',
data: {
id: randomId(),
domain: 'test',
status: 'success',
reference: 'sim-' + Date.now(),
amount: 50000,
currency: 'NGN',
channel: 'card',
customer: {
email: 'sim-customer@example.com',
customer_code: 'CUS_sim' + randomId(),
},
paid_at: new Date().toISOString(),
metadata: {},
},
}, overrides);
}
buildTransferSuccess(overrides) {
return deepMerge({
event: 'transfer.success',
data: {
domain: 'test',
status: 'success',
reference: 'tsf-' + Date.now(),
amount: 100000,
currency: 'NGN',
recipient: {
name: 'Vendor Test',
account_number: '0123456789',
bank_code: '058',
},
transfer_code: 'TRF_sim' + randomId(),
},
}, overrides);
}
buildTransferFailed(overrides) {
return deepMerge({
event: 'transfer.failed',
data: {
domain: 'test',
status: 'failed',
reference: 'tsf-' + Date.now(),
amount: 100000,
currency: 'NGN',
reason: 'Account could not be resolved',
},
}, overrides);
}
buildRefundProcessed(overrides) {
return deepMerge({
event: 'refund.processed',
data: {
id: randomId(),
status: 'processed',
transaction: {
reference: 'orig-' + Date.now(),
},
deducted_amount: 50000,
currency: 'NGN',
},
}, overrides);
}
}
function randomId() {
return Math.floor(Math.random() * 1000000000);
}
function deepMerge(target, source) {
if (!source) return target;
const result = JSON.parse(JSON.stringify(target));
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
module.exports = { PaystackWebhookSimulator };
This class handles the signing, the HTTP delivery, and the payload construction. Every payload matches the structure Paystack actually sends.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
For Python/Django/Flask applications, here is the equivalent simulator:
import hmac
import hashlib
import json
import time
import random
import requests
import os
class PaystackWebhookSimulator:
def __init__(self, secret_key=None, target_url=None):
self.secret_key = secret_key or os.environ.get('PAYSTACK_SECRET_KEY', 'sk_test_placeholder')
self.target_url = target_url or 'http://localhost:8000/webhooks/paystack/'
def sign(self, body_string):
return hmac.new(
self.secret_key.encode('utf-8'),
msg=body_string.encode('utf-8'),
digestmod=hashlib.sha512
).hexdigest()
def send(self, payload):
body = json.dumps(payload)
signature = self.sign(body)
response = requests.post(
self.target_url,
data=body,
headers={
'Content-Type': 'application/json',
'X-Paystack-Signature': signature,
}
)
return response
def charge_success(self, **overrides):
payload = {
'event': 'charge.success',
'data': {
'id': random.randint(100000, 999999999),
'domain': 'test',
'status': 'success',
'reference': 'sim-' + str(int(time.time())),
'amount': 50000,
'currency': 'NGN',
'channel': 'card',
'customer': {
'email': 'sim@example.com',
'customer_code': 'CUS_sim' + str(random.randint(1000, 9999)),
},
'paid_at': time.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
'metadata': {},
},
}
payload['data'].update(overrides)
return self.send(payload)
def transfer_failed(self, **overrides):
payload = {
'event': 'transfer.failed',
'data': {
'domain': 'test',
'status': 'failed',
'reference': 'tsf-' + str(int(time.time())),
'amount': 100000,
'currency': 'NGN',
'reason': 'Account not resolved',
},
}
payload['data'].update(overrides)
return self.send(payload)
Usage in a pytest test:
import pytest
from webhook_simulator import PaystackWebhookSimulator
@pytest.fixture
def simulator():
return PaystackWebhookSimulator(
secret_key='sk_test_your_test_key',
target_url='http://localhost:8000/webhooks/paystack/'
)
def test_charge_success_updates_order(simulator, db_session):
# Seed a pending order
db_session.execute(
"INSERT INTO orders (payment_reference, status) VALUES ('ref-001', 'pending')"
)
db_session.commit()
response = simulator.charge_success(reference='ref-001', amount=50000)
assert response.status_code == 200
# Verify the order was updated
result = db_session.execute(
"SELECT status FROM orders WHERE payment_reference = 'ref-001'"
)
assert result.fetchone()[0] == 'paid'
Real payment flows involve multiple events in sequence. A subscription creates a charge, which might later get refunded. A transfer succeeds and then gets reversed. Your tests should cover these multi-event flows.
const { PaystackWebhookSimulator } = require('./helpers/webhook-simulator');
describe('payment and refund flow', () => {
const sim = new PaystackWebhookSimulator({
secretKey: process.env.PAYSTACK_SECRET_KEY,
targetUrl: 'http://localhost:3000/webhooks/paystack',
});
it('handles charge followed by refund', async () => {
const reference = 'flow-test-' + Date.now();
// Step 1: Charge succeeds
const chargeResult = await sim.chargeSuccess({
data: { reference: reference, amount: 75000 },
});
expect(chargeResult.status).toBe(200);
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 300));
// Verify order is paid
const order = await db.query(
'SELECT status FROM orders WHERE payment_reference = $1',
[reference]
);
expect(order.rows[0].status).toBe('paid');
// Step 2: Refund is processed
const refundResult = await sim.refundProcessed({
data: {
transaction: { reference: reference },
deducted_amount: 75000,
},
});
expect(refundResult.status).toBe(200);
await new Promise(resolve => setTimeout(resolve, 300));
// Verify order is refunded
const updated = await db.query(
'SELECT status FROM orders WHERE payment_reference = $1',
[reference]
);
expect(updated.rows[0].status).toBe('refunded');
});
});
describe('transfer reversal flow', () => {
const sim = new PaystackWebhookSimulator({
secretKey: process.env.PAYSTACK_SECRET_KEY,
targetUrl: 'http://localhost:3000/webhooks/paystack',
});
it('handles transfer.success followed by transfer.reversed', async () => {
const reference = 'payout-' + Date.now();
// Transfer succeeds
await sim.transferSuccess({
data: { reference: reference, amount: 200000 },
});
await new Promise(resolve => setTimeout(resolve, 300));
const payout = await db.query(
'SELECT status FROM payouts WHERE reference = $1',
[reference]
);
expect(payout.rows[0].status).toBe('success');
// Transfer gets reversed by the bank
const reversalPayload = {
event: 'transfer.reversed',
data: {
domain: 'test',
status: 'reversed',
reference: reference,
amount: 200000,
currency: 'NGN',
},
};
await sim.send(reversalPayload);
await new Promise(resolve => setTimeout(resolve, 300));
const reversed = await db.query(
'SELECT status FROM payouts WHERE reference = $1',
[reference]
);
expect(reversed.rows[0].status).toBe('reversed');
});
});
Sequence tests catch bugs that single-event tests miss. A common one: your refund handler assumes the order is in "paid" status, but the order is still "pending" because the charge.success handler has not finished processing. See race conditions between webhooks and callbacks for more on timing issues.
The simulator lets you craft payloads that Paystack would never send in normal operation. These edge cases are where bugs hide:
// 1. Amount as a string (some JSON parsers might produce this)
await sim.chargeSuccess({ data: { reference: 'edge-1', amount: '50000' } });
// 2. Missing customer object entirely
await sim.send({
event: 'charge.success',
data: {
reference: 'edge-2',
amount: 50000,
currency: 'NGN',
status: 'success',
// No customer field
},
});
// 3. Very long reference
await sim.chargeSuccess({
data: {
reference: 'a'.repeat(500),
amount: 50000,
},
});
// 4. Non-NGN currency
await sim.chargeSuccess({
data: {
reference: 'edge-4',
amount: 10000,
currency: 'GHS',
},
});
// 5. Metadata with deeply nested objects
await sim.chargeSuccess({
data: {
reference: 'edge-5',
amount: 50000,
metadata: {
custom_fields: [
{ display_name: 'Order ID', variable_name: 'order_id', value: 'ORD-999' },
{ display_name: 'Plan', variable_name: 'plan', value: 'premium' },
],
},
},
});
// 6. Duplicate event with different data (same reference, different amount)
// This should not happen in production, but tests whether
// your idempotency correctly ignores the second delivery
await sim.chargeSuccess({ data: { reference: 'edge-6', amount: 50000 } });
await sim.chargeSuccess({ data: { reference: 'edge-6', amount: 99999 } });
For each edge case, verify that your handler either processes it correctly or fails gracefully (logs the error, returns 200, does not crash). The worst outcome is an unhandled exception that crashes your server and stops processing all subsequent webhooks.
Sometimes you need to fire a quick webhook during development without writing a full test. Here is a bash script that does it:
#!/bin/bash
# send-test-webhook.sh
# Usage: ./send-test-webhook.sh charge.success ref-001 50000
EVENT_TYPE=$1
REFERENCE=$2
AMOUNT=$3
SECRET="sk_test_your_secret_key"
URL="http://localhost:3000/webhooks/paystack"
BODY=$(cat <<EOFBODY
{"event":"$EVENT_TYPE","data":{"reference":"$REFERENCE","amount":$AMOUNT,"currency":"NGN","status":"success","domain":"test","customer":{"email":"test@example.com"}}}
EOFBODY
)
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
echo "Sending $EVENT_TYPE webhook..."
echo "Reference: $REFERENCE"
echo "Amount: $AMOUNT"
echo ""
RESPONSE=$(curl -s -w "
%{http_code}" -X POST "$URL" -H "Content-Type: application/json" -H "x-paystack-signature: $SIGNATURE" -d "$BODY")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY_RESPONSE=$(echo "$RESPONSE" | head -1)
echo "Response: $HTTP_CODE $BODY_RESPONSE"
Run it:
chmod +x send-test-webhook.sh
./send-test-webhook.sh charge.success test-ref-001 50000
# Output:
# Sending charge.success webhook...
# Reference: test-ref-001
# Amount: 50000
#
# Response: 200 OK
This is handy for smoke testing your webhook handler after a deploy or during a debugging session. For repeatable, automated testing, use the full simulator class described above.
For more on local testing with tunnels, see the ngrok guide and the Cloudflare Tunnel guide.
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