Handling Webhooks
Webhooks are HTTP callbacks that notify your application when events occur in Zentra. Use them to update your database, trigger workflows, and keep your app in sync.
What You’ll Learn
Set up webhook endpoints
Verify webhook signatures
Handle different event types
Build reliable webhook processing
Debug common issues
Prerequisites
A publicly accessible server (or ngrok for local development)
Webhook endpoint URL configured in the Developer Console
Webhook Flow
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Zentra API │────▶│ Your Server │────▶│ Your Logic │
│ (Event) │ │ (Webhook) │ │ (Process) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ 1. Event │ │
│ Occurs │ │
│ │ │
│ 2. POST to │ │
│ your URL │ │
│ ──────────────▶ │ │
│ │ 3. Verify │
│ │ Signature │
│ │ 4. Process │
│ │ Event │
│ ◀────────────── │ │
│ 5. Return 200 │ │
Step 1: Create Your Webhook Endpoint
Set up an endpoint to receive webhook events:
routes/webhooks.js
webhooks.py
const express = require ( 'express' );
const Zentra = require ( '@zentra/sdk' );
const router = express . Router ();
const client = new Zentra . Client ({
apiKey: process . env . ZENTRA_SECRET_KEY
});
// Use raw body for signature verification
router . post ( '/zentra' ,
express . raw ({ type: 'application/json' }),
async ( req , res ) => {
const signature = req . headers [ 'x-zentra-signature' ];
const payload = req . body . toString ();
// Verify signature
const isValid = client . webhooks . verify ( payload , signature );
if ( ! isValid ) {
console . error ( 'Invalid webhook signature' );
return res . status ( 401 ). send ( 'Invalid signature' );
}
const event = JSON . parse ( payload );
// Process the event
try {
await processWebhookEvent ( event );
res . status ( 200 ). send ( 'OK' );
} catch ( error ) {
console . error ( 'Webhook processing failed:' , error );
res . status ( 500 ). send ( 'Processing failed' );
}
}
);
module . exports = router ;
Always verify webhook signatures. Without verification, attackers could send fake events to your endpoint.
Step 2: Handle Event Types
Process different events based on their type:
async function processWebhookEvent ( event ) {
const { type , data , timestamp } = event ;
// Check for duplicate events (idempotency)
const exists = await db . webhookEvents . findUnique ({
where: { event_id: event . id }
});
if ( exists ) {
console . log ( 'Duplicate event, skipping:' , event . id );
return ;
}
// Log the event
await db . webhookEvents . create ({
data: {
event_id: event . id ,
type ,
data: JSON . stringify ( data ),
processed_at: new Date ()
}
});
// Route to appropriate handler
switch ( type ) {
// Payment events
case 'payment.success' :
await handlePaymentSuccess ( data );
break ;
case 'payment.failed' :
await handlePaymentFailed ( data );
break ;
case 'payment.refunded' :
await handlePaymentRefunded ( data );
break ;
// Transfer events
case 'transfer.completed' :
await handleTransferCompleted ( data );
break ;
case 'transfer.failed' :
await handleTransferFailed ( data );
break ;
// Virtual account events
case 'virtual_account.credited' :
await handleAccountCredited ( data );
break ;
// Card events
case 'card.created' :
await handleCardCreated ( data );
break ;
case 'card.transaction' :
await handleCardTransaction ( data );
break ;
// Identity events
case 'identity.verified' :
await handleIdentityVerified ( data );
break ;
case 'identity.failed' :
await handleIdentityFailed ( data );
break ;
default :
console . log ( 'Unhandled event type:' , type );
}
}
Step 3: Implement Event Handlers
Payment Success
async function handlePaymentSuccess ( data ) {
const { reference , amount_minor , customer_email , metadata } = data ;
// Update order status
const order = await db . orders . update ({
where: { reference },
data: {
status: 'paid' ,
paid_at: new Date (),
amount_paid_minor: amount_minor
}
});
// Trigger fulfillment
await fulfillmentService . process ( order . id );
// Send receipt
await emailService . send ( customer_email , 'payment_receipt' , {
order_id: order . id ,
amount_major: amount_minor / 100
});
}
Virtual Account Credited
async function handleAccountCredited ( data ) {
const { account_id , amount_minor , sender_name , reference } = data ;
// Find user by virtual account
const account = await db . virtualAccounts . findUnique ({
where: { zentra_id: account_id },
include: { user: true }
});
if ( ! account ) {
console . error ( 'Unknown account:' , account_id );
return ;
}
// Credit user's wallet
await db . wallets . update ({
where: { user_id: account . user_id },
data: {
balance_minor: { increment: amount_minor }
}
});
// Create transaction record
await db . transactions . create ({
data: {
user_id: account . user_id ,
type: 'deposit' ,
amount_minor ,
description: `Deposit from ${ sender_name } ` ,
reference ,
status: 'completed'
}
});
// Send push notification
await pushService . send ( account . user_id , {
title: 'Money Received' ,
body: `₦ ${ ( amount_minor / 100 ). toLocaleString () } from ${ sender_name } `
});
}
Transfer Completed
async function handleTransferCompleted ( data ) {
const { reference , amount_minor , recipient_name } = data ;
await db . transfers . update ({
where: { reference },
data: {
status: 'completed' ,
completed_at: new Date ()
}
});
// Notify user
const transfer = await db . transfers . findUnique ({
where: { reference },
include: { user: true }
});
await pushService . send ( transfer . user_id , {
title: 'Transfer Successful' ,
body: `₦ ${ ( amount_minor / 100 ). toLocaleString () } sent to ${ recipient_name } `
});
}
Webhook Events Reference
Payment Events
Event Description payment.successPayment completed successfully payment.failedPayment failed payment.pendingPayment is processing payment.refundedPayment was refunded
Transfer Events
Event Description transfer.completedTransfer delivered transfer.failedTransfer failed transfer.pendingTransfer is processing transfer.reversedTransfer was reversed
Virtual Account Events
Event Description virtual_account.creditedMoney received virtual_account.createdAccount created virtual_account.closedAccount closed
Card Events
Event Description card.createdCard issued card.transactionCard was used card.lockedCard locked card.unlockedCard unlocked
Identity Events
Event Description identity.verifiedKYC passed identity.failedKYC failed identity.pendingKYC in review
Best Practices
1. Respond Quickly
Return a 200 status immediately, then process asynchronously:
router . post ( '/zentra' , async ( req , res ) => {
// Acknowledge receipt immediately
res . status ( 200 ). send ( 'OK' );
// Process in background
processWebhookAsync ( req . body ). catch ( console . error );
});
2. Handle Retries
Zentra retries failed webhooks with exponential backoff:
1st retry: 5 minutes
2nd retry: 30 minutes
3rd retry: 2 hours
4th retry: 8 hours
5th retry: 24 hours
After 5 failed attempts, the webhook is marked as failed. You can manually retry from the dashboard.
3. Store Raw Events
Save raw webhook data for debugging:
await db . webhookLogs . create ({
data: {
event_id: event . id ,
type: event . type ,
raw_payload: JSON . stringify ( event ),
received_at: new Date ()
}
});
4. Use Idempotency
Always check if you’ve already processed an event:
const processed = await db . webhookEvents . findUnique ({
where: { event_id: event . id }
});
if ( processed ) {
return ; // Already handled
}
Testing Webhooks
Local Development with ngrok
# Install ngrok
npm install -g ngrok
# Expose your local server
ngrok http 3000
# Use the ngrok URL in your webhook settings
# https://abc123.ngrok.com/webhooks/zentra
Trigger Test Events
Use the dashboard or CLI to send test webhooks:
zentra webhooks trigger payment.success --data '{
"reference": "TEST_123",
"amount_minor": 500000,
"email": "test@example.com"
}'
Next Steps
Event Types All event types
Verify Signatures Signature verification
Going Live Production checklist