Skip to main content

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

EventDescription
payment.successPayment completed successfully
payment.failedPayment failed
payment.pendingPayment is processing
payment.refundedPayment was refunded

Transfer Events

EventDescription
transfer.completedTransfer delivered
transfer.failedTransfer failed
transfer.pendingTransfer is processing
transfer.reversedTransfer was reversed

Virtual Account Events

EventDescription
virtual_account.creditedMoney received
virtual_account.createdAccount created
virtual_account.closedAccount closed

Card Events

EventDescription
card.createdCard issued
card.transactionCard was used
card.lockedCard locked
card.unlockedCard unlocked

Identity Events

EventDescription
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

Webhook API

API reference

Event Types

All event types

Verify Signatures

Signature verification

Going Live

Production checklist