Overview
Webhooks provide real-time notifications about transaction status changes, eliminating the need for polling. When a transaction completes, fails, or changes status, FundKit sends an HTTP POST request to your configured endpoint with the updated information.
Webhooks are the recommended way to handle transaction status updates. They’re faster, more efficient, and provide better user experience than polling for status changes.
Why Use Webhooks?
Real-time Updates Get notified instantly when transactions complete, fail, or change status
Reduced API Calls No need to poll for status updates - webhooks push updates to you
Better Performance Lower latency and reduced server load compared to polling
Reliable Delivery Built-in retry mechanisms ensure webhook delivery
Webhook Flow
Configure Webhook URL
Set your webhook endpoint URL in provider configuration
Transaction Event Occurs
Payment completes, fails, or status changes
Webhook Sent
FundKit sends HTTP POST to your endpoint with event data
Verify & Process
Your server verifies signature and processes the event
Respond with 200
Acknowledge receipt with HTTP 200 status code
Configuration
Provider-Specific Setup
import { HoneyCoin } from '@fundkit/honeycoin' ;
const honeycoin = new HoneyCoin ({
apiKey: process . env . HONEYCOIN_API_KEY ! ,
publicKey: process . env . HONEYCOIN_PUBLIC_KEY ! ,
webhookUrl: 'https://api.myapp.com/webhooks/honeycoin' ,
webhookSecret: process . env . HONEYCOIN_WEBHOOK_SECRET
});
Multiple Webhook Endpoints
Configure different endpoints for different event types:
const tola = new Tola ({
apiKey: process . env . TOLA_API_KEY ! ,
merchantId: process . env . TOLA_MERCHANT_ID ! ,
// Multiple webhook endpoints
webhookEndpoints: [
{
url: 'https://api.myapp.com/webhooks/payments' ,
events: [ 'transaction.completed' , 'transaction.failed' ],
secret: process . env . PAYMENT_WEBHOOK_SECRET
},
{
url: 'https://api.myapp.com/webhooks/reconciliation' ,
events: [ 'reconciliation.ready' , 'settlement.completed' ],
secret: process . env . RECON_WEBHOOK_SECRET
}
]
});
Webhook Events
Common Event Types
Triggered when: Payment is successfully completedTypical timing: 30 seconds to 5 minutes after initiationAction required: Update order status, fulfill service, send confirmation{
"type" : "transaction.completed" ,
"data" : {
"transactionId" : "txn_abc123" ,
"externalId" : "order_12345" ,
"status" : "completed" ,
"amount" : 10000 ,
"currency" : "UGX" ,
"completedAt" : "2024-01-15T10:30:00Z" ,
"fees" : 100 ,
"netAmount" : 9900
}
}
Triggered when: Payment fails for any reasonTypical timing: Immediately to 5 minutes after initiationAction required: Update order status, notify customer, log for analysis{
"type" : "transaction.failed" ,
"data" : {
"transactionId" : "txn_abc123" ,
"externalId" : "order_12345" ,
"status" : "failed" ,
"amount" : 10000 ,
"currency" : "UGX" ,
"failedAt" : "2024-01-15T10:25:00Z" ,
"errorCode" : "INSUFFICIENT_FUNDS" ,
"errorMessage" : "Customer has insufficient balance"
}
}
Triggered when: Payment is initiated and waiting for customer actionTypical timing: Immediately after payment initiationAction required: Show pending status to customer{
"type" : "transaction.pending" ,
"data" : {
"transactionId" : "txn_abc123" ,
"externalId" : "order_12345" ,
"status" : "pending" ,
"amount" : 10000 ,
"currency" : "UGX" ,
"pendingAt" : "2024-01-15T10:20:00Z"
}
}
Triggered when: Payment times out waiting for customerTypical timing: 5-10 minutes after initiationAction required: Update status, allow retry, notify customer{
"type" : "transaction.timeout" ,
"data" : {
"transactionId" : "txn_abc123" ,
"externalId" : "order_12345" ,
"status" : "timeout" ,
"amount" : 10000 ,
"currency" : "UGX" ,
"timeoutAt" : "2024-01-15T10:30:00Z"
}
}
Provider-Specific Events
HoneyCoin Events EasyPay Events Tola Events
transaction.completed
transaction.failed
transaction.pending
transaction.timeout
payout.completed
payout.failed
reconciliation.ready
Webhook Implementation
Basic Express.js Handler
import express from 'express' ;
import crypto from 'crypto' ;
const app = express ();
// Important: Use raw body parser for webhook signature verification
app . use ( '/webhooks' , express . raw ({ type: 'application/json' }));
app . post ( '/webhooks/:provider' , async ( req , res ) => {
const provider = req . params . provider ;
const signature = req . headers [ 'x-webhook-signature' ] || req . headers [ 'x-' + provider + '-signature' ];
const timestamp = req . headers [ 'x-webhook-timestamp' ];
const payload = req . body ;
try {
// Verify webhook signature
if ( ! verifyWebhookSignature ( provider , payload , signature , timestamp )) {
console . error ( 'Invalid webhook signature' );
return res . status ( 400 ). json ({ error: 'Invalid signature' });
}
// Parse webhook data
const event = JSON . parse ( payload . toString ());
console . log ( `Received ${ provider } webhook:` , event . type );
// Process the webhook
await processWebhook ( provider , event );
// Always respond with 200 for successful processing
res . status ( 200 ). json ({
received: true ,
processed: true ,
timestamp: new Date (). toISOString ()
});
} catch ( error ) {
console . error ( 'Webhook processing error:' , error );
// Return 500 to trigger provider retry
res . status ( 500 ). json ({
error: 'Processing failed' ,
retry: true
});
}
});
function verifyWebhookSignature (
provider : string ,
payload : Buffer ,
signature : string ,
timestamp : string
) : boolean {
const secret = getWebhookSecret ( provider );
// Check timestamp to prevent replay attacks (5 minute window)
const now = Math . floor ( Date . now () / 1000 );
const webhookTimestamp = parseInt ( timestamp );
if ( Math . abs ( now - webhookTimestamp ) > 300 ) {
console . error ( 'Webhook timestamp too old' );
return false ;
}
// Calculate expected signature
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( ` ${ timestamp } . ${ payload } ` )
. digest ( 'hex' );
// Use timing-safe comparison
return crypto . timingSafeEqual (
Buffer . from ( signature . replace ( 'sha256=' , '' ), 'hex' ),
Buffer . from ( expectedSignature , 'hex' )
);
}
function getWebhookSecret ( provider : string ) : string {
switch ( provider ) {
case 'honeycoin' :
return process . env . HONEYCOIN_WEBHOOK_SECRET ! ;
case 'easypay' :
return process . env . EASYPAY_WEBHOOK_SECRET ! ;
case 'tola' :
return process . env . TOLA_WEBHOOK_SECRET ! ;
default :
throw new Error ( `Unknown provider: ${ provider } ` );
}
}
Robust Webhook Processor
class WebhookProcessor {
private readonly retryableErrors = [
'DATABASE_CONNECTION_ERROR' ,
'EXTERNAL_SERVICE_UNAVAILABLE' ,
'TEMPORARY_FAILURE'
];
async processWebhook ( provider : string , event : WebhookEvent ) {
const startTime = Date . now ();
try {
// Idempotency check
const existingEvent = await this . getProcessedEvent ( event . id );
if ( existingEvent ) {
console . log ( 'Event already processed:' , event . id );
return { processed: true , duplicate: true };
}
// Mark event as processing
await this . markEventProcessing ( event . id );
// Route to appropriate handler
await this . routeEvent ( provider , event );
// Mark as successfully processed
await this . markEventProcessed ( event . id , {
processedAt: new Date (),
processingTime: Date . now () - startTime
});
console . log ( `Webhook processed successfully: ${ event . id } ` );
return { processed: true };
} catch ( error ) {
console . error ( 'Webhook processing failed:' , error );
// Mark as failed
await this . markEventFailed ( event . id , error );
// Determine if error is retryable
if ( this . isRetryableError ( error )) {
throw error ; // This will trigger provider retry
}
// Non-retryable error - acknowledge to prevent retries
return { processed: false , error: error . message };
}
}
private async routeEvent ( provider : string , event : WebhookEvent ) {
const eventKey = ` ${ provider } . ${ event . type } ` ;
switch ( eventKey ) {
case 'honeycoin.transaction.completed' :
case 'easypay.payment.completed' :
case 'tola.transaction.completed' :
await this . handleTransactionCompleted ( event . data );
break ;
case 'honeycoin.transaction.failed' :
case 'easypay.payment.failed' :
case 'tola.transaction.failed' :
await this . handleTransactionFailed ( event . data );
break ;
case 'tola.escrow.released' :
await this . handleEscrowReleased ( event . data );
break ;
case 'tola.reconciliation.ready' :
await this . handleReconciliationReady ( event . data );
break ;
default :
console . log ( 'Unhandled webhook event:' , eventKey );
await this . logUnhandledEvent ( provider , event );
}
}
private async handleTransactionCompleted ( transaction : any ) {
console . log ( 'Processing completed transaction:' , transaction . externalId );
// Update database
await this . updateTransactionStatus ( transaction . externalId , 'completed' , {
completedAt: transaction . completedAt ,
fees: transaction . fees ,
netAmount: transaction . netAmount ,
providerReference: transaction . providerReference
});
// Send customer notification
await this . sendCustomerNotification ( transaction , 'success' );
// Fulfill order/service
await this . fulfillOrder ( transaction . externalId );
// Update analytics
await this . updateAnalytics ( 'transaction_completed' , transaction );
// Trigger any post-completion workflows
await this . triggerPostCompletionWorkflows ( transaction );
}
private async handleTransactionFailed ( transaction : any ) {
console . log ( 'Processing failed transaction:' , transaction . externalId );
// Update database
await this . updateTransactionStatus ( transaction . externalId , 'failed' , {
failedAt: transaction . failedAt ,
errorCode: transaction . errorCode ,
errorMessage: transaction . errorMessage
});
// Send customer notification with helpful message
await this . sendCustomerNotification ( transaction , 'failed' );
// Log for analysis
await this . logFailedTransaction ( transaction );
// Update analytics
await this . updateAnalytics ( 'transaction_failed' , transaction );
// Check if retry is appropriate
if ( this . shouldRetryTransaction ( transaction . errorCode )) {
await this . scheduleTransactionRetry ( transaction );
}
}
private isRetryableError ( error : any ) : boolean {
return this . retryableErrors . includes ( error . code ) ||
error . message . includes ( 'timeout' ) ||
error . message . includes ( 'connection' );
}
}
Next.js API Route Handler
// pages/api/webhooks/[provider].ts
import type { NextApiRequest , NextApiResponse } from 'next' ;
import { verifyWebhookSignature , processWebhook } from '@/lib/webhooks' ;
export default async function handler (
req : NextApiRequest ,
res : NextApiResponse
) {
if ( req . method !== 'POST' ) {
return res . status ( 405 ). json ({ error: 'Method not allowed' });
}
const provider = req . query . provider as string ;
const signature = req . headers [ 'x-webhook-signature' ] as string ;
const timestamp = req . headers [ 'x-webhook-timestamp' ] as string ;
try {
// Get raw body for signature verification
const payload = JSON . stringify ( req . body );
// Verify signature
const isValid = verifyWebhookSignature (
provider ,
Buffer . from ( payload ),
signature ,
timestamp
);
if ( ! isValid ) {
return res . status ( 400 ). json ({ error: 'Invalid signature' });
}
// Process webhook
const result = await processWebhook ( provider , req . body );
res . status ( 200 ). json ( result );
} catch ( error ) {
console . error ( 'Webhook error:' , error );
res . status ( 500 ). json ({ error: 'Processing failed' });
}
}
// Disable body parsing to get raw body for signature verification
export const config = {
api: {
bodyParser: {
sizeLimit: '1mb' ,
},
},
}
Security Best Practices
Signature Verification
Always verify webhook signatures to ensure authenticity:
function verifyWebhookSignature (
payload : Buffer ,
signature : string ,
secret : string ,
timestamp ?: string
) : boolean {
// Include timestamp in signature to prevent replay attacks
const signaturePayload = timestamp ? ` ${ timestamp } . ${ payload } ` : payload ;
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( signaturePayload )
. digest ( 'hex' );
// Remove algorithm prefix if present (e.g., "sha256=")
const cleanSignature = signature . replace ( / ^ sha256=/ , '' );
return crypto . timingSafeEqual (
Buffer . from ( cleanSignature , 'hex' ),
Buffer . from ( expectedSignature , 'hex' )
);
}
Timestamp Validation
Prevent replay attacks by validating timestamps:
function validateTimestamp ( timestamp : string , toleranceSeconds = 300 ) : boolean {
const now = Math . floor ( Date . now () / 1000 );
const webhookTime = parseInt ( timestamp );
// Check if timestamp is within tolerance window
return Math . abs ( now - webhookTime ) <= toleranceSeconds ;
}
IP Allowlisting
Restrict webhook access to known provider IPs:
const PROVIDER_IPS = {
honeycoin: [ '52.203.14.55' , '52.202.103.144' ],
easypay: [ '41.210.142.65' , '102.68.78.34' ],
tola: [ '197.157.64.45' , '102.68.79.123' ]
};
function validateSourceIP ( provider : string , clientIP : string ) : boolean {
const allowedIPs = PROVIDER_IPS [ provider ];
return allowedIPs ? allowedIPs . includes ( clientIP ) : false ;
}
// Express middleware
app . use ( '/webhooks/:provider' , ( req , res , next ) => {
const provider = req . params . provider ;
const clientIP = req . ip || req . connection . remoteAddress ;
if ( ! validateSourceIP ( provider , clientIP )) {
return res . status ( 403 ). json ({ error: 'IP not allowed' });
}
next ();
});
Idempotency & Retry Handling
Idempotent Processing
Ensure webhooks can be safely retried:
class IdempotentWebhookProcessor {
private processedEvents = new Map < string , ProcessingResult >();
async processWebhook ( event : WebhookEvent ) : Promise < ProcessingResult > {
const eventId = this . getEventId ( event );
// Check if already processed
const existingResult = this . processedEvents . get ( eventId );
if ( existingResult ) {
console . log ( 'Webhook already processed:' , eventId );
return existingResult ;
}
// Process the event
const result = await this . doProcessWebhook ( event );
// Store result for future duplicate detection
this . processedEvents . set ( eventId , result );
return result ;
}
private getEventId ( event : WebhookEvent ) : string {
// Use event ID if available, otherwise create from content
return event . id || crypto
. createHash ( 'sha256' )
. update ( JSON . stringify ( event ))
. digest ( 'hex' );
}
}
Database-Backed Idempotency
async function processWebhookIdempotent ( event : WebhookEvent ) {
const eventId = event . id || generateEventId ( event );
// Try to insert with unique constraint
try {
await db . webhookEvents . create ({
id: eventId ,
provider: event . provider ,
type: event . type ,
status: 'processing' ,
receivedAt: new Date (),
data: event . data
});
} catch ( error ) {
if ( error . code === 'UNIQUE_CONSTRAINT_VIOLATION' ) {
console . log ( 'Webhook already processed:' , eventId );
return { processed: true , duplicate: true };
}
throw error ;
}
try {
// Process the webhook
await processWebhookLogic ( event );
// Mark as completed
await db . webhookEvents . update ( eventId , {
status: 'completed' ,
processedAt: new Date ()
});
return { processed: true };
} catch ( error ) {
// Mark as failed
await db . webhookEvents . update ( eventId , {
status: 'failed' ,
error: error . message ,
failedAt: new Date ()
});
throw error ;
}
}
Testing Webhooks
Local Development with ngrok
Set up local webhook testing:
Install ngrok
npm install -g ngrok
# or download from https://ngrok.com/
Start Your Local Server
npm run dev # Your app running on localhost:3000
Expose with ngrok
Copy the HTTPS URL (e.g., https://abc123.ngrok.io
)
Configure Webhook URL
const honeycoin = new HoneyCoin ({
apiKey: process . env . HONEYCOIN_API_KEY ! ,
publicKey: process . env . HONEYCOIN_PUBLIC_KEY ! ,
webhookUrl: 'https://abc123.ngrok.io/webhooks/honeycoin'
});
Webhook Testing Utility
class WebhookTester {
private webhookUrl : string ;
private secret : string ;
constructor ( webhookUrl : string , secret : string ) {
this . webhookUrl = webhookUrl ;
this . secret = secret ;
}
async sendTestWebhook ( eventType : string , data : any ) {
const timestamp = Math . floor ( Date . now () / 1000 ). toString ();
const payload = JSON . stringify ({
id: `test_ ${ Date . now () } ` ,
type: eventType ,
data: data ,
timestamp: timestamp
});
const signature = this . generateSignature ( payload , timestamp );
const response = await fetch ( this . webhookUrl , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'X-Webhook-Signature' : `sha256= ${ signature } ` ,
'X-Webhook-Timestamp' : timestamp
},
body: payload
});
console . log ( 'Test webhook response:' , response . status );
return response ;
}
private generateSignature ( payload : string , timestamp : string ) : string {
return crypto
. createHmac ( 'sha256' , this . secret )
. update ( ` ${ timestamp } . ${ payload } ` )
. digest ( 'hex' );
}
async testTransactionCompleted ( externalId : string ) {
return this . sendTestWebhook ( 'transaction.completed' , {
transactionId: `test_txn_ ${ Date . now () } ` ,
externalId: externalId ,
status: 'completed' ,
amount: 10000 ,
currency: 'UGX' ,
completedAt: new Date (). toISOString (),
fees: 100 ,
netAmount: 9900
});
}
async testTransactionFailed ( externalId : string ) {
return this . sendTestWebhook ( 'transaction.failed' , {
transactionId: `test_txn_ ${ Date . now () } ` ,
externalId: externalId ,
status: 'failed' ,
amount: 10000 ,
currency: 'UGX' ,
failedAt: new Date (). toISOString (),
errorCode: 'INSUFFICIENT_FUNDS' ,
errorMessage: 'Customer has insufficient balance'
});
}
}
// Usage
const tester = new WebhookTester (
'http://localhost:3000/webhooks/test' ,
'test_webhook_secret'
);
await tester . testTransactionCompleted ( 'test_order_123' );
await tester . testTransactionFailed ( 'test_order_456' );
Monitoring & Debugging
Webhook Monitoring Dashboard
class WebhookMonitor {
async getWebhookStats ( timeframe : string = '24h' ) {
const stats = await db . webhookEvents . aggregate ([
{
$match: {
receivedAt: {
$gte: new Date ( Date . now () - this . parseTimeframe ( timeframe ))
}
}
},
{
$group: {
_id: {
provider: '$provider' ,
type: '$type' ,
status: '$status'
},
count: { $sum: 1 },
avgProcessingTime: { $avg: '$processingTime' }
}
}
]);
return this . formatStats ( stats );
}
async getFailedWebhooks ( limit : number = 50 ) {
return db . webhookEvents . find ({
status: 'failed'
})
. sort ({ failedAt: - 1 })
. limit ( limit );
}
async retryFailedWebhook ( eventId : string ) {
const event = await db . webhookEvents . findById ( eventId );
if ( ! event || event . status !== 'failed' ) {
throw new Error ( 'Event not found or not failed' );
}
// Reset status and retry
await db . webhookEvents . update ( eventId , {
status: 'processing' ,
retryCount: ( event . retryCount || 0 ) + 1 ,
retriedAt: new Date ()
});
// Reprocess the webhook
await this . processWebhook ( event );
}
}
Logging & Alerting
import { Logger } from 'winston' ;
class WebhookLogger {
constructor ( private logger : Logger ) {}
logWebhookReceived ( provider : string , eventType : string , eventId : string ) {
this . logger . info ( 'Webhook received' , {
provider ,
eventType ,
eventId ,
timestamp: new Date (). toISOString ()
});
}
logWebhookProcessed ( provider : string , eventType : string , eventId : string , processingTime : number ) {
this . logger . info ( 'Webhook processed' , {
provider ,
eventType ,
eventId ,
processingTime ,
timestamp: new Date (). toISOString ()
});
}
logWebhookFailed ( provider : string , eventType : string , eventId : string , error : any ) {
this . logger . error ( 'Webhook processing failed' , {
provider ,
eventType ,
eventId ,
error: error . message ,
stack: error . stack ,
timestamp: new Date (). toISOString ()
});
// Send alert for critical failures
if ( this . isCriticalError ( error )) {
this . sendAlert ( 'Critical webhook failure' , {
provider ,
eventType ,
error: error . message
});
}
}
private isCriticalError ( error : any ) : boolean {
const criticalErrors = [
'DATABASE_CONNECTION_LOST' ,
'PAYMENT_SYSTEM_DOWN' ,
'SECURITY_VIOLATION'
];
return criticalErrors . includes ( error . code );
}
private async sendAlert ( title : string , details : any ) {
// Implement your alerting system (Slack, email, PagerDuty, etc.)
console . error ( 'ALERT:' , title , details );
}
}
Best Practices
Always Verify Signatures // Good: Verify every webhook
const isValid = verifyWebhookSignature (
payload , signature , secret , timestamp
);
if ( ! isValid ) {
return res . status ( 400 ). end ();
}
Respond Quickly // Good: Respond within 5 seconds
app . post ( '/webhook' , async ( req , res ) => {
// Acknowledge receipt immediately
res . status ( 200 ). json ({ received: true });
// Process asynchronously
setImmediate (() => processWebhook ( req . body ));
});
Handle Retries Gracefully // Good: Idempotent processing
const eventId = getEventId ( webhook );
const existing = await getProcessedEvent ( eventId );
if ( existing ) {
return res . status ( 200 ). json ({ processed: true });
}
Log Everything // Good: Comprehensive logging
logger . info ( 'Webhook received' , {
provider: req . params . provider ,
type: webhook . type ,
id: webhook . id ,
timestamp: new Date (). toISOString ()
});
Troubleshooting
Webhooks Not Being Received
Possible causes:
Webhook URL not configured correctly
Server not accessible from internet
Firewall blocking webhook provider IPs
Solutions:
Verify webhook URL in provider dashboard
Test endpoint with curl or webhook testing tools
Check firewall and security group settings
Use ngrok for local development testing
Signature Verification Failing
Possible causes:
Wrong webhook secret
Body parsing modifying payload
Incorrect signature calculation
Solutions:
Verify webhook secret in environment variables
Use raw body parser for webhook endpoints
Log received vs expected signatures for debugging
Check timestamp validation logic
Possible causes:
Webhook endpoint returning non-200 status
Processing taking too long
Network timeouts
Solutions:
Always return 200 for successfully received webhooks
Implement idempotency using event IDs
Process webhooks asynchronously
Respond quickly (< 5 seconds)
Possible causes:
Provider service issues
Webhook URL changed
Rate limiting
Solutions:
Check provider status pages
Verify webhook configuration
Implement webhook retry/recovery mechanisms
Use transaction status polling as backup
Next Steps