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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.voveid.com/docs/kyb-know-your-business/kyb-webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
