Skip to main content
Webhooks allow Paystack to send real-time notifications to your server when events occur on your account. This guide shows you how to implement webhook handlers securely.

Overview

Webhooks are essential for:
  • Real-time payment confirmations
  • Automatic order fulfillment
  • Subscription management
  • Transfer status updates
  • Dispute notifications
The SDK handles signature verification, payload parsing, and type-safe event handling automatically.

Setup Webhook Handler

Register event handlers for the events you want to process.
1

Initialize the SDK

import { Paystack } from '@efobi/paystack';

const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY!);
2

Register event handlers

paystack.webhook
  .on('charge.success', (data) => {
    console.log('Payment successful:', data.reference);
    console.log('Amount:', data.amount / 100);
    console.log('Customer:', data.customer.email);
    
    // Fulfill order
    fulfillOrder(data.reference);
  })
  .on('transfer.success', (data) => {
    console.log('Transfer completed:', data.reference);
    updateTransferStatus(data.transfer_code, 'success');
  })
  .on('transfer.failed', (data) => {
    console.log('Transfer failed:', data.reference);
    handleTransferFailure(data);
  });
3

Create webhook endpoint

Set up an endpoint to receive webhook requests from Paystack.

Framework Integration

Express.js

import express, { Request, Response } from 'express';
import { Paystack } from '@efobi/paystack';

const app = express();
const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY!);

// Register handlers
paystack.webhook
  .on('charge.success', (data) => {
    console.log('Charge successful:', data.reference);
    fulfillOrder(data.reference, data.amount / 100);
  })
  .on('subscription.disable', (data) => {
    console.log('Subscription disabled:', data.subscription_code);
    deactivateSubscription(data.customer.email);
  });

// IMPORTANT: Use raw body parser for webhook route
app.post(
  '/api/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    try {
      const signature = req.headers['x-paystack-signature'] as string;
      const rawBody = req.body.toString('utf-8');

      // Process webhook
      const payload = await paystack.webhook.process(rawBody, signature);

      console.log('Webhook processed:', payload.event);
      res.status(200).json({ message: 'Webhook received' });
    } catch (error: any) {
      console.error('Webhook error:', error.message);
      res.status(400).json({ message: 'Invalid webhook' });
    }
  }
);

// Other routes use JSON parser
app.use(express.json());

app.listen(3000, () => console.log('Server running on port 3000'));
Use express.raw() middleware for the webhook route to preserve the raw body. This is required for signature verification.

Next.js (App Router)

// app/api/webhooks/paystack/route.ts
import { NextResponse } from 'next/server';
import { Paystack } from '@efobi/paystack';

const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY!);

// Register event handlers
paystack.webhook
  .on('charge.success', async (data) => {
    console.log(`Payment successful: ${data.reference}`);
    
    // Update database
    await db.orders.update(
      { reference: data.reference },
      { 
        status: 'paid',
        paidAt: data.paid_at,
        amount: data.amount / 100
      }
    );
    
    // Send confirmation email
    await sendOrderConfirmation(data.customer.email, data.reference);
  })
  .on('transfer.success', async (data) => {
    console.log(`Transfer successful: ${data.reference}`);
    
    await db.transfers.update(
      { reference: data.reference },
      { status: 'completed' }
    );
  });

export async function POST(req: Request) {
  try {
    const rawBody = await req.text();
    const signature = req.headers.get('x-paystack-signature');
    
    // Process and verify webhook
    const payload = await paystack.webhook.process(rawBody, signature);
    
    return NextResponse.json(
      { message: 'Webhook received', event: payload.event },
      { status: 200 }
    );
  } catch (error: any) {
    console.error('Webhook processing error:', error.message);
    return NextResponse.json(
      { message: 'Invalid webhook signature' },
      { status: 400 }
    );
  }
}

Hono (Bun/Cloudflare Workers)

import { Hono } from 'hono';
import { Paystack } from '@efobi/paystack';

const app = new Hono();
const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY!);

paystack.webhook
  .on('charge.success', (data) => {
    console.log('Payment received:', data.amount / 100);
  })
  .on('refund.processed', (data) => {
    console.log('Refund processed:', data.refund_reference);
  });

app.post('/api/webhooks/paystack', async (c) => {
  try {
    const signature = c.req.header('x-paystack-signature');
    const rawBody = await c.req.text();
    
    const payload = await paystack.webhook.process(rawBody, signature);
    
    return c.json({ message: 'Webhook received' }, 200);
  } catch (error: any) {
    console.error(error.message);
    return c.json({ message: 'Invalid webhook' }, 400);
  }
});

export default app;

Available Events

The SDK provides type-safe handlers for all Paystack webhook events:

Transaction Events

paystack.webhook.on('charge.success', (data) => {
  // data.reference, data.amount, data.customer, etc.
  console.log('Payment successful:', data.reference);
});

Transfer Events

paystack.webhook.on('transfer.success', (data) => {
  console.log('Transfer completed:', data.reference);
  console.log('Recipient:', data.recipient.name);
});

Virtual Account Events

paystack.webhook.on('dedicatedaccount.assign.success', (data) => {
  console.log('Virtual account assigned!');
  console.log('Account:', data.dedicated_account.account_number);
  console.log('Bank:', data.dedicated_account.bank.name);
});

Subscription Events

paystack.webhook.on('subscription.create', (data) => {
  console.log('Subscription created:', data.subscription_code);
  console.log('Next payment:', data.next_payment_date);
});

Refund Events

paystack.webhook.on('refund.pending', (data) => {
  console.log('Refund pending:', data.transaction_reference);
});

