Webhooks Guide
Overview
Webhooks allow you to receive real-time notifications about events happening in your Kira integration, such as deposit processing, verification status changes, and virtual account updates.
Instead of polling the API, webhooks push event data to your server immediately when events occur.
Benefits of Webhooks
✅ Real-time notifications - Get updates instantly when events happen ✅ Reduced API calls - No need to poll endpoints repeatedly ✅ Better user experience - Update your UI immediately ✅ Reliable delivery - Automatic retries if your server is temporarily down ✅ Scalable - Handle high volumes of events efficiently
Webhook Setup
1. Register Your Webhook URL
Register your webhook endpoint with the Kira API:
curl -X POST https://api.balampay.com/webhooks/register \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-domain.com/webhooks/kira",
"secret": "your_webhook_secret",
"client_uuid": "your-client-uuid"
}'Parameters:
webhook_url- Your HTTPS endpoint (must be HTTPS in production)secret- Secret key for verifying webhook signaturesclient_uuid- Your unique client identifier
Response:
{
"message": "Webhook registered successfully"
}2. Implement Webhook Endpoint
Create an endpoint on your server to receive webhook events:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Parse JSON body
app.post('/webhooks/kira',
express.json(),
async (req, res) => {
try {
// Verify webhook signature (if configured)
const signature = req.headers['x-kira-signature'];
if (signature) {
const isValid = verifyWebhookSignature(req.body, signature);
if (!isValid) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
}
// Get the webhook payload
const webhook = req.body; // { event: "event.name", data: {...} }
// Process the webhook
await handleWebhookEvent(webhook);
// Return 200 to acknowledge receipt
res.status(200).send('Webhook received');
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).send('Internal server error');
}
}
);
function verifyWebhookSignature(payload, signature) {
const secret = process.env.KIRA_WEBHOOK_SECRET;
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computedSignature)
);
}
async function handleWebhookEvent(event) {
console.log(`Received event: ${event.event}`);
switch (event.event) {
case 'user.created':
await handleUserCreatedEvent(event.data);
break;
case 'user.verification.accepted':
await handleVerificationAcceptedEvent(event.data);
break;
case 'user.verification.failed':
await handleVerificationFailedEvent(event.data);
break;
case 'virtual_account.deposit_funds_received':
await handleDepositReceivedEvent(event.data);
break;
case 'virtual_account.deposit_funds_in_transit':
await handleDepositInTransitEvent(event.data);
break;
case 'virtual_account.deposit_funds_in_destination':
await handleDepositInDestinationEvent(event.data);
break;
case 'virtual_account.deposit_funds_refunded':
await handleDepositRefundedEvent(event.data);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
}Webhook Events
User Created
Sent when a new user account is created.
Event Type: user.created
Payload:
{
"event": "user.created",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"type": "person",
"email": "[email protected]",
"first_name": "John",
"last_name": "Doe",
"created_at": "2024-01-15T10:30:00Z"
}
}Handler example:
async function handleUserCreatedEvent(data) {
const { user_id, email, first_name, last_name } = data;
// Update your database
await db.users.create({
id: user_id,
email: email,
firstName: first_name,
lastName: last_name,
createdAt: new Date(data.created_at)
});
// Send welcome email
await sendEmail(email, {
subject: 'Welcome to Our Platform',
body: `Hi ${first_name}, your account has been created successfully.`
});
console.log(`User created: ${user_id}`);
}Verification Accepted
Sent when user verification is successfully completed.
Event Type: user.verification.accepted
Payload:
{
"event": "user.verification.accepted",
"data": {
"event_id": "evt_abc123",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"id": "verif_123",
"verification_status": "verified",
"kyc_type": "full"
}
}Handler example:
async function handleVerificationAcceptedEvent(data) {
const { user_id, verification_status, kyc_type } = data;
// Update user status
await db.users.update(user_id, {
verificationStatus: verification_status,
verifiedAt: new Date(),
kycType: kyc_type
});
// Notify user
await notifyUser(user_id, {
title: 'Verification Complete',
message: 'Your identity has been verified. You can now create virtual accounts.'
});
console.log(`User ${user_id} verified with ${kyc_type} KYC`);
}Verification Failed
Sent when user verification is rejected.
Event Type: user.verification.failed
Payload:
{
"event": "user.verification.failed",
"data": {
"event_id": "evt_def456",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"id": "verif_123",
"verification_status": "rejected",
"reasons": [
"Invalid document",
"Photo quality too low"
]
}
}Handler example:
async function handleVerificationFailedEvent(data) {
const { user_id, verification_status, reasons } = data;
// Update user status
await db.users.update(user_id, {
verificationStatus: verification_status,
rejectionReasons: reasons,
rejectedAt: new Date()
});
// Notify user with reasons
await notifyUser(user_id, {
title: 'Verification Failed',
message: `Your verification was rejected. Reasons: ${reasons.join(', ')}`
});
console.log(`User ${user_id} verification failed: ${reasons.join(', ')}`);
}Deposit Funds Received
Sent when USD funds are received at the virtual account.
Event Type: virtual_account.deposit_funds_received
Payload:
{
"event": "virtual_account.deposit_funds_received",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"virtual_account_id": "va_789012345",
"amount": "1000.00",
"currency": "usdc",
"created_at": "2024-01-15T14:30:00Z",
"deposit_id": "dep_abc123",
"exchange_fee_amount": "20.00",
"subtotal_amount": "980.00",
"source": {
"payment_rail": "ach_push",
"sender_name": "Acme Corporation",
"sender_bank_routing_number": "021000021",
"trace_number": "123456789",
"description": "Payment from sender"
}
}
}Handler example:
async function handleDepositReceivedEvent(data) {
const { virtual_account_id, amount, currency, source, deposit_id } = data;
// Update your database
await db.deposits.create({
id: deposit_id,
virtualAccountId: virtual_account_id,
amount: amount,
currency: currency,
sender: source.sender_name,
paymentRail: source.payment_rail,
traceNumber: source.trace_number,
status: 'received',
receivedAt: new Date(data.created_at)
});
// Notify user
await notifyUser(data.user_id, {
title: 'Funds Received',
message: `${amount} ${currency.toUpperCase()} received from ${source.sender_name}`,
depositId: deposit_id
});
console.log(`Deposit received: ${amount} ${currency}`);
}Deposit Funds in Transit
Sent when funds are being sent to the destination blockchain wallet.
Event Type: virtual_account.deposit_funds_in_transit
Payload:
{
"event": "virtual_account.deposit_funds_in_transit",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"virtual_account_id": "va_789012345",
"amount": "1000.00",
"currency": "usdc",
"created_at": "2024-01-15T14:32:00Z",
"source": {
"payment_rail": "ach_push",
"sender_name": "Acme Corporation",
"sender_bank_routing_number": "021000021",
"trace_number": "123456789",
"description": "Payment from sender"
}
}
}Handler example:
async function handleDepositInTransitEvent(data) {
const { virtual_account_id, amount, currency } = data;
// Update deposit status
await db.deposits.updateByVirtualAccount(virtual_account_id, {
status: 'in_transit',
inTransitAt: new Date(data.created_at)
});
// Notify user
await notifyUser(data.user_id, {
title: 'Sending to Wallet',
message: `${amount} ${currency.toUpperCase()} is being sent to your wallet`
});
console.log(`Deposit in transit: ${amount} ${currency}`);
}Deposit Funds in Destination
Sent when funds have arrived at the destination blockchain wallet.
Event Type: virtual_account.deposit_funds_in_destination
Payload:
{
"event": "virtual_account.deposit_funds_in_destination",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"virtual_account_id": "va_789012345",
"amount": "1000.00",
"currency": "usdc",
"created_at": "2024-01-15T14:35:00Z",
"destination_tx_hash": "5KYmFMZ3qvXpTrZ...",
"source": {
"payment_rail": "ach_push",
"sender_name": "Acme Corporation",
"sender_bank_routing_number": "021000021",
"trace_number": "123456789",
"description": "Payment from sender"
}
}
}Handler example:
async function handleDepositInDestinationEvent(data) {
const { virtual_account_id, amount, currency, destination_tx_hash } = data;
// Update deposit as completed
await db.deposits.updateByVirtualAccount(virtual_account_id, {
status: 'completed',
destinationTxHash: destination_tx_hash,
completedAt: new Date(data.created_at)
});
// Notify user with blockchain link
await notifyUser(data.user_id, {
title: 'Deposit Complete',
message: `${amount} ${currency.toUpperCase()} has arrived in your wallet`,
txHash: destination_tx_hash,
blockchainLink: `https://solscan.io/tx/${destination_tx_hash}`
});
console.log(`Deposit completed: ${amount} ${currency}`);
}Deposit Funds Refunded
Sent when deposit funds have been refunded.
Event Type: virtual_account.deposit_funds_refunded
Payload:
{
"event": "virtual_account.deposit_funds_refunded",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"virtual_account_id": "va_789012345",
"amount": "1000.00",
"currency": "usdc",
"created_at": "2024-01-15T16:00:00Z",
"source": {
"payment_rail": "ach_push",
"sender_name": "Acme Corporation",
"sender_bank_routing_number": "021000021",
"trace_number": "123456789",
"description": "Payment from sender"
},
"refund": {
"code": "300",
"reason": "Transaction Review",
"refunded_at": "2024-01-15T16:00:00Z"
}
}
}Handler example:
async function handleDepositRefundedEvent(data) {
const { virtual_account_id, amount, currency, refund } = data;
// Update deposit as refunded
await db.deposits.updateByVirtualAccount(virtual_account_id, {
status: 'refunded',
refundCode: refund.code,
refundReason: refund.reason,
refundedAt: new Date(refund.refunded_at)
});
// Notify user about refund
await notifyUser(data.user_id, {
title: 'Deposit Refunded',
message: `${amount} ${currency.toUpperCase()} has been refunded. Reason: ${refund.reason}`,
refundCode: refund.code
});
console.log(`Deposit refunded: ${amount} ${currency} - ${refund.reason}`);
}Other Deposit Events
The API also sends these additional deposit lifecycle events:
virtual_account.deposit_scheduled- Deposit has been scheduled for processingvirtual_account.deposit_in_review- Deposit is under manual reviewvirtual_account.microdeposit_funds_received- Microdeposit received (used for verification)
These events follow the same structure as the deposit events above, with the relevant data in the data object.
Webhook Security
Signature Verification
All webhooks include an x-kira-signature header with an HMAC SHA256 signature:
function verifyWebhookSignature(payload, signature) {
// Use the same secret you registered with
const secret = process.env.KIRA_WEBHOOK_SECRET;
// Compute signature from raw body
const computedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload)) // Stringify the parsed JSON
.digest('hex');
// Compare signatures using timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computedSignature)
);
}Important:
- Always verify signatures before processing webhooks
- Use timing-safe comparison to prevent timing attacks
- Use the raw request body (before JSON parsing)
- Store your webhook secret securely
Additional Security Measures
- Use HTTPS - Webhook URLs must use HTTPS in production
- Validate event structure - Check that required fields are present
- Idempotency - Use unique identifiers from the data payload (e.g.,
user_id,deposit_id,event_idwithin data) to prevent duplicate processing - IP Whitelisting - Optionally restrict to Kira's IP addresses
- Rate Limiting - Implement rate limiting on your webhook endpoint
Best practices:
- Return
200 OKas soon as webhook is received - Process webhooks asynchronously (use a queue)
- Log failed webhooks for manual review
- Implement idempotency using
event_id
Handling Webhooks at Scale
Use a Message Queue
const bull = require('bull');
const webhookQueue = new bull('webhook-processing');
// Webhook endpoint - just enqueue
app.post('/webhooks/kira', express.json(), async (req, res) => {
// Verify signature (if configured)
const signature = req.headers['x-kira-signature'];
if (signature && !verifyWebhookSignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
// Add to queue
const webhook = req.body; // { event: "event.name", data: {...} }
await webhookQueue.add(webhook, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 }
});
// Return immediately
res.status(200).send('Queued');
});
// Process webhooks from queue
webhookQueue.process(async (job) => {
await handleWebhookEvent(job.data);
});Implement Idempotency
async function handleWebhookEvent(webhook) {
// Generate unique key based on event type and data
let idempotencyKey;
switch (webhook.event) {
case 'user.created':
idempotencyKey = `user_created_${webhook.data.user_id}`;
break;
case 'user.verification.accepted':
case 'user.verification.failed':
idempotencyKey = `verification_${webhook.data.event_id}`;
break;
case 'virtual_account.deposit_funds_received':
case 'virtual_account.deposit_funds_in_transit':
case 'virtual_account.deposit_funds_in_destination':
case 'virtual_account.deposit_funds_refunded':
idempotencyKey = `deposit_${webhook.data.virtual_account_id}_${webhook.data.created_at}`;
break;
default:
idempotencyKey = `webhook_${webhook.event}_${Date.now()}`;
}
// Check if already processed
const processed = await db.processedWebhooks.findOne({
idempotencyKey: idempotencyKey
});
if (processed) {
console.log(`Event ${idempotencyKey} already processed`);
return;
}
// Process event
await processEvent(webhook);
// Mark as processed
await db.processedWebhooks.create({
idempotencyKey: idempotencyKey,
eventType: webhook.event,
processedAt: new Date()
});
}Testing Webhooks
Local Development with ngrok
Use ngrok to expose your local server for webhook testing:
# Start your local server
npm run dev
# In another terminal, start ngrok
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# Example: https://abc123.ngrok.io/webhooks/kiraTesting Your Webhook Endpoint
Test your webhook implementation by manually sending events:
# Compute signature (example in Node.js)
node -e "
const crypto = require('crypto');
const payload = JSON.stringify({
event: 'user.created',
data: {
user_id: '550e8400-e29b-41d4-a716-446655440000',
type: 'person',
email: '[email protected]',
first_name: 'Test',
last_name: 'User',
created_at: new Date().toISOString()
}
});
const secret = 'your_webhook_secret';
const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
console.log('Signature:', signature);
console.log('Payload:', payload);
"
# Then send the test webhook
curl -X POST https://your-server.com/webhooks/kira \
-H "Content-Type: application/json" \
-H "x-kira-signature: COMPUTED_SIGNATURE_FROM_ABOVE" \
-d '{
"event": "user.created",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"type": "person",
"email": "[email protected]",
"first_name": "Test",
"last_name": "User",
"created_at": "2024-01-15T10:30:00Z"
}
}'Sandbox Testing
In sandbox mode:
- User verification is automatically approved
- Webhooks for user creation and verification are sent immediately
- Webhooks for deposits are sent when real deposit events occur
- Register your ngrok or test server URL using the webhook registration endpoint
Monitoring Webhooks
Log All Webhook Events
async function handleWebhookEvent(webhook) {
// Generate unique log ID
const logId = `${webhook.event}_${Date.now()}`;
// Log to database
await db.webhookLogs.create({
id: logId,
eventType: webhook.event,
payload: webhook,
receivedAt: new Date(),
processed: false
});
try {
// Process event
await processEvent(webhook);
// Mark as processed
await db.webhookLogs.update(logId, {
processed: true,
processedAt: new Date()
});
} catch (error) {
// Log error
await db.webhookLogs.update(logId, {
error: error.message,
failedAt: new Date()
});
throw error;
}
}Monitor Webhook Health
// Check for webhook failures
async function checkWebhookHealth() {
const recentFailures = await db.webhookLogs.find({
processed: false,
receivedAt: { $gte: new Date(Date.now() - 3600000) } // Last hour
});
if (recentFailures.length > 10) {
await alertOps('High webhook failure rate detected');
}
}
// Run every 5 minutes
setInterval(checkWebhookHealth, 5 * 60 * 1000);Troubleshooting
Webhooks not being received
Possible causes:
- Webhook URL not configured or incorrect
- Firewall blocking Kira's IP addresses
- Server returning non-2xx status codes
- HTTPS certificate issues
Solutions:
- Verify webhook URL in Kira dashboard
- Check server logs for incoming requests
- Test webhook endpoint manually with curl
- Ensure HTTPS certificate is valid
Signature verification failing
Possible causes:
- Wrong webhook secret
- Modified request body before verification
- Character encoding issues
Solutions:
- Verify you're using the correct webhook secret
- Use raw request body for verification
- Check that no middleware is modifying the body
Duplicate webhook processing
Possible causes:
- Not implementing idempotency
- Retries being processed as new events
Solutions:
- Store
event_idto detect duplicates - Return 200 OK quickly to prevent retries
- Use database constraints on event_id
Best Practices
- Return 200 quickly - Acknowledge receipt immediately, process asynchronously
- Implement idempotency - Use unique identifiers from the data payload (e.g.,
user_id,deposit_id,event_idwithin data) to prevent duplicate processing - Verify signatures - Always validate webhook authenticity using the secret you registered
- Use message queues - Handle high volumes efficiently with queuing systems
- Log everything - Keep audit trail of all webhook events
- Monitor failures - Alert on webhook processing errors
- Test thoroughly - Use ngrok and sandbox to test all event types
- Handle errors gracefully - Don't crash on unexpected payloads
- Process events by type - Handle each event type appropriately based on your business logic
- Store webhook secret securely - Use environment variables and never commit secrets to code
Next Steps
- Create your webhook endpoint on your server
- Register your webhook URL using the
/webhooks/registerendpoint - Implement signature verification using your registered secret
- Test with ngrok for local development
- Test in sandbox environment with real API calls
- Monitor webhook health in production
- Review error handling for webhook failures
Summary
Webhooks provide real-time notifications for important events in your Kira integration:
User Events:
user.created- New user accountuser.verification.accepted- Verification completeduser.verification.failed- Verification rejected
Deposit Events:
virtual_account.deposit_funds_received- Funds receivedvirtual_account.deposit_funds_in_transit- Sending to blockchainvirtual_account.deposit_funds_in_destination- Arrived at walletvirtual_account.deposit_funds_refunded- Deposit refunded
All webhooks use the format: { event: "event.name", data: {...} }
Register your webhook URL at: POST /webhooks/register
Updated 12 days ago
