Bonaventure OgetoBy Bonaventure Ogeto|

Simulating Paystack Webhook Events for Automated Tests

A Paystack webhook simulator generates JSON payloads that match the real webhook format, computes the HMAC SHA512 signature using your test secret key, and delivers them to your handler via HTTP POST. This lets you test charge.success, transfer events, subscription lifecycle, refunds, and edge cases without making real transactions or exposing your server to the internet.

Why Simulate Instead of Using Real Events

Real Paystack webhook events are useful for manual testing, but they are a poor fit for automated tests. Here is why:

  • You cannot control the timing. After initiating a test transaction, you wait for Paystack to process it and send the webhook. That delay is unpredictable. In an automated test, you need the event to arrive within milliseconds.
  • You cannot control the payload. Paystack generates the reference, the amount, the customer details. You cannot force a specific edge case (what happens when metadata is null? when the customer has no email?).
  • You cannot test failure events on demand. Getting Paystack to send a transfer.failed or refund.failed event requires a real failure, which you cannot reliably trigger in test mode.
  • You need a public URL. Real webhooks require ngrok or a tunnel. In CI, this adds complexity and external dependencies.
  • Tests become flaky. Any network issue between Paystack and your server causes a test failure that has nothing to do with your code.

A simulator removes all of these problems. You generate the exact payload you want, sign it correctly, and deliver it instantly to your handler running on localhost.

This article is part of the Paystack webhooks engineering guide. For CI-specific setup, see testing in CI without a public URL.

The Core Simulator in Node.js

The simulator has three parts: a payload builder, a signer, and a sender.

// test/helpers/webhook-simulator.js
const crypto = require('crypto');
const http = require('http');

class PaystackWebhookSimulator {
  constructor(options) {
    this.secretKey = options.secretKey || process.env.PAYSTACK_SECRET_KEY;
    this.targetUrl = options.targetUrl || 'http://localhost:3000/webhooks/paystack';
  }

  // Sign a JSON payload the same way Paystack does
  sign(jsonString) {
    return crypto
      .createHmac('sha512', this.secretKey)
      .update(jsonString)
      .digest('hex');
  }

  // Send a signed webhook to the target URL
  async send(payload) {
    const body = JSON.stringify(payload);
    const signature = this.sign(body);

    const url = new URL(this.targetUrl);
    const options = {
      hostname: url.hostname,
      port: url.port,
      path: url.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-paystack-signature': signature,
        'Content-Length': Buffer.byteLength(body),
      },
    };

    return new Promise(function(resolve, reject) {
      const req = http.request(options, function(res) {
        let data = '';
        res.on('data', function(chunk) { data += chunk; });
        res.on('end', function() {
          resolve({ status: res.statusCode, body: data });
        });
      });

      req.on('error', reject);
      req.write(body);
      req.end();
    });
  }

  // Convenience methods for common events
  async chargeSuccess(overrides) {
    const payload = this.buildChargeSuccess(overrides);
    return this.send(payload);
  }

  async transferSuccess(overrides) {
    const payload = this.buildTransferSuccess(overrides);
    return this.send(payload);
  }

  async transferFailed(overrides) {
    const payload = this.buildTransferFailed(overrides);
    return this.send(payload);
  }

  async refundProcessed(overrides) {
    const payload = this.buildRefundProcessed(overrides);
    return this.send(payload);
  }

  // Payload builders
  buildChargeSuccess(overrides) {
    return deepMerge({
      event: 'charge.success',
      data: {
        id: randomId(),
        domain: 'test',
        status: 'success',
        reference: 'sim-' + Date.now(),
        amount: 50000,
        currency: 'NGN',
        channel: 'card',
        customer: {
          email: 'sim-customer@example.com',
          customer_code: 'CUS_sim' + randomId(),
        },
        paid_at: new Date().toISOString(),
        metadata: {},
      },
    }, overrides);
  }

  buildTransferSuccess(overrides) {
    return deepMerge({
      event: 'transfer.success',
      data: {
        domain: 'test',
        status: 'success',
        reference: 'tsf-' + Date.now(),
        amount: 100000,
        currency: 'NGN',
        recipient: {
          name: 'Vendor Test',
          account_number: '0123456789',
          bank_code: '058',
        },
        transfer_code: 'TRF_sim' + randomId(),
      },
    }, overrides);
  }

  buildTransferFailed(overrides) {
    return deepMerge({
      event: 'transfer.failed',
      data: {
        domain: 'test',
        status: 'failed',
        reference: 'tsf-' + Date.now(),
        amount: 100000,
        currency: 'NGN',
        reason: 'Account could not be resolved',
      },
    }, overrides);
  }

  buildRefundProcessed(overrides) {
    return deepMerge({
      event: 'refund.processed',
      data: {
        id: randomId(),
        status: 'processed',
        transaction: {
          reference: 'orig-' + Date.now(),
        },
        deducted_amount: 50000,
        currency: 'NGN',
      },
    }, overrides);
  }
}

function randomId() {
  return Math.floor(Math.random() * 1000000000);
}

function deepMerge(target, source) {
  if (!source) return target;
  const result = JSON.parse(JSON.stringify(target));
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
      result[key] = deepMerge(result[key] || {}, source[key]);
    } else {
      result[key] = source[key];
    }
  }
  return result;
}

module.exports = { PaystackWebhookSimulator };

This class handles the signing, the HTTP delivery, and the payload construction. Every payload matches the structure Paystack actually sends.

The Simulator in Python

For Python/Django/Flask applications, here is the equivalent simulator:

import hmac
import hashlib
import json
import time
import random
import requests
import os

class PaystackWebhookSimulator:
    def __init__(self, secret_key=None, target_url=None):
        self.secret_key = secret_key or os.environ.get('PAYSTACK_SECRET_KEY', 'sk_test_placeholder')
        self.target_url = target_url or 'http://localhost:8000/webhooks/paystack/'

    def sign(self, body_string):
        return hmac.new(
            self.secret_key.encode('utf-8'),
            msg=body_string.encode('utf-8'),
            digestmod=hashlib.sha512
        ).hexdigest()

    def send(self, payload):
        body = json.dumps(payload)
        signature = self.sign(body)
        response = requests.post(
            self.target_url,
            data=body,
            headers={
                'Content-Type': 'application/json',
                'X-Paystack-Signature': signature,
            }
        )
        return response

    def charge_success(self, **overrides):
        payload = {
            'event': 'charge.success',
            'data': {
                'id': random.randint(100000, 999999999),
                'domain': 'test',
                'status': 'success',
                'reference': 'sim-' + str(int(time.time())),
                'amount': 50000,
                'currency': 'NGN',
                'channel': 'card',
                'customer': {
                    'email': 'sim@example.com',
                    'customer_code': 'CUS_sim' + str(random.randint(1000, 9999)),
                },
                'paid_at': time.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
                'metadata': {},
            },
        }
        payload['data'].update(overrides)
        return self.send(payload)

    def transfer_failed(self, **overrides):
        payload = {
            'event': 'transfer.failed',
            'data': {
                'domain': 'test',
                'status': 'failed',
                'reference': 'tsf-' + str(int(time.time())),
                'amount': 100000,
                'currency': 'NGN',
                'reason': 'Account not resolved',
            },
        }
        payload['data'].update(overrides)
        return self.send(payload)

Usage in a pytest test:

import pytest
from webhook_simulator import PaystackWebhookSimulator

@pytest.fixture
def simulator():
    return PaystackWebhookSimulator(
        secret_key='sk_test_your_test_key',
        target_url='http://localhost:8000/webhooks/paystack/'
    )

def test_charge_success_updates_order(simulator, db_session):
    # Seed a pending order
    db_session.execute(
        "INSERT INTO orders (payment_reference, status) VALUES ('ref-001', 'pending')"
    )
    db_session.commit()

    response = simulator.charge_success(reference='ref-001', amount=50000)
    assert response.status_code == 200

    # Verify the order was updated
    result = db_session.execute(
        "SELECT status FROM orders WHERE payment_reference = 'ref-001'"
    )
    assert result.fetchone()[0] == 'paid'

Simulating Event Sequences

