Simulating Failed Payments on Paystack
Simulate Paystack payment failures using: (1) test decline cards (4084080000000409 for Do Not Honor, 4084080000000005 for Insufficient Funds) — these fire charge.failed webhooks; (2) mock the Paystack initialize endpoint returning 5xx — test your server error handling; (3) use an expired access_code — test session timeout handling; (4) send a charge.failed webhook payload manually with a valid HMAC — test your failed payment webhook handler.
Types of Paystack Payment Failures and How to Simulate Them
| Failure Type | How to Simulate | Expected Behavior |
|---|---|---|
| Card declined (Do Not Honor) | Use test card 4084080000000409 | charge.failed webhook fires; order stays pending; user sees decline message |
| Insufficient funds | Use test card 4084080000000005 | charge.failed webhook; user told to check balance or try different card |
| Paystack API 500 on initialize | Mock fetch to return 500 response | Checkout returns 503 with friendly message; no order created |
| Paystack API timeout | Mock fetch to hang for 30+ seconds | Timeout after configured limit; retry logic fires; user sees "try again" |
| Expired access_code / session | Use an already-processed reference | Paystack returns error; user is redirected to restart checkout |
| Duplicate reference | Send same reference to initialize twice | Paystack returns error on second call; your code handles it without crashing |
Testing the charge.failed Webhook Handler
// test: charge.failed webhook handler
const crypto = require('crypto');
describe('charge.failed webhook handling', () => {
it('marks order as payment_failed without fulfilling it', async () => {
var orderId = 77;
await db.orders.create({ id: orderId, status: 'pending', amount: 5000 });
var payload = {
event: 'charge.failed',
data: {
reference: 'ref_fail_001',
amount: 500000,
status: 'failed',
gateway_response: 'Insufficient Funds',
metadata: { order_id: orderId },
customer: { email: 'test@example.com' },
},
};
var body = JSON.stringify(payload);
var sig = crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY).update(body).digest('hex');
await request(app)
.post('/webhook/paystack')
.set('x-paystack-signature', sig)
.send(body)
.expect(200);
var order = await db.orders.findByPk(orderId);
expect(order.status).toBe('payment_failed');
expect(order.failure_reason).toBe('Insufficient Funds');
// Critically: the order should NOT be fulfilled
expect(order.fulfilled_at).toBeNull();
});
});
Learn More
This guide is part of the Paystack testing guide.
Key Takeaways
- ✓Paystack test decline cards trigger charge.failed webhook events — use them to test your failed payment flow end-to-end.
- ✓Test the Paystack API being unavailable: mock a 503 response from initialize and verify your endpoint handles it gracefully.
- ✓Test expired payment session: use an already-used access_code and verify the checkout tells the user to restart.
- ✓Test webhook handling for charge.failed: POST a valid HMAC-signed charge.failed payload and verify the order is not fulfilled.
- ✓Test every decline reason your UX displays — map gateway_response values to user-friendly messages and test each mapping.
- ✓Failed payment recovery: after a failure, can the customer retry with a different card without losing their cart?
Frequently Asked Questions
- Does Paystack always send a webhook for failed payments?
- Paystack sends a charge.failed event for card declines that reached the processing stage. Some failures (like network errors before the charge was attempted) may not generate a webhook. This is why you should not rely solely on webhooks for payment status — run a nightly reconciliation job that verifies pending orders using the Paystack verify API.
- How should my frontend react when a payment fails?
- Show a specific, helpful message based on the failure reason. "Do Not Honor" → "Your bank declined this payment. Please contact your bank or try a different card." "Insufficient Funds" → "Your account balance may be too low. Please add funds or use a different card." Give the customer a clear retry path without losing their cart.
- Should failed payment orders be deleted or kept in the database?
- Keep them — with status "payment_failed". This allows: (1) customer support to see what happened; (2) analytics on your failure rate by gateway_response; (3) the customer to retry the same order. Soft-delete after 7-30 days if needed for cleanup, but never immediately delete failed payment records.
- What is the difference between charge.failed and transfer.failed?
- charge.failed is when a customer's payment attempt fails (card declined, bank error). transfer.failed is when your payout to a vendor or recipient fails (wrong account number, bank technical error). Both are distinct webhook events requiring different handling — charge.failed marks an order unpaid, transfer.failed requires retry or investigation of the recipient's bank details.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon