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.
Initialize the SDK
import { Paystack } from '@efobi/paystack' ;
const paystack = new Paystack ( process . env . PAYSTACK_SECRET_KEY ! );
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 );
});
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
charge.success
charge.dispute.create
charge.dispute.resolve
paystack . webhook . on ( 'charge.success' , ( data ) => {
// data.reference, data.amount, data.customer, etc.
console . log ( 'Payment successful:' , data . reference );
});
Transfer Events
transfer.success
transfer.failed
transfer.reversed
paystack . webhook . on ( 'transfer.success' , ( data ) => {
console . log ( 'Transfer completed:' , data . reference );
console . log ( 'Recipient:' , data . recipient . name );
});
Virtual Account Events
dedicatedaccount.assign.success
dedicatedaccount.assign.failed
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
subscription.create
subscription.disable
subscription.not_renew
paystack . webhook . on ( 'subscription.create' , ( data ) => {
console . log ( 'Subscription created:' , data . subscription_code );
console . log ( 'Next payment:' , data . next_payment_date );
});
Refund Events
refund.pending
refund.processed
refund.failed
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
Paystack signs each webhook with your secret key
The SDK verifies the signature before processing
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
Return 200 immediately - Process webhooks asynchronously
Implement idempotency - Handle duplicate webhook deliveries
Log all webhooks - Keep audit trails for debugging
Monitor failures - Set up alerts for webhook errors
Use queues - Process webhooks in background jobs
Verify transactions - Always confirm with API calls when needed
Test thoroughly - Use Paystack’s webhook testing tool
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
Check your webhook URL in Paystack Dashboard
Ensure your server is publicly accessible
Verify your server returns 200 OK
Check firewall settings
Signature Verification Failed
Use the raw request body (not parsed JSON)
Verify you’re using the correct secret key
Check for middleware that modifies the body
Ensure the signature header is being read correctly
Duplicate Webhooks
Implement idempotency checks
Use unique references to track processed webhooks
Return 200 OK even for duplicates