McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Simulating Paystack Webhook Events for Automated Tests
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. Why Simulate Instead of Using Real Events
  2. 2. The Core Simulator in Node.js
  3. 3. The Simulator in Python
  4. 4. Simulating Event Sequences
  5. 5. Edge Case Payloads Worth Testing
  6. 6. Bash One-Liner for Quick Manual Tests

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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Webhooks: Complete Engineering Guide

Everything you need to receive, verify, and process Paystack webhook events in production. Covers signature verification, the 200 OK rule, idempotency, local testing, and the failures that trip up most teams.

Read more

Handling customeridentification.success and Failure Events

A practical guide to handling Paystack customer identification webhook events. Covers BVN verification, identity validation payloads, updating KYC status in your database, and recovering from failed verification attempts.

Read more

Storing Raw Paystack Webhook Payloads for Audit

Store every raw Paystack webhook payload before processing it. This gives you an audit trail for compliance, a debugging tool for production issues, and a safety net for replaying failed events. Covers database schema, store-then-process pattern, retention policies, and querying for investigations.

Read more

Paystack Webhooks on Cloudflare Workers

A complete guide to handling Paystack webhooks on Cloudflare Workers. Covers the Web Crypto API for HMAC-SHA512 signature verification (since Workers do not have Node.js crypto), request body handling, environment secrets, and a production-ready Worker you can deploy in minutes.

Read more

Building a Fully Custom Checkout on the Paystack Charge API

When the hosted checkout does not fit your product, you can build every piece of the payment experience yourself. This guide walks through designing the form, posting card or bank data to your server, running the Charge API flow, and shipping a production-ready checkout.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.