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 signatures
  • client_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 processing
  • virtual_account.deposit_in_review - Deposit is under manual review
  • virtual_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

  1. Use HTTPS - Webhook URLs must use HTTPS in production
  2. Validate event structure - Check that required fields are present
  3. Idempotency - Use unique identifiers from the data payload (e.g., user_id, deposit_id, event_id within data) to prevent duplicate processing
  4. IP Whitelisting - Optionally restrict to Kira's IP addresses
  5. Rate Limiting - Implement rate limiting on your webhook endpoint

Best practices:

  • Return 200 OK as 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/kira

Testing 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:

  1. Webhook URL not configured or incorrect
  2. Firewall blocking Kira's IP addresses
  3. Server returning non-2xx status codes
  4. 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:

  1. Wrong webhook secret
  2. Modified request body before verification
  3. 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:

  1. Not implementing idempotency
  2. Retries being processed as new events

Solutions:

  • Store event_id to detect duplicates
  • Return 200 OK quickly to prevent retries
  • Use database constraints on event_id

Best Practices

  1. Return 200 quickly - Acknowledge receipt immediately, process asynchronously
  2. Implement idempotency - Use unique identifiers from the data payload (e.g., user_id, deposit_id, event_id within data) to prevent duplicate processing
  3. Verify signatures - Always validate webhook authenticity using the secret you registered
  4. Use message queues - Handle high volumes efficiently with queuing systems
  5. Log everything - Keep audit trail of all webhook events
  6. Monitor failures - Alert on webhook processing errors
  7. Test thoroughly - Use ngrok and sandbox to test all event types
  8. Handle errors gracefully - Don't crash on unexpected payloads
  9. Process events by type - Handle each event type appropriately based on your business logic
  10. Store webhook secret securely - Use environment variables and never commit secrets to code

Next Steps

  1. Create your webhook endpoint on your server
  2. Register your webhook URL using the /webhooks/register endpoint
  3. Implement signature verification using your registered secret
  4. Test with ngrok for local development
  5. Test in sandbox environment with real API calls
  6. Monitor webhook health in production
  7. 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 account
  • user.verification.accepted - Verification completed
  • user.verification.failed - Verification rejected

Deposit Events:

  • virtual_account.deposit_funds_received - Funds received
  • virtual_account.deposit_funds_in_transit - Sending to blockchain
  • virtual_account.deposit_funds_in_destination - Arrived at wallet
  • virtual_account.deposit_funds_refunded - Deposit refunded

All webhooks use the format: { event: "event.name", data: {...} }

Register your webhook URL at: POST /webhooks/register