> ## Documentation Index
> Fetch the complete documentation index at: https://docs.usezentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Handling Webhooks

> Receive real-time event notifications from Zentra

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](https://api.usezentra.com/developer/signin?callbackUrl=%2Fdeveloper%2Fwebhooks)

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

<CodeGroup>
  ```javascript routes/webhooks.js theme={null}
  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;
  ```

  ```python webhooks.py theme={null}
  from flask import Flask, request, jsonify
  from zentra import Client
  import os

  app = Flask(__name__)
  client = Client(api_key=os.getenv("ZENTRA_SECRET_KEY"))

  @app.route("/webhooks/zentra", methods=["POST"])
  def handle_webhook():
      signature = request.headers.get("X-Zentra-Signature")
      payload = request.get_data(as_text=True)
      
      # Verify signature
      if not client.webhooks.verify(payload, signature):
          return jsonify({"error": "Invalid signature"}), 401
      
      event = request.get_json()
      
      try:
          process_webhook_event(event)
          return "OK", 200
      except Exception as e:
          print(f"Webhook processing failed: {e}")
          return "Processing failed", 500
  ```
</CodeGroup>

<Warning>
  Always verify webhook signatures. Without verification, attackers could send fake events to your endpoint.
</Warning>

## Step 2: Handle Event Types

Process different events based on their type:

```javascript theme={null}
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

```javascript theme={null}
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

```javascript theme={null}
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

```javascript theme={null}
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.success`  | Payment completed successfully |
| `payment.failed`   | Payment failed                 |
| `payment.pending`  | Payment is processing          |
| `payment.refunded` | Payment was refunded           |

### Transfer Events

| Event                | Description            |
| -------------------- | ---------------------- |
| `transfer.completed` | Transfer delivered     |
| `transfer.failed`    | Transfer failed        |
| `transfer.pending`   | Transfer is processing |
| `transfer.reversed`  | Transfer was reversed  |

### Virtual Account Events

| Event                      | Description     |
| -------------------------- | --------------- |
| `virtual_account.credited` | Money received  |
| `virtual_account.created`  | Account created |
| `virtual_account.closed`   | Account closed  |

### Card Events

| Event              | Description   |
| ------------------ | ------------- |
| `card.created`     | Card issued   |
| `card.transaction` | Card was used |
| `card.locked`      | Card locked   |
| `card.unlocked`    | Card unlocked |

### Identity Events

| Event               | Description   |
| ------------------- | ------------- |
| `identity.verified` | KYC passed    |
| `identity.failed`   | KYC failed    |
| `identity.pending`  | KYC in review |

## Best Practices

### 1. Respond Quickly

Return a 200 status immediately, then process asynchronously:

```javascript theme={null}
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

<Note>
  After 5 failed attempts, the webhook is marked as failed. You can manually retry from the dashboard.
</Note>

### 3. Store Raw Events

Save raw webhook data for debugging:

```javascript theme={null}
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:

```javascript theme={null}
const processed = await db.webhookEvents.findUnique({
  where: { event_id: event.id }
});

if (processed) {
  return; // Already handled
}
```

## Testing Webhooks

### Local Development with ngrok

```bash theme={null}
# 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:

```bash theme={null}
zentra webhooks trigger payment.success --data '{
  "reference": "TEST_123",
  "amount_minor": 500000,
  "email": "test@example.com"
}'
```

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhook API" icon="webhook" href="/api-reference/webhooks/overview">
    API reference
  </Card>

  <Card title="Event Types" icon="list" href="/api-reference/webhooks/events">
    All event types
  </Card>

  <Card title="Verify Signatures" icon="shield" href="/api-reference/webhooks/verify-signature">
    Signature verification
  </Card>

  <Card title="Going Live" icon="rocket" href="/guides/going-live">
    Production checklist
  </Card>
</CardGroup>
