Bonaventure OgetoBy Bonaventure Ogeto|

Testing Paystack Webhooks in CI Without a Public URL

In CI, you do not need Paystack to send real webhooks. Instead, build test payloads that match the Paystack webhook format, compute a valid HMAC SHA512 signature using your test secret key, and POST them to your webhook handler running on localhost. This tests your signature verification, event routing, idempotency, and business logic without any external dependency.

The CI Problem

During local development, you can use ngrok or Cloudflare Tunnel to receive real Paystack webhooks. In CI, this approach falls apart.

Your CI runner (GitHub Actions, GitLab CI, Jenkins, CircleCI) is an ephemeral container or VM. It has no stable public IP. It exists for the duration of the pipeline and then gets destroyed. Setting up a tunnel adds complexity, latency, and a dependency on external services. If ngrok's servers are slow or Cloudflare has a hiccup, your entire CI pipeline fails for reasons that have nothing to do with your code.

The better approach: do not involve Paystack at all. Your webhook handler is just an HTTP endpoint that expects a specific request format and a valid HMAC signature. You can generate both yourself. The handler does not know or care whether the request came from Paystack's servers or from a curl command on the same machine.

This is not a shortcut. It is actually a more thorough test. You control every field in the payload, so you can test edge cases that real Paystack events might never produce: missing fields, zero-amount charges, unusual currencies, extremely long references.

This article is part of the Paystack webhooks engineering guide.

Generating Valid HMAC Signatures

Paystack signs every webhook payload by computing an HMAC SHA512 hash of the raw JSON body using your secret key. To simulate a webhook in CI, you do the same thing.

Node.js

const crypto = require('crypto');

function signPayload(jsonBody, secretKey) {
  return crypto
    .createHmac('sha512', secretKey)
    .update(jsonBody)
    .digest('hex');
}

// Usage
const body = JSON.stringify({
  event: 'charge.success',
  data: {
    reference: 'test-ref-001',
    amount: 50000,
    currency: 'NGN',
    status: 'success',
    customer: { email: 'test@example.com' },
  },
});

const signature = signPayload(body, process.env.PAYSTACK_SECRET_KEY);
// signature is a 128-character hex string

Python

import hmac
import hashlib
import json

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

body = json.dumps({
    'event': 'charge.success',
    'data': {
        'reference': 'test-ref-001',
        'amount': 50000,
        'currency': 'NGN',
        'status': 'success',
        'customer': {'email': 'test@example.com'},
    },
})

signature = sign_payload(body, os.environ['PAYSTACK_SECRET_KEY'])

Bash (curl + openssl)

SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000,"currency":"NGN","status":"success","customer":{"email":"test@example.com"}}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/paystack   -H "Content-Type: application/json"   -H "x-paystack-signature: $SIGNATURE"   -d "$BODY"

The key detail: the signature is computed over the exact bytes of the body string. If you change even one character (add a space, reorder a key), the signature changes. Use the same string for both signing and sending.

Building a Webhook Payload Factory

Hardcoding payloads in every test is tedious and fragile. Build a factory function that generates realistic payloads for each event type:

// test/helpers/paystack-webhook-factory.js
const crypto = require('crypto');

const SECRET = process.env.PAYSTACK_SECRET_KEY || 'sk_test_placeholder';

