Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks: Complete Engineering Guide

Paystack webhooks are HTTP POST requests that Paystack sends to a URL you configure whenever an event happens on your account. Each request carries a JSON payload describing the event and an x-paystack-signature header containing an HMAC SHA512 hash of the body, signed with your secret key. Your server must verify this signature, return 200 immediately, and then process the event.

How Paystack Webhooks Work

Paystack webhooks are event-driven notifications. When something happens on your Paystack account (a charge succeeds, a transfer fails, a subscription renews), Paystack makes an HTTP POST request to a URL you have registered. The request body is JSON. The content type is application/json.

This is the opposite of polling. Instead of your server repeatedly asking Paystack "did anything happen yet?", Paystack tells you the moment something does. That distinction matters because payment events are time-sensitive. A customer who just paid expects their account to update within seconds, not whenever your next polling cycle runs.

Every webhook request includes:

  • A JSON body with an event field (like charge.success) and a data object containing the full transaction, transfer, or subscription details.
  • An x-paystack-signature header containing an HMAC SHA512 hash of the request body, signed with your Paystack secret key.

Here is what a typical webhook payload looks like for a successful charge:

{
  "event": "charge.success",
  "data": {
    "id": 1234567890,
    "domain": "live",
    "status": "success",
    "reference": "your-unique-ref-abc123",
    "amount": 50000,
    "currency": "NGN",
    "channel": "card",
    "customer": {
      "email": "customer@example.com",
      "customer_code": "CUS_xxxxxxxxx"
    },
    "paid_at": "2026-07-20T10:30:00.000Z",
    "metadata": {
      "order_id": "ORD-789"
    }
  }
}

The amount field is always in the smallest currency unit. For NGN, that means kobo. 50000 kobo = 500 Naira. For GHS, it is pesewas. For KES, it is cents. For ZAR, it is cents. This catches people off guard, so check it early.

If you are coming from a callback-based flow and wondering why webhooks matter, the short version: callbacks depend on the customer's browser staying open and redirecting back to your site. Webhooks come server-to-server. The customer can close their browser, lose their connection, or throw their phone into Lake Victoria. The webhook still arrives. Read more about this in why webhooks beat callbacks for Paystack payments.

This guide is part of the complete Paystack engineering guide.

Setting Up Your Webhook URL

You configure your webhook URL in the Paystack Dashboard under Settings > API Keys & Webhooks. There is one URL field. Every event type goes to that single URL.

Requirements:

  • HTTPS only. Paystack will not send webhooks to an HTTP URL. You need a valid SSL certificate. Self-signed certificates will not work.
  • Publicly reachable. The URL must be accessible from the internet. Paystack's servers need to reach it.
  • Returns a 200 status code. Any non-2xx response tells Paystack the delivery failed, and it will retry.

A few practical notes:

  • You only get one webhook URL per Paystack account. If you need to route different events to different services, receive them all at one endpoint and dispatch internally.
  • Test mode and live mode share the same webhook URL setting, but events from test mode carry "domain": "test" in the payload, while live events carry "domain": "live".
  • If you change the URL, it takes effect immediately. There is no propagation delay.

For local development, you will need a tunnel to expose your machine to the internet. We cover that in the testing section below.

Verifying the x-paystack-signature Header

This is the most important part of your webhook handler. The x-paystack-signature header proves the request actually came from Paystack and was not tampered with in transit. If you skip this check, anyone who discovers your webhook URL can send fake payment confirmations and your system will happily grant value for payments that never happened.

The verification process:

  1. Read the raw request body as a string (not a parsed object).
  2. Compute an HMAC SHA512 hash of that raw body, using your Paystack secret key as the key.
  3. Compare the computed hash to the value in the x-paystack-signature header.
  4. If they match, the request is authentic. If not, reject it with a 400 or just silently ignore it.

Here is the Node.js (Express) implementation:

const crypto = require('crypto');
const express = require('express');
const app = express();

// You MUST read the raw body before any JSON parsing middleware.
// Use express.raw() on just the webhook route.
app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.PAYSTACK_SECRET_KEY;
    const signature = req.headers['x-paystack-signature'];

    const hash = crypto
      .createHmac('sha512', secret)
      .update(req.body) // req.body is a Buffer here, not a parsed object
      .digest('hex');

    if (hash !== signature) {
      // Signature mismatch. This request did not come from Paystack.
      return res.status(400).send('Invalid signature');
    }

    // Signature valid. Parse the body and process the event.
    const event = JSON.parse(req.body.toString());
    console.log('Received event:', event.event);

    // Return 200 immediately. Do heavy work in a queue.
    res.status(200).send('OK');

    // Then queue the event for processing
    // processWebhookEvent(event);
  }
);

And the Python (Flask) version:

import hmac
import hashlib
import json
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/paystack', methods=['POST'])
def paystack_webhook():
    secret = os.environ['PAYSTACK_SECRET_KEY'].encode('utf-8')
    signature = request.headers.get('X-Paystack-Signature', '')

    computed = hmac.new(
        secret,
        msg=request.get_data(),  # raw bytes, not parsed JSON
        digestmod=hashlib.sha512
    ).hexdigest()

    if computed != signature:
        return 'Invalid signature', 400

    event = json.loads(request.get_data())
    print(f"Received event: {event['event']}")

    return 'OK', 200

The critical detail in both examples: you compute the hash over the raw bytes, not a re-serialized JSON object. If your framework parses the JSON body and you then JSON.stringify it back, the whitespace or key ordering might differ from what Paystack sent, and the signature will not match. This is the single most common webhook bug. See the raw body problem for a deeper explanation and framework-specific fixes.

The Events That Matter

Paystack sends many event types. Most applications only need a few. Here are the ones you are most likely to handle:

Payment events

Transfer events

  • transfer.success — A transfer (payout) to a bank account was completed.
  • transfer.failed — A transfer failed. The money stays in your Paystack balance.
  • transfer.reversed — A transfer that initially succeeded was reversed by the bank.

If your app sends money out (vendor payouts, withdrawals), you need all three. See handling transfer events.

Subscription events

  • subscription.create — A new subscription was created.
  • subscription.disable — A subscription was cancelled.
  • subscription.not_renew — An upcoming renewal will not be charged (the customer cancelled before the next cycle).
  • invoice.create — An invoice was generated for an upcoming subscription charge.
  • invoice.payment_failed — A subscription renewal charge failed.
  • invoice.update — An invoice was updated (usually after a successful charge).

See handling subscription and invoice events.

Refund events

  • refund.processed — A refund was completed.
  • refund.failed — A refund attempt failed.

See handling refund events.

Other events you may need

For a full reference of every event type Paystack can send, see every Paystack webhook event explained.

The 200 OK Rule

Your webhook endpoint must return a 200 status code quickly. Paystack expects a response within a few seconds. If your endpoint takes too long or returns a non-2xx status, Paystack treats the delivery as failed and will retry.

This means you should never do heavy processing inside the webhook handler itself. Do not send emails, update multiple database tables, call third-party APIs, or generate PDFs before responding. All of that takes time, and if any of it fails or times out, Paystack thinks you did not receive the event.

The correct pattern:

  1. Verify the signature.
  2. Return 200 immediately.
  3. Push the event payload onto a background queue (BullMQ, Celery, Laravel Queues, a database-backed job queue, or even a simple Redis list).
  4. A separate worker picks up the event and does the real work.
app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // 1. Verify signature (same as above, omitted for brevity)

    // 2. Return 200 FIRST
    res.status(200).send('OK');

    // 3. Queue the work
    const event = JSON.parse(req.body.toString());
    await webhookQueue.add('paystack-event', {
      event: event.event,
      data: event.data,
      receivedAt: new Date().toISOString(),
    });
  }
);

If you cannot set up a queue right away, at minimum write the raw payload to a database row and have a separate process pick it up. The point is: acknowledge receipt before doing anything that could fail or take time.

For a deeper look at why this matters and what happens when you break this rule, see why you must return 200 immediately. For queue-specific guides, see the BullMQ, Celery, and Laravel Queues articles linked in the cluster overview below.

Idempotency: Handle Duplicate Events

Paystack can send the same event more than once. This happens during retries, network hiccups, or internal recovery processes. If your handler grants value every time it receives a charge.success for the same reference, you will credit a customer's account twice (or more) for a single payment.

The fix is idempotency: make your handler produce the same result whether it processes an event once or five times.

The simplest approach: use the transaction reference as a deduplication key.

async function processChargeSuccess(data) {
  const { reference, amount, currency } = data;

  // Check if we already processed this reference
  const existing = await db.query(
    'SELECT id FROM processed_webhooks WHERE reference = $1',
    [reference]
  );

  if (existing.rows.length > 0) {
    console.log(`Already processed reference ${reference}, skipping.`);
    return;
  }

  // Process the payment (credit the user, fulfill the order, etc.)
  await db.query('BEGIN');
  try {
    await db.query(
      'INSERT INTO processed_webhooks (reference, event, processed_at) VALUES ($1, $2, NOW())',
      [reference, 'charge.success']
    );
    await db.query(
      'UPDATE orders SET status = $1, paid_at = NOW() WHERE payment_reference = $2',
      ['paid', reference]
    );
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    throw err;
  }
}

The key detail: the insert into processed_webhooks and the business logic update happen in the same database transaction. If the order update fails, the deduplication record also rolls back, so a retry will correctly try again. If both succeed, a duplicate event will hit the early return.

You can also add a unique constraint on the reference column in processed_webhooks so that a duplicate insert fails at the database level even if your application-level check has a race condition.

For the full pattern with edge cases, see idempotent Paystack webhook handlers.

Testing Webhooks Locally

Your local machine is not reachable from the internet. Paystack cannot POST to http://localhost:3000. You need a tunnel that gives your local server a public URL.

Option 1: ngrok

# Install ngrok, then:
ngrok http 3000

# Output:
# Forwarding  https://abc123.ngrok-free.app -> http://localhost:3000

Copy the https:// URL and paste it into the Paystack Dashboard as your webhook URL (add your path, e.g., https://abc123.ngrok-free.app/webhooks/paystack). See testing with ngrok for the full walkthrough.

Option 2: Cloudflare Tunnel

# Install cloudflared, then:
cloudflared tunnel --url http://localhost:3000

# Output:
# Your quick tunnel: https://random-words.trycloudflare.com

Same idea. Copy the URL, set it in the dashboard. No account required for quick tunnels. See testing with Cloudflare Tunnel.

Option 3: Simulate locally without a tunnel

If you do not want to route live Paystack events to your machine, you can simulate webhook requests yourself using curl or a test script:

SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000,"currency":"NGN","status":"success"}}'
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"

This generates a valid signature for your test secret key, so your verification code will pass. It is useful for unit testing and CI pipelines where you do not have a public URL at all. See simulating webhook events for automated tests and testing in CI without a public URL for the full approach.

For more general testing strategies, see the Paystack test mode guide.

Common Webhook Failures

When your webhook stops working, it is almost always one of these problems:

1. Signature verification fails because the body was parsed

This is the most common bug by far. Frameworks like Express (with express.json()), Django, Laravel, and Rails parse the request body into an object automatically. When you try to verify the signature, you are hashing a re-serialized version of the body, not the original bytes Paystack sent. The hashes will never match.

The fix: read the raw body before any middleware parses it. In Express, use express.raw() on the webhook route. In Django, use request.body. In Laravel, use file_get_contents('php://input'). See the raw body problem for framework-specific solutions.

2. Reverse proxy modifies the body

