Inject failures into your payment flow to verify graceful degradation: (1) mock Paystack API returning 500 → your checkout endpoint should return a user-friendly error, not expose the API error; (2) mock webhook arriving with database unavailable → should queue the event for retry, not lose it; (3) inject a 30-second delay in verify call → should timeout and retry with backoff; (4) deliver the same webhook twice → idempotency should prevent double fulfillment.
| Failure Scenario | Correct System Behavior | Bad Behavior to Avoid |
|---|---|---|
| Paystack API returns 500 on initialize | Return friendly error to user, log the raw error, allow retry | Expose raw "Internal Server Error" from Paystack to user |
| Webhook arrives but database is unavailable | Return 500 to Paystack (triggers retry), queue event for processing | Return 200 and silently drop the event |
| Same webhook delivered twice | First delivery fulfills; second is a no-op (idempotency) | Double fulfillment or duplicate order credit |
| Paystack verify call times out | Retry with exponential backoff (3 attempts), fail gracefully after | User sees payment failed even though Paystack succeeded |
| Webhook signature check fails (network glitch corrupted header) | Return 401, log the failure — do not process | Process the event anyway or crash with unhandled error |
// Simulate Paystack API outage
describe('Paystack API 500 error', () => {
it('returns friendly error when Paystack is down', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ status: false, message: 'Internal server error' }),
});
var res = await request(app).post('/api/checkout').send({ email: 'user@test.com', amount: 10000 });
expect(res.status).toBe(503);
expect(res.body.error).toBe('Payment service temporarily unavailable');
// Should NOT expose: "Paystack: Internal server error"
});
});
// Simulate DB failure during webhook
describe('Database failure during webhook', () => {
it('returns 500 so Paystack retries', async () => {
jest.spyOn(db.orders, 'update').mockRejectedValue(new Error('Connection refused'));
var payload = { event: 'charge.success', data: { reference: 'ref_001', metadata: { order_id: 1 } } };
var body = JSON.stringify(payload);
var sig = crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY).update(body).digest('hex');
var res = await request(app)
.post('/webhook/paystack')
.set('x-paystack-signature', sig)
.send(body);
// Must return 500 so Paystack retries — not 200
expect(res.status).toBe(500);
});
});
// Simulate duplicate webhook delivery
describe('Idempotency under duplicate delivery', () => {
it('fulfills order exactly once for duplicate events', async () => {
var fulfillSpy = jest.spyOn(orderService, 'fulfill');
var payload = JSON.stringify({ event: 'charge.success', data: { reference: 'ref_dup', metadata: { order_id: 99 } } });
var sig = crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY).update(payload).digest('hex');
var headers = { 'x-paystack-signature': sig, 'Content-Type': 'application/json' };
await request(app).post('/webhook/paystack').set(headers).send(payload).expect(200);
await request(app).post('/webhook/paystack').set(headers).send(payload).expect(200);
expect(fulfillSpy).toHaveBeenCalledTimes(1); // Not twice
});
});
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
This guide is part of the Paystack testing 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