Security

The SDK automatically verifies webhook signatures using HMAC SHA-512.

How It Works

  1. Paystack signs each webhook with your secret key
  2. The SDK verifies the signature before processing
  3. Invalid signatures are rejected automatically
// Automatic verification
try {
  const payload = await paystack.webhook.process(rawBody, signature);
  // Signature is valid, process the event
} catch (error) {
  // Signature verification failed
  console.error('Invalid signature:', error.message);
}
Never disable signature verification in production. Always use the raw request body for verification.

Best Practices

  1. Return 200 immediately - Process webhooks asynchronously
  2. Implement idempotency - Handle duplicate webhook deliveries
  3. Log all webhooks - Keep audit trails for debugging
  4. Monitor failures - Set up alerts for webhook errors
  5. Use queues - Process webhooks in background jobs
  6. Verify transactions - Always confirm with API calls when needed
  7. Test thoroughly - Use Paystack’s webhook testing tool
  8. Handle errors gracefully - Don’t let webhook failures break your app

Common Patterns

Asynchronous Processing

import { Queue } from 'bull';

const webhookQueue = new Queue('webhooks');

paystack.webhook.on('charge.success', async (data) => {
  // Queue for background processing
  await webhookQueue.add('payment-success', {
    reference: data.reference,
    amount: data.amount,
    customerEmail: data.customer.email
  });
});

// Process in worker
webhookQueue.process('payment-success', async (job) => {
  const { reference, amount, customerEmail } = job.data;
  
  // Verify transaction (optional but recommended)
  const { data } = await paystack.transaction.verify(reference);
  
  if (data.data.status === 'success') {
    await fulfillOrder(reference);
    await sendReceiptEmail(customerEmail, amount / 100);
  }
});

Idempotent Webhook Handling

paystack.webhook.on('charge.success', async (data) => {
  const reference = data.reference;
  
  // Check if already processed
  const existing = await db.processedWebhooks.findOne({ reference });
  
  if (existing) {
    console.log('Webhook already processed:', reference);
    return; // Skip duplicate
  }
  
  // Process webhook
  await fulfillOrder(reference);
  
  // Mark as processed
  await db.processedWebhooks.create({
    reference,
    event: 'charge.success',
    processedAt: new Date()
  });
});

Error Handling with Retries

paystack.webhook.on('charge.success', async (data) => {
  const maxRetries = 3;
  let attempt = 0;
  
  while (attempt < maxRetries) {
    try {
      await fulfillOrder(data.reference);
      break; // Success, exit loop
    } catch (error) {
      attempt++;
      console.error(`Attempt ${attempt} failed:`, error);
      
      if (attempt === maxRetries) {
        // All retries failed, log for manual intervention
        await db.failedWebhooks.create({
          reference: data.reference,
          event: 'charge.success',
          error: error.message,
          attempts: maxRetries
        });
        
        await notifyAdmin('webhook_processing_failed', {
          reference: data.reference
        });
      } else {
        // Wait before retry (exponential backoff)
        await new Promise(resolve => 
          setTimeout(resolve, Math.pow(2, attempt) * 1000)
        );
      }
    }
  }
});

Complete Order Fulfillment

paystack.webhook.on('charge.success', async (data) => {
  const reference = data.reference;
  
  try {
    // 1. Verify payment amount matches order
    const order = await db.orders.findOne({ reference });
    
    if (!order) {
      console.error('Order not found:', reference);
      return;
    }
    
    if (order.amount !== data.amount / 100) {
      console.error('Amount mismatch:', { 
        expected: order.amount,
        received: data.amount / 100
      });
      return;
    }
    
    // 2. Update order status
    await db.orders.update(
      { reference },
      {
        status: 'paid',
        paidAt: data.paid_at,
        transactionId: data.id,
        paymentChannel: data.channel
      }
    );
    
    // 3. Grant access to product/service
    await grantAccess(order.customerId, order.productId);
    
    // 4. Send confirmation email
    await sendOrderConfirmation(data.customer.email, {
      orderId: order.id,
      amount: data.amount / 100,
      reference
    });
    
    // 5. Notify relevant parties
    await notifyVendor(order.vendorId, 'new_order', order);
    
    console.log('Order fulfilled successfully:', reference);
  } catch (error) {
    console.error('Order fulfillment failed:', error);
    // Queue for manual review
    await db.pendingOrders.create({ reference, error: error.message });
  }
});

Testing Webhooks

Test your webhook handlers locally:
// Create a test webhook payload
const testPayload = {
  event: 'charge.success',
  data: {
    id: 123456,
    reference: 'test-ref-123',
    amount: 50000,
    currency: 'NGN',
    status: 'success',
    customer: {
      email: 'test@example.com',
      first_name: 'John',
      last_name: 'Doe'
    },
    paid_at: new Date().toISOString()
  }
};

// Manually trigger the handler
const handler = paystack.webhook['handlers']['charge.success'];
if (handler) {
  await handler(testPayload.data);
}
Use tools like ngrok to expose your local server for testing with real Paystack webhooks.

Troubleshooting

Webhook Not Received

  1. Check your webhook URL in Paystack Dashboard
  2. Ensure your server is publicly accessible
  3. Verify your server returns 200 OK
  4. Check firewall settings

Signature Verification Failed

  1. Use the raw request body (not parsed JSON)
  2. Verify you’re using the correct secret key
  3. Check for middleware that modifies the body
  4. Ensure the signature header is being read correctly

Duplicate Webhooks

  1. Implement idempotency checks
  2. Use unique references to track processed webhooks
  3. Return 200 OK even for duplicates