function createWebhookPayload(eventType, overrides) {
  const defaults = {
    'charge.success': {
      event: 'charge.success',
      data: {
        id: Math.floor(Math.random() * 1000000000),
        domain: 'test',
        status: 'success',
        reference: 'ref-' + Date.now() + '-' + Math.random().toString(36).substring(7),
        amount: 50000,
        currency: 'NGN',
        channel: 'card',
        customer: {
          email: 'test@example.com',
          customer_code: 'CUS_test123',
        },
        paid_at: new Date().toISOString(),
        metadata: {},
      },
    },
    'transfer.success': {
      event: 'transfer.success',
      data: {
        domain: 'test',
        status: 'success',
        reference: 'transfer-' + Date.now(),
        amount: 100000,
        currency: 'NGN',
        recipient: {
          name: 'Test Vendor',
          account_number: '0123456789',
          bank_code: '058',
        },
      },
    },
    'transfer.failed': {
      event: 'transfer.failed',
      data: {
        domain: 'test',
        status: 'failed',
        reference: 'transfer-' + Date.now(),
        amount: 100000,
        currency: 'NGN',
        reason: 'Could not resolve account',
      },
    },
    'refund.processed': {
      event: 'refund.processed',
      data: {
        id: Math.floor(Math.random() * 1000000),
        status: 'processed',
        transaction: {
          reference: 'original-ref-' + Date.now(),
        },
        deducted_amount: 50000,
        currency: 'NGN',
      },
    },
  };

  const base = defaults[eventType];
  if (!base) {
    throw new Error('Unknown event type: ' + eventType);
  }

  // Deep merge overrides
  const payload = JSON.parse(JSON.stringify(base));
  if (overrides) {
    Object.assign(payload.data, overrides);
  }

  return payload;
}

function signAndSend(payload) {
  const body = JSON.stringify(payload);
  const signature = crypto
    .createHmac('sha512', SECRET)
    .update(body)
    .digest('hex');

  return { body, signature };
}

module.exports = { createWebhookPayload, signAndSend };

Using the factory in a test:

const { createWebhookPayload, signAndSend } = require('./helpers/paystack-webhook-factory');

const payload = createWebhookPayload('charge.success', {
  reference: 'order-abc-123',
  amount: 75000, // 750 NGN
});

const { body, signature } = signAndSend(payload);

// Send to your webhook endpoint
const response = await fetch('http://localhost:3000/webhooks/paystack', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-paystack-signature': signature,
  },
  body: body,
});

expect(response.status).toBe(200);

The Full Integration Test Pattern

A proper CI test for your webhook handler should verify the complete chain: HTTP request in, signature check, event routing, database changes, idempotency. Here is a complete example using Jest and supertest:

// test/webhooks/paystack.integration.test.js
const request = require('supertest');
const crypto = require('crypto');
const { Pool } = require('pg');
const app = require('../../src/app');

const SECRET = process.env.PAYSTACK_SECRET_KEY;
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });

function buildSignedRequest(payload) {
  const body = JSON.stringify(payload);
  const signature = crypto
    .createHmac('sha512', SECRET)
    .update(body)
    .digest('hex');
  return { body, signature };
}

beforeEach(async () => {
  // Clean up test data
  await pool.query('DELETE FROM processed_events');
  await pool.query('DELETE FROM orders');
  // Seed a pending order
  await pool.query(
    'INSERT INTO orders (payment_reference, status, amount) VALUES ($1, $2, $3)',
    ['order-ref-001', 'pending', 50000]
  );
});

afterAll(async () => {
  await pool.end();
});

describe('POST /webhooks/paystack', () => {
  it('returns 400 for invalid signature', async () => {
    const response = await request(app)
      .post('/webhooks/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', 'invalid-signature')
      .send('{"event":"charge.success","data":{}}');

    expect(response.status).toBe(400);
  });

  it('processes charge.success and updates order', async () => {
    const payload = {
      event: 'charge.success',
      data: {
        reference: 'order-ref-001',
        amount: 50000,
        currency: 'NGN',
        status: 'success',
        customer: { email: 'buyer@example.com' },
      },
    };
    const { body, signature } = buildSignedRequest(payload);

    const response = await request(app)
      .post('/webhooks/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', signature)
      .send(body);

    expect(response.status).toBe(200);

    // Wait briefly for async processing
    await new Promise(resolve => setTimeout(resolve, 500));

    // Verify the order was updated
    const order = await pool.query(
      'SELECT status FROM orders WHERE payment_reference = $1',
      ['order-ref-001']
    );
    expect(order.rows[0].status).toBe('paid');
  });

  it('handles duplicate charge.success idempotently', async () => {
    const payload = {
      event: 'charge.success',
      data: {
        reference: 'order-ref-001',
        amount: 50000,
        currency: 'NGN',
        status: 'success',
        customer: { email: 'buyer@example.com' },
      },
    };
    const { body, signature } = buildSignedRequest(payload);

    // First delivery
    await request(app)
      .post('/webhooks/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', signature)
      .send(body);

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

    // Second delivery (duplicate)
    await request(app)
      .post('/webhooks/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', signature)
      .send(body);

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

    // Verify only one processed_events record
    const events = await pool.query(
      'SELECT COUNT(*)::int as count FROM processed_events WHERE idempotency_key = $1',
      ['order-ref-001']
    );
    expect(events.rows[0].count).toBe(1);
  });
});