Real payment flows involve multiple events in sequence. A subscription creates a charge, which might later get refunded. A transfer succeeds and then gets reversed. Your tests should cover these multi-event flows.

const { PaystackWebhookSimulator } = require('./helpers/webhook-simulator');

describe('payment and refund flow', () => {
  const sim = new PaystackWebhookSimulator({
    secretKey: process.env.PAYSTACK_SECRET_KEY,
    targetUrl: 'http://localhost:3000/webhooks/paystack',
  });

  it('handles charge followed by refund', async () => {
    const reference = 'flow-test-' + Date.now();

    // Step 1: Charge succeeds
    const chargeResult = await sim.chargeSuccess({
      data: { reference: reference, amount: 75000 },
    });
    expect(chargeResult.status).toBe(200);

    // Wait for processing
    await new Promise(resolve => setTimeout(resolve, 300));

    // Verify order is paid
    const order = await db.query(
      'SELECT status FROM orders WHERE payment_reference = $1',
      [reference]
    );
    expect(order.rows[0].status).toBe('paid');

    // Step 2: Refund is processed
    const refundResult = await sim.refundProcessed({
      data: {
        transaction: { reference: reference },
        deducted_amount: 75000,
      },
    });
    expect(refundResult.status).toBe(200);

    await new Promise(resolve => setTimeout(resolve, 300));

    // Verify order is refunded
    const updated = await db.query(
      'SELECT status FROM orders WHERE payment_reference = $1',
      [reference]
    );
    expect(updated.rows[0].status).toBe('refunded');
  });
});

describe('transfer reversal flow', () => {
  const sim = new PaystackWebhookSimulator({
    secretKey: process.env.PAYSTACK_SECRET_KEY,
    targetUrl: 'http://localhost:3000/webhooks/paystack',
  });

  it('handles transfer.success followed by transfer.reversed', async () => {
    const reference = 'payout-' + Date.now();

    // Transfer succeeds
    await sim.transferSuccess({
      data: { reference: reference, amount: 200000 },
    });
    await new Promise(resolve => setTimeout(resolve, 300));

    const payout = await db.query(
      'SELECT status FROM payouts WHERE reference = $1',
      [reference]
    );
    expect(payout.rows[0].status).toBe('success');

    // Transfer gets reversed by the bank
    const reversalPayload = {
      event: 'transfer.reversed',
      data: {
        domain: 'test',
        status: 'reversed',
        reference: reference,
        amount: 200000,
        currency: 'NGN',
      },
    };
    await sim.send(reversalPayload);
    await new Promise(resolve => setTimeout(resolve, 300));

    const reversed = await db.query(
      'SELECT status FROM payouts WHERE reference = $1',
      [reference]
    );
    expect(reversed.rows[0].status).toBe('reversed');
  });
});

Sequence tests catch bugs that single-event tests miss. A common one: your refund handler assumes the order is in "paid" status, but the order is still "pending" because the charge.success handler has not finished processing. See race conditions between webhooks and callbacks for more on timing issues.

Edge Case Payloads Worth Testing

The simulator lets you craft payloads that Paystack would never send in normal operation. These edge cases are where bugs hide:

// 1. Amount as a string (some JSON parsers might produce this)
await sim.chargeSuccess({ data: { reference: 'edge-1', amount: '50000' } });

// 2. Missing customer object entirely
await sim.send({
  event: 'charge.success',
  data: {
    reference: 'edge-2',
    amount: 50000,
    currency: 'NGN',
    status: 'success',
    // No customer field
  },
});

// 3. Very long reference
await sim.chargeSuccess({
  data: {
    reference: 'a'.repeat(500),
    amount: 50000,
  },
});

// 4. Non-NGN currency
await sim.chargeSuccess({
  data: {
    reference: 'edge-4',
    amount: 10000,
    currency: 'GHS',
  },
});

// 5. Metadata with deeply nested objects
await sim.chargeSuccess({
  data: {
    reference: 'edge-5',
    amount: 50000,
    metadata: {
      custom_fields: [
        { display_name: 'Order ID', variable_name: 'order_id', value: 'ORD-999' },
        { display_name: 'Plan', variable_name: 'plan', value: 'premium' },
      ],
    },
  },
});

