In CI, you do not need Paystack to send real webhooks. Instead, build test payloads that match the Paystack webhook format, compute a valid HMAC SHA512 signature using your test secret key, and POST them to your webhook handler running on localhost. This tests your signature verification, event routing, idempotency, and business logic without any external dependency.
During local development, you can use ngrok or Cloudflare Tunnel to receive real Paystack webhooks. In CI, this approach falls apart.
Your CI runner (GitHub Actions, GitLab CI, Jenkins, CircleCI) is an ephemeral container or VM. It has no stable public IP. It exists for the duration of the pipeline and then gets destroyed. Setting up a tunnel adds complexity, latency, and a dependency on external services. If ngrok's servers are slow or Cloudflare has a hiccup, your entire CI pipeline fails for reasons that have nothing to do with your code.
The better approach: do not involve Paystack at all. Your webhook handler is just an HTTP endpoint that expects a specific request format and a valid HMAC signature. You can generate both yourself. The handler does not know or care whether the request came from Paystack's servers or from a curl command on the same machine.
This is not a shortcut. It is actually a more thorough test. You control every field in the payload, so you can test edge cases that real Paystack events might never produce: missing fields, zero-amount charges, unusual currencies, extremely long references.
This article is part of the Paystack webhooks engineering guide.
Paystack signs every webhook payload by computing an HMAC SHA512 hash of the raw JSON body using your secret key. To simulate a webhook in CI, you do the same thing.
Node.js
const crypto = require('crypto');
function signPayload(jsonBody, secretKey) {
return crypto
.createHmac('sha512', secretKey)
.update(jsonBody)
.digest('hex');
}
// Usage
const body = JSON.stringify({
event: 'charge.success',
data: {
reference: 'test-ref-001',
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'test@example.com' },
},
});
const signature = signPayload(body, process.env.PAYSTACK_SECRET_KEY);
// signature is a 128-character hex string
Python
import hmac
import hashlib
import json
def sign_payload(json_body, secret_key):
return hmac.new(
secret_key.encode('utf-8'),
msg=json_body.encode('utf-8'),
digestmod=hashlib.sha512
).hexdigest()
body = json.dumps({
'event': 'charge.success',
'data': {
'reference': 'test-ref-001',
'amount': 50000,
'currency': 'NGN',
'status': 'success',
'customer': {'email': 'test@example.com'},
},
})
signature = sign_payload(body, os.environ['PAYSTACK_SECRET_KEY'])
Bash (curl + openssl)
SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000,"currency":"NGN","status":"success","customer":{"email":"test@example.com"}}}'
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"
The key detail: the signature is computed over the exact bytes of the body string. If you change even one character (add a space, reorder a key), the signature changes. Use the same string for both signing and sending.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Hardcoding payloads in every test is tedious and fragile. Build a factory function that generates realistic payloads for each event type:
// test/helpers/paystack-webhook-factory.js
const crypto = require('crypto');
const SECRET = process.env.PAYSTACK_SECRET_KEY || 'sk_test_placeholder';
function createWebhookPayload(eventType, overrides) {
const defaults = {
'charge.success': {
event: 'charge.success',
data: {
id: Math.floor(Math.random() * 1000000000),
domain: 'test',
status: 'success',
reference: 'ref-' + Date.now() + '-' + Math.random().toString(36).substring(7),
amount: 50000,
currency: 'NGN',
channel: 'card',
customer: {
email: 'test@example.com',
customer_code: 'CUS_test123',
},
paid_at: new Date().toISOString(),
metadata: {},
},
},
'transfer.success': {
event: 'transfer.success',
data: {
domain: 'test',
status: 'success',
reference: 'transfer-' + Date.now(),
amount: 100000,
currency: 'NGN',
recipient: {
name: 'Test Vendor',
account_number: '0123456789',
bank_code: '058',
},
},
},
'transfer.failed': {
event: 'transfer.failed',
data: {
domain: 'test',
status: 'failed',
reference: 'transfer-' + Date.now(),
amount: 100000,
currency: 'NGN',
reason: 'Could not resolve account',
},
},
'refund.processed': {
event: 'refund.processed',
data: {
id: Math.floor(Math.random() * 1000000),
status: 'processed',
transaction: {
reference: 'original-ref-' + Date.now(),
},
deducted_amount: 50000,
currency: 'NGN',
},
},
};
const base = defaults[eventType];
if (!base) {
throw new Error('Unknown event type: ' + eventType);
}
// Deep merge overrides
const payload = JSON.parse(JSON.stringify(base));
if (overrides) {
Object.assign(payload.data, overrides);
}
return payload;
}
function signAndSend(payload) {
const body = JSON.stringify(payload);
const signature = crypto
.createHmac('sha512', SECRET)
.update(body)
.digest('hex');
return { body, signature };
}
module.exports = { createWebhookPayload, signAndSend };
Using the factory in a test:
const { createWebhookPayload, signAndSend } = require('./helpers/paystack-webhook-factory');
const payload = createWebhookPayload('charge.success', {
reference: 'order-abc-123',
amount: 75000, // 750 NGN
});
const { body, signature } = signAndSend(payload);
// Send to your webhook endpoint
const response = await fetch('http://localhost:3000/webhooks/paystack', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-paystack-signature': signature,
},
body: body,
});
expect(response.status).toBe(200);
A proper CI test for your webhook handler should verify the complete chain: HTTP request in, signature check, event routing, database changes, idempotency. Here is a complete example using Jest and supertest:
// test/webhooks/paystack.integration.test.js
const request = require('supertest');
const crypto = require('crypto');
const { Pool } = require('pg');
const app = require('../../src/app');
const SECRET = process.env.PAYSTACK_SECRET_KEY;
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
function buildSignedRequest(payload) {
const body = JSON.stringify(payload);
const signature = crypto
.createHmac('sha512', SECRET)
.update(body)
.digest('hex');
return { body, signature };
}
beforeEach(async () => {
// Clean up test data
await pool.query('DELETE FROM processed_events');
await pool.query('DELETE FROM orders');
// Seed a pending order
await pool.query(
'INSERT INTO orders (payment_reference, status, amount) VALUES ($1, $2, $3)',
['order-ref-001', 'pending', 50000]
);
});
afterAll(async () => {
await pool.end();
});
describe('POST /webhooks/paystack', () => {
it('returns 400 for invalid signature', async () => {
const response = await request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', 'invalid-signature')
.send('{"event":"charge.success","data":{}}');
expect(response.status).toBe(400);
});
it('processes charge.success and updates order', async () => {
const payload = {
event: 'charge.success',
data: {
reference: 'order-ref-001',
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'buyer@example.com' },
},
};
const { body, signature } = buildSignedRequest(payload);
const response = await request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', signature)
.send(body);
expect(response.status).toBe(200);
// Wait briefly for async processing
await new Promise(resolve => setTimeout(resolve, 500));
// Verify the order was updated
const order = await pool.query(
'SELECT status FROM orders WHERE payment_reference = $1',
['order-ref-001']
);
expect(order.rows[0].status).toBe('paid');
});
it('handles duplicate charge.success idempotently', async () => {
const payload = {
event: 'charge.success',
data: {
reference: 'order-ref-001',
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'buyer@example.com' },
},
};
const { body, signature } = buildSignedRequest(payload);
// First delivery
await request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', signature)
.send(body);
await new Promise(resolve => setTimeout(resolve, 500));
// Second delivery (duplicate)
await request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', signature)
.send(body);
await new Promise(resolve => setTimeout(resolve, 500));
// Verify only one processed_events record
const events = await pool.query(
'SELECT COUNT(*)::int as count FROM processed_events WHERE idempotency_key = $1',
['order-ref-001']
);
expect(events.rows[0].count).toBe(1);
});
});
This test suite covers the three most important behaviors: signature rejection, successful processing, and idempotency. Add more tests for each event type your handler supports.
Here is a GitHub Actions workflow that runs your webhook integration tests with a real PostgreSQL database:
# .github/workflows/webhook-tests.yml
name: Webhook Integration Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
PAYSTACK_SECRET_KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TEST_DATABASE_URL: postgres://test:testpass@localhost:5432/testdb
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run database migrations
run: npm run migrate
- name: Run webhook tests
run: npx jest test/webhooks/ --forceExit
Important details:
PAYSTACK_SECRET_KEY is a test key. It never touches real money. You can put it directly in the workflow file or store it as a GitHub Actions secret for an extra layer of caution.processed_events and orders tables exist.For GitLab CI, the pattern is similar. Use the services keyword for PostgreSQL:
# .gitlab-ci.yml
webhook-tests:
image: node:20
services:
- postgres:15
variables:
POSTGRES_USER: test
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
PAYSTACK_SECRET_KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TEST_DATABASE_URL: postgres://test:testpass@postgres:5432/testdb
script:
- npm ci
- npm run migrate
- npx jest test/webhooks/ --forceExit
CI is the best place to test edge cases that you cannot easily reproduce with real Paystack events. Since you control the payload, you can craft unusual scenarios:
Missing optional fields
// Test: customer object without customer_code
const payload = {
event: 'charge.success',
data: {
reference: 'edge-case-001',
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'test@example.com' },
// No customer_code, no metadata
},
};
// Your handler should not crash on missing fields
Zero-amount transactions
// Test: amount is 0 (can happen with certain plan trials)
const payload = {
event: 'charge.success',
data: {
reference: 'edge-case-002',
amount: 0,
currency: 'NGN',
status: 'success',
customer: { email: 'trial@example.com' },
},
};
// Should your handler grant value for a zero-amount charge?
Unknown event types
// Test: event type your handler does not recognize
const payload = {
event: 'some.future.event',
data: { id: 12345 },
};
// Your handler should log it and return 200, not crash
Concurrent duplicate delivery
// Test: two identical webhooks arriving at the same time
const payload = {
event: 'charge.success',
data: {
reference: 'concurrent-ref-001',
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'test@example.com' },
},
};
const { body, signature } = buildSignedRequest(payload);
// Fire both requests simultaneously
const [res1, res2] = await Promise.all([
request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', signature)
.send(body),
request(app)
.post('/webhooks/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', signature)
.send(body),
]);
// Both should return 200
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
await new Promise(resolve => setTimeout(resolve, 500));
// But the order should only be processed once
const events = await pool.query(
'SELECT COUNT(*)::int as count FROM processed_events WHERE idempotency_key = $1',
['concurrent-ref-001']
);
expect(events.rows[0].count).toBe(1);
These tests catch bugs that production traffic might not surface for months. Run them on every pull request.
Not every webhook test needs a database and an HTTP server. Separate your tests into two layers:
Unit tests test individual functions in isolation. They are fast and do not need external services:
verifySignature(body, signature, secret) return true for a valid signature?verifySignature(body, wrongSignature, secret) return false?extractIdempotencyKey(event) return the correct key for each event type?Integration tests test the full chain. They need the database, the HTTP server, and realistic payloads:
/webhooks/paystack return 200 and update the database?// Unit test: no database, no HTTP server
describe('verifySignature', () => {
const secret = 'sk_test_abc123';
it('returns true for a valid signature', () => {
const body = '{"event":"charge.success"}';
const hash = crypto
.createHmac('sha512', secret)
.update(body)
.digest('hex');
expect(verifySignature(body, hash, secret)).toBe(true);
});
it('returns false for a tampered body', () => {
const originalBody = '{"event":"charge.success"}';
const hash = crypto
.createHmac('sha512', secret)
.update(originalBody)
.digest('hex');
const tamperedBody = '{"event":"charge.success","data":{"amount":0}}';
expect(verifySignature(tamperedBody, hash, secret)).toBe(false);
});
it('returns false for a wrong secret', () => {
const body = '{"event":"charge.success"}';
const hash = crypto
.createHmac('sha512', 'wrong_secret')
.update(body)
.digest('hex');
expect(verifySignature(body, hash, secret)).toBe(false);
});
});
Run unit tests on every commit. Run integration tests on pull requests and before deploys. This keeps your CI pipeline fast for small changes while still catching integration issues before they reach production.
For more on simulating events, see simulating Paystack webhook events for automated tests. For the full testing strategy, see the webhooks engineering 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