This test suite covers the three most important behaviors: signature rejection, successful processing, and idempotency. Add more tests for each event type your handler supports.

GitHub Actions Setup

Here is a GitHub Actions workflow that runs your webhook integration tests with a real PostgreSQL database:

# .github/workflows/webhook-tests.yml
name: Webhook Integration Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      PAYSTACK_SECRET_KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      TEST_DATABASE_URL: postgres://test:testpass@localhost:5432/testdb

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Run database migrations
        run: npm run migrate

      - name: Run webhook tests
        run: npx jest test/webhooks/ --forceExit

Important details:

  • The PAYSTACK_SECRET_KEY is a test key. It never touches real money. You can put it directly in the workflow file or store it as a GitHub Actions secret for an extra layer of caution.
  • The PostgreSQL service container runs on the same runner as your tests. Connection is via localhost.
  • Run your database migrations before running tests so the processed_events and orders tables exist.

For GitLab CI, the pattern is similar. Use the services keyword for PostgreSQL:

# .gitlab-ci.yml
webhook-tests:
  image: node:20
  services:
    - postgres:15
  variables:
    POSTGRES_USER: test
    POSTGRES_PASSWORD: testpass
    POSTGRES_DB: testdb
    PAYSTACK_SECRET_KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    TEST_DATABASE_URL: postgres://test:testpass@postgres:5432/testdb
  script:
    - npm ci
    - npm run migrate
    - npx jest test/webhooks/ --forceExit

Testing Edge Cases in CI

CI is the best place to test edge cases that you cannot easily reproduce with real Paystack events. Since you control the payload, you can craft unusual scenarios:

Missing optional fields

// Test: customer object without customer_code
const payload = {
  event: 'charge.success',
  data: {
    reference: 'edge-case-001',
    amount: 50000,
    currency: 'NGN',
    status: 'success',
    customer: { email: 'test@example.com' },
    // No customer_code, no metadata
  },
};
// Your handler should not crash on missing fields

Zero-amount transactions

// Test: amount is 0 (can happen with certain plan trials)
const payload = {
  event: 'charge.success',
  data: {
    reference: 'edge-case-002',
    amount: 0,
    currency: 'NGN',
    status: 'success',
    customer: { email: 'trial@example.com' },
  },
};
// Should your handler grant value for a zero-amount charge?

Unknown event types

// Test: event type your handler does not recognize
const payload = {
  event: 'some.future.event',
  data: { id: 12345 },
};
// Your handler should log it and return 200, not crash

Concurrent duplicate delivery

// Test: two identical webhooks arriving at the same time
const payload = {
  event: 'charge.success',
  data: {
    reference: 'concurrent-ref-001',
    amount: 50000,
    currency: 'NGN',
    status: 'success',
    customer: { email: 'test@example.com' },
  },
};
const { body, signature } = buildSignedRequest(payload);

// Fire both requests simultaneously
const [res1, res2] = await Promise.all([
  request(app)
    .post('/webhooks/paystack')
    .set('Content-Type', 'application/json')
    .set('x-paystack-signature', signature)
    .send(body),
  request(app)
    .post('/webhooks/paystack')
    .set('Content-Type', 'application/json')
    .set('x-paystack-signature', signature)
    .send(body),
]);

// Both should return 200
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);

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

// But the order should only be processed once
const events = await pool.query(
  'SELECT COUNT(*)::int as count FROM processed_events WHERE idempotency_key = $1',
  ['concurrent-ref-001']
);
expect(events.rows[0].count).toBe(1);

These tests catch bugs that production traffic might not surface for months. Run them on every pull request.

