> For the complete documentation index, see [llms.txt](https://docs.voveid.com/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.voveid.com/docs/kyb-know-your-business/kyb-webhooks.md).

# KYB Webhooks

### Real-time KYB Event Notifications

#### Overview

VOVE ID uses webhooks to send real-time notifications about KYB case status changes to your application. Webhooks allow you to receive immediate updates when a case moves into progress, enters review, is completed, or is rejected, eliminating the need for polling the API.

Webhooks are powered by [Svix](https://www.svix.com/), providing reliable delivery, automatic retries, and webhook signature verification.

## Setting Up Webhooks

### Dashboard Configuration

1. Log in to the VOVE ID dashboard
2. Navigate to Settings → Webhooks
3. Add your webhook endpoint URL (must be HTTPS)
4. Select which event types you want to receive
5. Save and note your webhook signing secret

### Endpoint Requirements

Your webhook endpoint must:

* Accept HTTP POST requests
* Use HTTPS (HTTP is not supported for production)
* Return a 2xx status code within 15 seconds
* Verify the webhook signature (recommended for security)

## Webhook Events

### KYB Case Events

#### `kyb.case.in_progress`

Triggered when a KYB case moves into `IN_PROGRESS`.

**Payload:**

```json
{
  "refId": "unique-business-ref-12345",
  "status": "IN_PROGRESS",
  "previousStatus": "NOT_STARTED"
}
```

#### `kyb.case.in_review`

Triggered when a KYB case moves into `IN_REVIEW`.

**Payload:**

```json
{
  "refId": "unique-business-ref-12345",
  "status": "IN_REVIEW",
  "previousStatus": "IN_PROGRESS"
}
```

#### `kyb.case.completed`

Triggered when a case is approved and marked as completed.

**Payload:**

```json
{
  "refId": "unique-business-ref-12345",
  "status": "COMPLETED",
  "previousStatus": "IN_REVIEW"
}
```

#### `kyb.case.rejected`

Triggered when a case is rejected.

**Payload:**

```json
{
  "refId": "unique-business-ref-12345",
  "status": "REJECTED",
  "previousStatus": "IN_REVIEW",
  "reason": "DOCUMENT_MISMATCH"
}
```

> `kyb.case.created` is not currently emitted as an external webhook. Webhooks begin once the case status changes from `NOT_STARTED`.

## Implementing Webhook Handlers

### Node.js/Express Example

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

// Use raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

// Webhook signing secret from VOVE ID dashboard
const WEBHOOK_SECRET = process.env.VOVE_WEBHOOK_SECRET;

// Verify webhook signature
function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Webhook endpoint
app.post('/webhooks/vove-kyb', (req, res) => {
  // Get signature from header
  const signature = req.headers['svix-signature'];

  // Verify signature
  if (!verifyWebhookSignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;

  // Handle status changes from the webhook payload
  switch (event.status) {
    case 'IN_PROGRESS':
      handleCaseInProgress(event);
      break;

    case 'IN_REVIEW':
      handleCaseInReview(event);
      break;

    case 'COMPLETED':
      handleCaseCompleted(event);
      break;

    case 'REJECTED':
      handleCaseRejected(event);
      break;

    default:
      console.log('Unknown KYB webhook payload:', event);
  }

  // Return 200 to acknowledge receipt
  res.status(200).send('OK');
});

function handleCaseInProgress(data) {
  console.log('Case is now in progress:', data.refId);
  // Record that the customer started the KYB flow
}

function handleCaseInReview(data) {
  console.log('Case is now in review:', data.refId);
  // Update your database with the latest review status
}

function handleCaseCompleted(data) {
  console.log('Case completed:', data.refId);
  // Enable business account, grant access, etc.
}

function handleCaseRejected(data) {
  console.log('Case rejected:', data.refId, 'Reason:', data.reason);
  // Notify business customer, request additional information, etc.
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});
```

### Python/Flask Example

```python
import os
import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = os.environ['VOVE_WEBHOOK_SECRET']

def verify_webhook_signature(payload, signature, secret):
    """Verify the webhook signature"""
    expected_signature = hmac.new(
        secret.encode(),
        payload.encode(),
        hashlib.sha256
    ).digest().hex()

    return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/vove-kyb', methods=['POST'])
def webhook_handler():
    # Get signature from header
    signature = request.headers.get('svix-signature')

    # Verify signature
    if not verify_webhook_signature(request.data.decode(), signature, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    payload = request.json
    status = payload.get('status')

    if status == 'IN_PROGRESS':
        handle_case_in_progress(payload)
    elif status == 'IN_REVIEW':
        handle_case_in_review(payload)
    elif status == 'COMPLETED':
        handle_case_completed(payload)
    elif status == 'REJECTED':
        handle_case_rejected(payload)

    return jsonify({'status': 'success'}), 200

def handle_case_in_progress(data):
    print(f"Case in progress: {data['refId']}")
    # Record that the customer started the KYB flow

def handle_case_in_review(data):
    print(f"Case in review: {data['refId']}")
    # Update your database with the latest review status

def handle_case_completed(data):
    print(f"Case completed: {data['refId']}")
    # Enable business account, grant access, etc.

def handle_case_rejected(data):
    print(f"Case rejected: {data['refId']}, Reason: {data.get('reason')}")
    # Notify business customer, request additional information, etc.

if __name__ == '__main__':
    app.run(port=3000)
```

## Security Best Practices

### Signature Verification

Always verify webhook signatures to ensure the request is from VOVE ID:

1. Get the `svix-signature` header from the request
2. Compute the expected signature using your webhook secret
3. Compare the signatures using a timing-safe comparison
4. Reject requests with invalid signatures

### Idempotency

Webhooks may be delivered more than once. Implement idempotency handling:

```javascript
const processedEvents = new Set();

app.post('/webhooks/vove-kyb', (req, res) => {
  const eventId = req.headers['svix-id'];

  // Check if we've already processed this event
  if (processedEvents.has(eventId)) {
    console.log('Event already processed:', eventId);
    return res.status(200).send('OK');
  }

  // Process the event
  handleWebhookEvent(req.body);

  // Mark as processed
  processedEvents.add(eventId);

  res.status(200).send('OK');
});
```

For production systems, use a database instead of an in-memory Set.

### Error Handling

Return appropriate status codes:

* `200-299`: Success, webhook acknowledged
* `400-499`: Client error, will not be retried
* `500-599`: Server error, will be retried with exponential backoff

```javascript
app.post('/webhooks/vove-kyb', async (req, res) => {
  try {
    await processWebhookEvent(req.body);
    res.status(200).send('OK');
  } catch (error) {
    if (error.name === 'ValidationError') {
      // Client error, don't retry
      res.status(400).send('Invalid data');
    } else {
      // Server error, allow retry
      res.status(500).send('Internal error');
    }
  }
});
```

### Timeout Handling

Respond quickly (within 15 seconds) to avoid timeouts:

```javascript
app.post('/webhooks/vove-kyb', async (req, res) => {
  // Acknowledge receipt immediately
  res.status(200).send('OK');

  // Process asynchronously
  processWebhookEventAsync(req.body)
    .catch(error => {
      console.error('Error processing webhook:', error);
    });
});
```

## Testing Webhooks

### Local Development with ngrok

Use [ngrok](https://ngrok.com/) to expose your local server for webhook testing:

```bash
# Install ngrok
npm install -g ngrok

# Start your webhook server locally
node webhook-server.js

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok HTTPS URL in VOVE ID dashboard
# Example: https://abc123.ngrok.io/webhooks/vove-kyb
```

### Testing with cURL

Simulate webhook events locally:

```bash
curl -X POST http://localhost:3000/webhooks/vove-kyb \
  -H "Content-Type: application/json" \
  -H "svix-signature: your-test-signature" \
  -d '{
    "refId": "test-ref-id",
    "status": "COMPLETED",
    "previousStatus": "IN_REVIEW"
  }'
```

## Webhook Retry Logic

VOVE ID automatically retries failed webhook deliveries:

* **Initial retry**: After 5 seconds
* **Subsequent retries**: Exponential backoff up to 1 hour
* **Maximum attempts**: 5 attempts over 24 hours
* **Failure criteria**: Non-2xx response or timeout

Failed webhooks are logged in the dashboard for manual review and retry.

## Monitoring and Debugging

### Dashboard Monitoring

Monitor webhook delivery in the VOVE ID dashboard:

* View recent webhook deliveries
* Check delivery status (success/failed)
* View request/response details
* Manually retry failed webhooks

### Logging

Implement comprehensive logging in your webhook handler:

```javascript
app.post('/webhooks/vove-kyb', (req, res) => {
  const eventId = req.headers['svix-id'];
  const status = req.body.status;

  console.log('Webhook received:', {
    eventId,
    status,
    timestamp: new Date().toISOString()
  });

  try {
    handleWebhookEvent(req.body);

    console.log('Webhook processed successfully:', eventId);
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook processing error:', {
      eventId,
      error: error.message,
      stack: error.stack
    });
    res.status(500).send('Error');
  }
});
```

## Common Use Cases

### Update Case Status in Database

```javascript
async function handleStatusChanged(data) {
  await database.kybCases.update(
    { refId: data.refId },
    {
      status: data.status,
      previousStatus: data.previousStatus,
      updatedAt: new Date()
    }
  );

  console.log(`Updated case ${data.refId} to status ${data.status}`);
}
```

### Enable Business Account on Completion

```javascript
async function handleCaseCompleted(data) {
  // Update business status
  await database.businesses.update(
    { refId: data.refId },
    {
      verified: true,
      verifiedAt: new Date(),
      accountStatus: 'ACTIVE'
    }
  );

  // Send notification email
  await emailService.send({
    to: business.email,
    subject: 'Business Verification Complete',
    template: 'business-verified'
  });

  // Grant access to platform features
  await grantBusinessAccess(data.refId);
}
```

### Notify Customer on Rejection

```javascript
async function handleCaseRejected(data) {
  const business = await database.businesses.findOne({ refId: data.refId });

  // Send notification with rejection reason
  await emailService.send({
    to: business.email,
    subject: 'Business Verification Requires Attention',
    template: 'business-rejected',
    data: {
      rejectionReason: data.reason,
      supportLink: 'https://support.example.com'
    }
  });
}
```

## Related Documentation

* [KYB Overview](/docs/kyb-know-your-business/kyb-overview.md) - Understand the KYB verification flow
* [Create KYB Case](/docs/kyb-know-your-business/kyb-create-case.md) - Learn how to create cases
* [KYB Case Management](/docs/kyb-know-your-business/kyb-case-management.md) - Manage cases and UBOs
* [Webhooks (General)](/docs/api/webhooks.md) - General webhook documentation

<br>
