Bonaventure OgetoBy Bonaventure Ogeto|

Contract Testing Your Paystack Webhook Handler

Contract test your Paystack webhook handler by: (1) capturing real webhook payloads from Paystack test mode (log them during development), (2) storing them as JSON fixtures, (3) writing tests that feed those fixtures into your handler and assert the correct business logic runs. Also validate incoming payloads against a JSON schema to catch Paystack API changes early.

Creating Webhook Payload Fixtures

// tests/fixtures/paystack-charge-success.json
{
  "event": "charge.success",
  "data": {
    "id": 1234567,
    "domain": "test",
    "status": "success",
    "reference": "ref_test_001",
    "amount": 100000,
    "message": null,
    "gateway_response": "Approved",
    "paid_at": "2026-07-22T10:00:00.000Z",
    "created_at": "2026-07-22T09:59:55.000Z",
    "channel": "card",
    "currency": "NGN",
    "ip_address": "197.234.240.2",
    "metadata": {
      "order_id": 42,
      "custom_fields": []
    },
    "fees_breakdown": null,
    "log": null,
    "fees": 1500,
    "fees_split": null,
    "authorization": {
      "authorization_code": "AUTH_test_code",
      "bin": "408408",
      "last4": "4081",
      "exp_month": "05",
      "exp_year": "2030",
      "channel": "card",
      "card_type": "visa",
      "bank": "TEST BANK",
      "country_code": "NG",
      "brand": "visa",
      "reusable": true,
      "signature": "SIG_test",
      "account_name": null
    },
    "customer": {
      "id": 9876543,
      "first_name": null,
      "last_name": null,
      "email": "test@example.com",
      "customer_code": "CUS_test",
      "phone": null,
      "metadata": {},
      "risk_action": "allow",
      "international_format_phone": null
    },
    "plan": {},
    "subaccount": {},
    "order_id": null,
    "paidAt": "2026-07-22T10:00:00.000Z",
    "requested_amount": 100000,
    "pos_transaction_data": null,
    "source": null,
    "fees_breakdown": null
  }
}

Contract Test Using Fixtures

// tests/webhook-contract.test.js
const crypto = require('crypto');
const request = require('supertest');
const app = require('../app');
const db = require('../db');

const chargeSuccessFixture = require('./fixtures/paystack-charge-success.json');

function signPayload(body) {
  return crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY).update(body).digest('hex');
}

describe('Webhook contract: charge.success', () => {
  it('extracts order_id from metadata and marks order paid', async () => {
    await db.orders.create({ id: 42, email: 'test@example.com', status: 'pending', amount: 1000 });

    var body = JSON.stringify(chargeSuccessFixture);
    var sig = signPayload(body);

    await request(app)
      .post('/webhook/paystack')
      .set('x-paystack-signature', sig)
      .set('Content-Type', 'application/json')
      .send(body)
      .expect(200);

    var order = await db.orders.findByPk(42);
    expect(order.status).toBe('paid');
    expect(order.paystack_reference).toBe('ref_test_001');
  });

  it('rejects payload with missing required fields gracefully', async () => {
    var malformedPayload = { event: 'charge.success', data: {} };
    var body = JSON.stringify(malformedPayload);
    var sig = signPayload(body);

    var res = await request(app)
      .post('/webhook/paystack')
      .set('x-paystack-signature', sig)
      .send(body);

    // Should not throw 500 — handle gracefully
    expect(res.status).not.toBe(500);
  });
});

Learn More

Key Takeaways

  • Capture real Paystack test webhook payloads during development and save them as JSON test fixtures.
  • Feed those fixtures into your webhook handler in tests and assert the expected database updates occur.
  • Validate incoming webhook payloads against a JSON schema — catch field renames or type changes from Paystack early.
  • Test all event types your handler processes: charge.success, charge.failed, subscription.disable, transfer.success.
  • Test the signature validation as a separate contract requirement — your handler must reject payloads without a valid signature.
  • If Paystack changes their webhook schema (adds a required field you depend on), your contract test fixture will expose the mismatch.

Frequently Asked Questions

How do I capture a real Paystack webhook payload to use as a fixture?
In development, log the entire raw webhook request body when a test event fires: console.log(JSON.stringify(req.body, null, 2)). Trigger a test payment, check your server logs, copy the payload, save it as a JSON fixture file. This gives you an accurate fixture that matches Paystack's actual schema.
Should I validate incoming webhook payloads against a schema?
Yes — use a lightweight schema validator like Ajv or Zod. Define the expected shape of charge.success, charge.failed, and other events. Log validation failures but do not reject the webhook (Paystack expects 200). This lets you detect schema changes from Paystack without breaking your payment flow.
What is the difference between contract testing and integration testing here?
Integration tests verify that your system works end-to-end with the real API. Contract tests verify that your handler correctly handles the specific data shape that Paystack sends. Contract tests use fixtures (stored payloads) rather than calling the real API, making them faster and more focused on the data contract.
Does Paystack publish a schema or specification for webhook events?
Paystack documents the webhook event payload structure in their API reference. They do not publish a formal JSON Schema or OpenAPI spec for events as of 2026. The best approach is to capture live payloads and use those as your source of truth for fixtures.

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