// 6. Duplicate event with different data (same reference, different amount)
// This should not happen in production, but tests whether
// your idempotency correctly ignores the second delivery
await sim.chargeSuccess({ data: { reference: 'edge-6', amount: 50000 } });
await sim.chargeSuccess({ data: { reference: 'edge-6', amount: 99999 } });

For each edge case, verify that your handler either processes it correctly or fails gracefully (logs the error, returns 200, does not crash). The worst outcome is an unhandled exception that crashes your server and stops processing all subsequent webhooks.

Bash One-Liner for Quick Manual Tests

Sometimes you need to fire a quick webhook during development without writing a full test. Here is a bash script that does it:

#!/bin/bash
# send-test-webhook.sh
# Usage: ./send-test-webhook.sh charge.success ref-001 50000

EVENT_TYPE=$1
REFERENCE=$2
AMOUNT=$3
SECRET="sk_test_your_secret_key"
URL="http://localhost:3000/webhooks/paystack"

BODY=$(cat <<EOFBODY
{"event":"$EVENT_TYPE","data":{"reference":"$REFERENCE","amount":$AMOUNT,"currency":"NGN","status":"success","domain":"test","customer":{"email":"test@example.com"}}}
EOFBODY
)

SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

echo "Sending $EVENT_TYPE webhook..."
echo "Reference: $REFERENCE"
echo "Amount: $AMOUNT"
echo ""

RESPONSE=$(curl -s -w "
%{http_code}" -X POST "$URL"   -H "Content-Type: application/json"   -H "x-paystack-signature: $SIGNATURE"   -d "$BODY")

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY_RESPONSE=$(echo "$RESPONSE" | head -1)

echo "Response: $HTTP_CODE $BODY_RESPONSE"

Run it:

chmod +x send-test-webhook.sh
./send-test-webhook.sh charge.success test-ref-001 50000
# Output:
# Sending charge.success webhook...
# Reference: test-ref-001
# Amount: 50000
#
# Response: 200 OK

This is handy for smoke testing your webhook handler after a deploy or during a debugging session. For repeatable, automated testing, use the full simulator class described above.

For more on local testing with tunnels, see the ngrok guide and the Cloudflare Tunnel guide.

Key Takeaways

  • A webhook simulator generates payloads that match the real Paystack format and signs them with a valid HMAC SHA512 signature.
  • Build payload factories for each event type: charge.success, transfer.success, transfer.failed, subscription.create, refund.processed, and others.
  • Simulate event sequences (charge.success followed by refund.processed) to test how your system handles multi-step payment flows.
  • Test edge cases that real Paystack events rarely produce: missing fields, zero amounts, unusual currencies, extremely long references.
  • The simulator uses the same signing algorithm as Paystack, so your signature verification code works without modification.
  • Keep the simulator as a test helper in your repository. Every developer and CI pipeline should use the same tool.

Frequently Asked Questions

Is a simulated webhook the same as a real Paystack webhook?
The format is identical. A simulated webhook uses the same JSON structure, the same HMAC SHA512 signing algorithm, and the same x-paystack-signature header. Your handler processes it exactly the same way. The only difference is the source: your test script instead of Paystack servers.
Can I use the simulator against my production webhook endpoint?
Technically yes, if you sign with the correct live secret key. But you should never do this. A simulated charge.success with a fake reference could credit a non-existent order or cause data inconsistency. Only use the simulator against local or test environments.
How do I simulate events that my handler does not recognize?
Use the send() method directly with any event type string. For example, send a payload with event set to "some.new.event" and verify your handler returns 200 without crashing. This ensures your handler gracefully ignores unknown events.
Should I commit the simulator to version control?
Yes. Keep it in your test helpers directory alongside your other test utilities. Every developer and every CI pipeline should use the same simulator. This ensures consistent test behavior across environments.
How do I simulate a webhook with an invalid signature?
Send the payload with a hardcoded wrong signature string instead of using the sign() method. For example, set the x-paystack-signature header to "abc123" and verify your handler returns 400. This tests that your signature verification actually rejects forged events.

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