Bonaventure OgetoBy Bonaventure Ogeto|

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 TypeHow to SimulateExpected 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

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