If you are behind Nginx, Apache, or a CDN, the proxy might modify the request body (gzip re-encoding, character set conversion, or adding/removing whitespace). The body that reaches your application is different from what Paystack signed. The fix depends on your proxy config. See webhooks behind Nginx and a reverse proxy.

3. CSRF protection blocks the POST

Django, Laravel, and Rails all have CSRF protection enabled by default. Paystack's webhook POST will not carry a CSRF token, so the framework rejects it with a 403 or 419 before your handler even runs.

4. The endpoint returns 200 but does not process the event

Your handler runs, returns 200, and silently fails somewhere in the processing logic. The payment goes through but the user's account is not credited. This is a silent failure, and it is worse than a loud error because Paystack thinks the delivery succeeded and will not retry. The fix: log every event you receive, and build alerts for events received but not processed. See monitoring and alerting on webhook failures.

5. Serverless function cold starts exceed the timeout

On Vercel, Netlify, or AWS Lambda, a cold start can add several seconds to your response time. If the total time exceeds Paystack's timeout window, it treats the delivery as failed. Keep your webhook handler function lightweight and minimize dependencies to reduce cold start times. See the platform-specific guides: Vercel, Netlify, AWS Lambda, Cloudflare Workers.

For a wider view of Paystack errors and debugging, see the errors and troubleshooting reference.

What This Cluster Covers

This article is the hub for the webhooks cluster. The spoke articles go deeper on every topic mentioned above:

Fundamentals

Event handling

Reliability patterns

Testing

Platform-specific deployment

Framework-specific CSRF fixes

Monitoring and operations

McTaba Academy courses at academy.mctaba.com. Pick the skill you need, learn on your schedule.

Key Takeaways

  • Paystack sends a POST request with a JSON body and an x-paystack-signature header for every event on your account.
  • You verify the signature by computing HMAC SHA512 of the raw request body using your secret key and comparing it to the header value.
  • Your webhook endpoint must return 200 within a few seconds. Queue any heavy work for background processing.
  • The same event can arrive more than once. Deduplicate by the transaction reference or event ID before granting value.
  • Most webhook signature failures come from frameworks that parse the body before you can read it raw.

Frequently Asked Questions

How many times does Paystack retry a failed webhook?
Paystack retries failed webhook deliveries several times over a period of hours. The exact retry schedule and maximum number of attempts are not publicly documented with fixed numbers, so check the Paystack documentation or your dashboard logs for the current behavior. Each retry sends the same payload. Your handler must be idempotent to handle this safely.
Can I allowlist Paystack webhook IPs on my firewall?
Paystack does publish IP addresses that their webhook requests originate from. You can add these to your firewall or server allowlist as an extra layer of security on top of signature verification. However, these IPs can change, so always treat signature verification as the primary authentication method and check the Paystack documentation for the current list.
Where can I find a list of all Paystack webhook events?
The official Paystack API documentation lists all supported webhook events. Common events include charge.success, transfer.success, transfer.failed, transfer.reversed, subscription.create, subscription.disable, invoice.create, invoice.payment_failed, refund.processed, refund.failed, dedicatedaccount.assign.success, and dispute.create. The full list may grow as Paystack adds features.
How do I test Paystack webhooks without deploying to a server?
Use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local development server to the internet. Set the tunnel URL as your webhook URL in the Paystack Dashboard. Alternatively, skip the tunnel entirely and simulate webhook requests using curl or a test script that computes a valid HMAC SHA512 signature with your test secret key.
Why does my Django or Laravel app return 403 or 419 for Paystack webhooks?
Both Django and Laravel have CSRF protection enabled by default. Paystack webhook POSTs do not include a CSRF token, so the framework rejects them before your handler runs. In Django, decorate the view with @csrf_exempt. In Laravel, add the webhook route to the $except array in the VerifyCsrfToken middleware. In Rails, skip the verify_authenticity_token callback for the webhook action.

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