Separating Unit Tests from Integration Tests

Not every webhook test needs a database and an HTTP server. Separate your tests into two layers:

Unit tests test individual functions in isolation. They are fast and do not need external services:

  • Does verifySignature(body, signature, secret) return true for a valid signature?
  • Does verifySignature(body, wrongSignature, secret) return false?
  • Does extractIdempotencyKey(event) return the correct key for each event type?
  • Does your event router call the right handler for each event type?

Integration tests test the full chain. They need the database, the HTTP server, and realistic payloads:

  • Does a signed POST to /webhooks/paystack return 200 and update the database?
  • Does a duplicate POST skip processing?
  • Does a POST with an invalid signature return 400?
// Unit test: no database, no HTTP server
describe('verifySignature', () => {
  const secret = 'sk_test_abc123';

  it('returns true for a valid signature', () => {
    const body = '{"event":"charge.success"}';
    const hash = crypto
      .createHmac('sha512', secret)
      .update(body)
      .digest('hex');

    expect(verifySignature(body, hash, secret)).toBe(true);
  });

  it('returns false for a tampered body', () => {
    const originalBody = '{"event":"charge.success"}';
    const hash = crypto
      .createHmac('sha512', secret)
      .update(originalBody)
      .digest('hex');

    const tamperedBody = '{"event":"charge.success","data":{"amount":0}}';
    expect(verifySignature(tamperedBody, hash, secret)).toBe(false);
  });

  it('returns false for a wrong secret', () => {
    const body = '{"event":"charge.success"}';
    const hash = crypto
      .createHmac('sha512', 'wrong_secret')
      .update(body)
      .digest('hex');

    expect(verifySignature(body, hash, secret)).toBe(false);
  });
});

Run unit tests on every commit. Run integration tests on pull requests and before deploys. This keeps your CI pipeline fast for small changes while still catching integration issues before they reach production.

For more on simulating events, see simulating Paystack webhook events for automated tests. For the full testing strategy, see the webhooks engineering guide.

Key Takeaways

  • CI servers have no public URL. You cannot use ngrok or Cloudflare Tunnel in a pipeline. Generate valid HMAC-signed payloads locally instead.
  • Compute the x-paystack-signature header by HMAC SHA512 hashing the raw JSON body with your test secret key. This is exactly what Paystack does.
  • Build a webhook payload factory that generates realistic test payloads for each event type with configurable fields.
  • Test the full request cycle: HTTP POST to your webhook endpoint, signature verification, event routing, database changes, and idempotency.
  • Keep your test secret key in CI environment variables. Never hardcode secret keys in test files.
  • Run your app server and database in CI (Docker Compose or service containers), then hit the webhook endpoint with curl or a test HTTP client.

Frequently Asked Questions

Do I need ngrok or a tunnel to test Paystack webhooks in CI?
No. In CI, generate valid HMAC-signed payloads locally and POST them to your webhook handler running on localhost. This is faster, more reliable, and lets you test edge cases that real Paystack events cannot produce. Save tunnels for manual testing during local development.
Is it safe to put my Paystack test secret key in CI environment variables?
Yes. Test keys (sk_test_) cannot process real payments. Store them as CI secrets (GitHub Actions secrets, GitLab CI variables) for good practice, but even if exposed, they only affect the test environment. Never put live keys in CI.
How do I test webhook idempotency in CI?
Send the same signed payload twice to your webhook endpoint and verify that the business effect (database update, processed_events record) only happens once. For concurrent duplicate testing, send both requests simultaneously using Promise.all and check the results.
Should I test every Paystack event type in CI?
Test every event type your handler actually processes. If your handler only cares about charge.success and transfer events, test those. Also test that unknown event types return 200 without crashing. You do not need to test event types you intentionally ignore.
How do I run a database in CI for webhook integration tests?
Use service containers. GitHub Actions and GitLab CI both support running PostgreSQL as a service alongside your test runner. The database runs on the same network as your tests, so you connect via localhost (GitHub Actions) or the service name (GitLab CI). Run your schema migrations before the tests.

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