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

# Webhook Notifications

> Set up real-time notifications for payment events using webhooks

Webhooks provide real-time notifications about payment events, allowing your application to respond immediately to payment completions, failures, and status changes. This guide covers implementing secure webhook endpoints.

## Why Use Webhooks

* **Real-time updates**: Immediate notification when payments complete
* **Reliable delivery**: Built-in retry mechanisms for failed deliveries
* **Reduced polling**: No need to constantly check payment status
* **Better UX**: Instant confirmation and account updates

## Webhook Events

Zuba sends webhooks for the following events:

### Payout Events

* `payout.processing` - Payout being processed by provider
* `payout.paid` - Payout completed successfully
* `payout.failed` - Payout failed before settlement
* `payout.reverted` - A payout that had already settled (`payout.paid`) was later rejected by the provider's downstream bank; an operator manually refunded the merchant and the funds were credited back. Unlike `payout.failed` (a pre-settlement failure), this payout did originally succeed — do **not** automatically re-issue it. *(Not yet delivered — pending a backend enum migration. You can register a handler now, but it will only start firing once that migration ships.)*
* `payout.cancelled` - Payout was cancelled

#### Payout Lifecycle

Where each event fires in a payout's life. `created` and `queued` are internal pre-processing states and fire **no webhook** — the first delivery you receive for a payout is always `payout.processing`:

```mermaid theme={"dark"}
%%{init: {
  "theme": "base",
  "themeVariables": {
    "fontFamily": "'Hanken Grotesk', system-ui, sans-serif",
    "fontSize": "13px",
    "primaryColor": "#fbf4ea",
    "primaryBorderColor": "#d8c8ad",
    "primaryTextColor": "#323236",
    "lineColor": "#a99e8c",
    "tertiaryColor": "#efe3d2",
    "tertiaryBorderColor": "#d8c8ad",
    "tertiaryTextColor": "#5b5347"
  }
}}%%
stateDiagram-v2
    direction LR

    state "created / queued" as pending
    state "processing" as processing

    [*] --> pending : POST /v1/payouts (no webhook)
    pending --> processing : payout.processing
    processing --> paid : payout.paid
    processing --> failed : payout.failed
    pending --> cancelled : payout.cancelled
    processing --> cancelled : payout.cancelled
    paid --> failed : payout.reverted (settled, then returned)

    paid --> [*]
    failed --> [*]
    cancelled --> [*]
```

<Note>
  The decline reason is not included in the `payout.failed` webhook payload.
  When you receive one, fetch `GET /v1/payouts/:id` and read the
  `failureReason` field.
</Note>

### Order Events

Orders (`POST /v1/orders`) are executed manually by Zuba's trade desk; webhooks are how you learn the outcome. See [Trade Desk Orders](/guides/orders) for the full lifecycle.

* `order.created` - The order was created. Fires together with `order.funds_received` (balance-funded) or `order.awaiting_funds` (settlement-funded)
* `order.awaiting_funds` - Funding instructions were issued; the payload's `settlement` block carries the account or address, amount, and reference to fund the order
* `order.funds_received` - The source amount is held and the order is queued for desk execution
* `order.completed` - The desk delivered to your beneficiary; `uetr` is present for wire deliveries
* `order.failed` - The order failed (`failureReason` set) and the held funds were returned to your balance
* `order.expired` - The funding window closed before the order was funded; a transfer arriving later credits the balance instead

There is no `order.cancelled` event (cancellation is answered synchronously by `POST /v1/orders/:id/cancel`) and no event for the `executing` status (desk review is internal).

### Account Events

* `account.created` - An account record was created via `POST /v1/accounts`

### Other Events

* `webhook.test` - Test event sent via the test endpoint

## Event Payload

Every webhook delivery is a JSON POST request with this envelope:

```json theme={"dark"}
{
  "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "payout.paid",
  "createdAt": "2026-03-23T14:30:00.000Z",
  "test": false,
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "clientRef": "your-reference-123",
    "amount": "1000.00",
    "currency": "USD",
    "status": "paid",
    "completedAt": "2026-03-23T14:29:58.000Z"
  }
}
```

| Field       | Type    | Description                                   |
| ----------- | ------- | --------------------------------------------- |
| `id`        | string  | Unique event ID (`evt_` prefix)               |
| `type`      | string  | Event type                                    |
| `createdAt` | string  | ISO 8601 timestamp of event creation          |
| `test`      | boolean | `true` if sent from the test webhook endpoint |
| `data`      | object  | Event-specific payload                        |

### Event-specific `data` fields

**`payout.*` events**

| Field         | Type             | Description                                            |
| ------------- | ---------------- | ------------------------------------------------------ |
| `id`          | string (UUID)    | Payout ID                                              |
| `clientRef`   | string           | Your reference from the create request                 |
| `amount`      | string           | Major-unit decimal string (e.g. `"1000.00"`)           |
| `currency`    | string           | ISO 4217 currency code                                 |
| `status`      | string           | Payout status after the transition                     |
| `completedAt` | string \| `null` | ISO 8601 completion timestamp; `null` until settlement |

**`order.*` events**

| Field                 | Type             | Description                                                                                                                                                                                     |
| --------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | string (UUID)    | Order ID                                                                                                                                                                                        |
| `clientRef`           | string \| `null` | Your reference from the create request                                                                                                                                                          |
| `status`              | string           | Order status after the transition                                                                                                                                                               |
| `fromCurrency`        | string           | Sell (source) currency                                                                                                                                                                          |
| `toCurrency`          | string           | Buy (delivered) currency                                                                                                                                                                        |
| `fromAmount`          | string           | Total source debit, major-unit decimal string                                                                                                                                                   |
| `toAmount`            | string           | Amount delivered, major-unit decimal string                                                                                                                                                     |
| `rate`                | string           | All-in rate: `fromCurrency` per 1 `toCurrency`                                                                                                                                                  |
| `failureReason`       | string \| `null` | `execution_failed` or `review_expired`; set only on `order.failed`                                                                                                                              |
| `uetr`                | string \| `null` | Outbound wire reference; set on completion over a wire rail                                                                                                                                     |
| `settlement`          | object \| `null` | Funding instructions for settlement-funded orders (`settlementId`, `method`, `requiredAmount`, `currency`, `reference`, `status`, `instructions`, `expiresAt`); `null` on balance-funded orders |
| `fundingDeadlineAt`   | string           | ISO 8601 funding deadline                                                                                                                                                                       |
| `executionDeadlineAt` | string \| `null` | ISO 8601 desk execution deadline, set once funds are received                                                                                                                                   |
| `fundedAt`            | string \| `null` | ISO 8601 funding timestamp                                                                                                                                                                      |
| `completedAt`         | string \| `null` | ISO 8601 completion timestamp                                                                                                                                                                   |
| `createdAt`           | string           | ISO 8601 creation timestamp                                                                                                                                                                     |

**`account.created`**

| Field             | Type          | Description                                       |
| ----------------- | ------------- | ------------------------------------------------- |
| `id`              | string (UUID) | Account record ID                                 |
| `parentAccountId` | string (UUID) | The master account the record was created under   |
| `accountType`     | string        | `sub` for records created via `POST /v1/accounts` |
| `status`          | string        | Account status (`pending` on create)              |
| `country`         | string        | ISO 3166 alpha-2 country code                     |
| `createdAt`       | string        | ISO 8601 creation timestamp                       |

Each request includes these headers:

| Header             | Description                                |
| ------------------ | ------------------------------------------ |
| `Content-Type`     | `application/json`                         |
| `X-Zuba-Signature` | HMAC-SHA256 hex signature                  |
| `X-Zuba-Timestamp` | Unix timestamp (seconds) used in signature |

## Setting Up Webhook Endpoints

### Basic Webhook Handler

Create an endpoint to receive webhook notifications:

```typescript theme={"dark"}
import express from 'express';
import crypto from 'crypto';

const app = express();

// Use raw body parser for webhook signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks/zuba', async (req, res) => {
  const signature = req.headers['x-zuba-signature'];
  const timestamp = req.headers['x-zuba-timestamp'];
  const payload = req.body.toString();

  // Verify webhook signature
  if (!verifyWebhookSignature(payload, signature, timestamp)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(payload);

  try {
    await processWebhookEvent(event);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook processing failed:', error);
    res.status(500).json({ error: 'Processing failed' });
  }
});
```

### Processing Different Event Types

Handle specific webhook events:

```typescript theme={"dark"}
async function processWebhookEvent(event) {
  console.log(`Processing webhook: ${event.type}`);

  switch (event.type) {
    case 'payout.paid':
      await handlePayoutCompleted(event.data);
      break;

    case 'payout.failed':
      await handlePayoutFailed(event.data);
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
}

// Handle payout completion
async function handlePayoutCompleted(payload) {
  const payoutId = payload.id;

  // Update payout status
  await updatePayoutStatus(payoutId, 'completed');

  // Notify recipient
  await notifyPayoutRecipient(payoutId);

  // Update accounting records
  await recordPayoutCompletion(payoutId, payload.amount);

  console.log(`Payout completed: ${payoutId}`);
}
```

## Webhook Security

### Verifying Signatures

Every webhook delivery includes a cryptographic signature so you can verify the request genuinely originated from Zuba and has not been tampered with. **You should always verify signatures before processing webhook events.**

The signature is computed as:

```
HMAC-SHA256(signing_secret, timestamp + "." + raw_body)
```

Where:

* **`signing_secret`** is the plaintext secret (the `whsec_`-prefixed value returned when you created the endpoint)
* **`timestamp`** is the value from the `X-Zuba-Timestamp` header
* **`raw_body`** is the raw HTTP request body (not parsed JSON)
* The result is hex-encoded (64 characters)

<Warning>
  Your signing secret is only shown once when you create a webhook endpoint or
  rotate the secret. Store it securely. If you lose it, use the rotate secret
  endpoint to generate a new one.
</Warning>

#### Verification Steps

1. Extract the `X-Zuba-Signature` and `X-Zuba-Timestamp` headers
2. Concatenate the timestamp, a literal `.`, and the raw request body
3. Compute the HMAC-SHA256 of that string using your signing secret
4. Compare your computed signature against the header value using a **constant-time comparison**
5. Check that the timestamp is recent (within your tolerance window) to prevent replay attacks

#### Code Examples

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  import crypto from 'crypto';

  function verifyWebhookSignature(payload, signature, timestamp) {
    const secret = process.env.ZUBA_WEBHOOK_SECRET;

    if (!secret || !signature || !timestamp) {
      return false;
    }

    // Reject timestamps older than 5 minutes
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
      return false;
    }

    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${payload}`)
      .digest('hex');

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

  ```python Python theme={"dark"}
  import hmac
  import hashlib
  import time

  def verify_webhook_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
      signature = headers.get('x-zuba-signature', '')
      timestamp = headers.get('x-zuba-timestamp', '')

      if not signature or not timestamp:
          return False

      # Reject timestamps older than 5 minutes
      current_time = int(time.time())
      if abs(current_time - int(timestamp)) > 300:
          return False

      signed_content = f"{timestamp}.{raw_body.decode('utf-8')}"
      expected_signature = hmac.new(
          secret.encode('utf-8'),
          signed_content.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(expected_signature, signature)

  # Flask example
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  @app.route('/webhooks/zuba', methods=['POST'])
  def handle_webhook():
      raw_body = request.get_data()

      if not verify_webhook_signature(raw_body, request.headers, WEBHOOK_SECRET):
          return jsonify({'error': 'Invalid signature'}), 401

      event = request.get_json(force=True)
      # Process event...
      return jsonify({'received': True}), 200
  ```

  ```go Go theme={"dark"}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"strconv"
  	"time"
  )

  func verifyWebhookSignature(rawBody []byte, header http.Header, secret string) bool {
  	signature := header.Get("X-Zuba-Signature")
  	timestamp := header.Get("X-Zuba-Timestamp")

  	if signature == "" || timestamp == "" {
  		return false
  	}

  	// Reject timestamps older than 5 minutes
  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return false
  	}
  	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
  		return false
  	}

  	signedContent := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(signedContent))
  	expectedSignature := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(expectedSignature), []byte(signature))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	rawBody, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "Bad request", http.StatusBadRequest)
  		return
  	}

  	if !verifyWebhookSignature(rawBody, r.Header, webhookSecret) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}

  	// Process event...
  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte(`{"received": true}`))
  }
  ```

  ```java Java theme={"dark"}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;

  public class WebhookVerifier {

      private final String secret;

      public WebhookVerifier(String secret) {
          this.secret = secret;
      }

      public boolean verify(String rawBody, String signature, String timestamp) {
          if (signature == null || timestamp == null) {
              return false;
          }

          // Reject timestamps older than 5 minutes
          long currentTime = System.currentTimeMillis() / 1000;
          if (Math.abs(currentTime - Long.parseLong(timestamp)) > 300) {
              return false;
          }

          try {
              String signedContent = timestamp + "." + rawBody;
              Mac mac = Mac.getInstance("HmacSHA256");
              mac.init(new SecretKeySpec(
                  secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
              byte[] hash = mac.doFinal(
                  signedContent.getBytes(StandardCharsets.UTF_8));
              String expectedSignature = bytesToHex(hash);

              return MessageDigest.isEqual(
                  expectedSignature.getBytes(StandardCharsets.UTF_8),
                  signature.getBytes(StandardCharsets.UTF_8));
          } catch (Exception e) {
              return false;
          }
      }

      private static String bytesToHex(byte[] bytes) {
          StringBuilder sb = new StringBuilder(bytes.length * 2);
          for (byte b : bytes) {
              sb.append(String.format("%02x", b));
          }
          return sb.toString();
      }
  }
  ```
</CodeGroup>

#### Replay Protection

The timestamp is included in the signed content specifically to enable replay protection. An attacker who intercepts a valid webhook cannot replay it at a later time if you enforce a timestamp tolerance.

We recommend rejecting any webhook where the `X-Zuba-Timestamp` is more than **5 minutes** from your server's current time. All the examples above implement this check.

<Note>
  Make sure your server's clock is synchronized with NTP. A clock that drifts
  significantly could cause valid webhooks to be rejected.
</Note>

### IP Allowlisting

Zuba does not currently publish a static egress IP range for webhook deliveries. Signature verification (above) is the supported way to authenticate incoming webhooks — it proves both origin and integrity, which an IP check alone does not.

## Handling Webhook Failures

### Idempotency

Ensure webhooks can be safely retried:

```typescript theme={"dark"}
const processedWebhooks = new Set(); // In production, use Redis or database

async function processWebhookEvent(event) {
  const eventId = event.id || `${event.type}-${event.createdAt}`;

  // Check if already processed
  if (processedWebhooks.has(eventId)) {
    console.log(`Webhook ${eventId} already processed, skipping`);
    return;
  }

  try {
    // Process the event
    await handleEvent(event);

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

    console.log(`Webhook ${eventId} processed successfully`);
  } catch (error) {
    console.error(`Failed to process webhook ${eventId}:`, error);
    throw error;
  }
}
```

### Retry Logic

Implement exponential backoff for webhook processing:

```typescript theme={"dark"}
class WebhookProcessor {
  constructor() {
    this.maxRetries = 3;
    this.baseDelay = 1000; // 1 second
  }

  async processWithRetry(event, attempt = 1) {
    try {
      await this.processEvent(event);
    } catch (error) {
      if (attempt < this.maxRetries) {
        const delay = this.baseDelay * Math.pow(2, attempt - 1);
        console.log(`Retry ${attempt} in ${delay}ms for event ${event.type}`);

        setTimeout(() => {
          this.processWithRetry(event, attempt + 1);
        }, delay);
      } else {
        console.error(`Max retries exceeded for event ${event.type}:`, error);
        // Send to dead letter queue or alert admin
        await this.handleFinalFailure(event, error);
      }
    }
  }

  async handleFinalFailure(event, error) {
    // Log to monitoring system
    console.error('Webhook processing failed permanently:', {
      event: event.type,
      eventId: event.id,
      error: error.message,
    });

    // Optionally: send alert, save to DLQ, etc.
  }
}
```

### Delivery and Retries

Zuba retries failed deliveries up to **5 times** (6 attempts in total) with exponential backoff. A delivery is successful when your endpoint responds with a **2xx** status code within **30 seconds**. Redirects are not followed — a 3xx response counts as a failed attempt.

| Attempt | Approximate delay |
| ------- | ----------------- |
| 1       | Immediate         |
| 2       | \~1 minute        |
| 3       | \~2 minutes       |
| 4       | \~4 minutes       |
| 5       | \~8 minutes       |
| 6       | \~16 minutes      |

After all attempts are exhausted, the delivery is marked as failed. You can view delivery history via the API:

```bash theme={"dark"}
curl "https://api.sandbox.zuba.com/v1/webhooks/{endpoint_id}/deliveries" \
  -H "Authorization: Bearer $TOKEN"
```

<Warning>
  Your endpoint must respond within 30 seconds. If you need to do long-running
  processing, accept the webhook with a `200` response immediately and process
  the event asynchronously.
</Warning>

## Database Integration

### Storing Webhook Events

Track webhook events for debugging and compliance:

```sql theme={"dark"}
CREATE TABLE webhook_events (
  id SERIAL PRIMARY KEY,
  event_id VARCHAR(255) UNIQUE,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  signature VARCHAR(255),
  processed_at TIMESTAMP,
  status VARCHAR(50) DEFAULT 'pending',
  error_message TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);
```

```typescript theme={"dark"}
async function storeWebhookEvent(event, signature) {
  const query = `
    INSERT INTO webhook_events (event_id, event_type, payload, signature, status)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (event_id) DO NOTHING
    RETURNING id
  `;

  const values = [
    event.id || generateEventId(event),
    event.type,
    JSON.stringify(event),
    signature,
    'pending',
  ];

  const result = await db.query(query, values);
  return result.rows[0]?.id;
}

async function markWebhookProcessed(eventId, error = null) {
  const query = `
    UPDATE webhook_events
    SET processed_at = NOW(),
        status = $2,
        error_message = $3
    WHERE event_id = $1
  `;

  await db.query(query, [
    eventId,
    error ? 'failed' : 'processed',
    error?.message,
  ]);
}
```

## Testing Webhooks

### Local Testing with ngrok

Expose your local development server:

```bash theme={"dark"}
# Install ngrok
npm install -g ngrok

# Expose local server
ngrok http 3000

# Use the HTTPS URL for webhook configuration
# https://abc123.ngrok.io/webhooks/zuba
```

### Webhook Testing Tool

Create a test utility for webhook development:

```typescript theme={"dark"}
class WebhookTester {
  constructor(webhookUrl, secret) {
    this.webhookUrl = webhookUrl;
    this.secret = secret;
  }

  generateSignature(timestamp, payload) {
    return crypto
      .createHmac('sha256', this.secret)
      .update(`${timestamp}.${payload}`)
      .digest('hex');
  }

  async sendTestWebhook(eventType, payload) {
    const event = {
      id: `test-${Date.now()}`,
      type: eventType,
      createdAt: new Date().toISOString(),
      test: true,
      data: payload,
    };

    const body = JSON.stringify(event);
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = this.generateSignature(timestamp, body);

    const response = await fetch(this.webhookUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Zuba-Signature': signature,
        'X-Zuba-Timestamp': timestamp,
      },
      body: body,
    });

    return {
      status: response.status,
      response: await response.text(),
    };
  }
}

// Usage
const tester = new WebhookTester(
  'http://localhost:3000/webhooks/zuba',
  process.env.ZUBA_WEBHOOK_SECRET,
);

// Test payout completion
await tester.sendTestWebhook('payout.paid', {
  id: 'payout_123',
  status: 'paid',
  amount: '99.99',
  currency: 'EUR',
});
```

## Production Setup

### Environment Configuration

Set up environment variables:

```bash theme={"dark"}
# .env
ZUBA_WEBHOOK_SECRET=your_webhook_secret_here
WEBHOOK_URL=https://yourapi.com/webhooks/zuba
DATABASE_URL=postgresql://user:pass@localhost/db
```

### Load Balancer Configuration

Configure your load balancer for webhook endpoints:

```nginx theme={"dark"}
# nginx.conf
upstream webhook_servers {
    server web1:3000;
    server web2:3000;
    ip_hash; # Ensure sticky sessions for webhook processing
}

server {
    listen 443 ssl;
    server_name api.yoursite.com;

    location /webhooks {
        proxy_pass http://webhook_servers;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Increase timeout for webhook processing
        proxy_read_timeout 30s;
        proxy_connect_timeout 30s;
    }
}
```

## Monitoring and Alerting

### Health Checks

Monitor webhook endpoint health:

```typescript theme={"dark"}
app.get('/webhooks/health', (req, res) => {
  res.status(200).json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version,
  });
});
```

### Metrics Collection

Track webhook performance:

```typescript theme={"dark"}
const webhookMetrics = {
  received: 0,
  processed: 0,
  failed: 0,
  processingTime: [],
};

async function processWebhookEvent(event) {
  const startTime = Date.now();
  webhookMetrics.received++;

  try {
    await handleEvent(event);
    webhookMetrics.processed++;
  } catch (error) {
    webhookMetrics.failed++;
    throw error;
  } finally {
    const processingTime = Date.now() - startTime;
    webhookMetrics.processingTime.push(processingTime);

    // Keep only last 100 timing measurements
    if (webhookMetrics.processingTime.length > 100) {
      webhookMetrics.processingTime.shift();
    }
  }
}

// Metrics endpoint
app.get('/metrics/webhooks', (req, res) => {
  const avgProcessingTime =
    webhookMetrics.processingTime.reduce((a, b) => a + b, 0) /
    webhookMetrics.processingTime.length;

  res.json({
    ...webhookMetrics,
    averageProcessingTime: avgProcessingTime || 0,
  });
});
```

## Complete Example

```typescript theme={"dark"}
import express from 'express';
import crypto from 'crypto';
import { Pool } from 'pg';

const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });

// Webhook middleware
app.use('/webhooks', express.raw({ type: 'application/json' }));

class WebhookHandler {
  constructor() {
    this.secret = process.env.ZUBA_WEBHOOK_SECRET;
  }

  verifySignature(payload, signature, timestamp) {
    if (!this.secret || !signature || !timestamp) return false;

    // Reject timestamps older than 5 minutes
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
      return false;
    }

    const expectedSignature = crypto
      .createHmac('sha256', this.secret)
      .update(`${timestamp}.${payload}`)
      .digest('hex');

    try {
      return crypto.timingSafeEqual(
        Buffer.from(expectedSignature, 'hex'),
        Buffer.from(signature, 'hex'),
      );
    } catch (error) {
      return false;
    }
  }

  async handleEvent(event) {
    switch (event.type) {
      case 'payout.paid':
        await this.updatePayoutStatus(event.data.id, 'completed');
        break;

      default:
        console.log(`Unhandled event: ${event.type}`);
    }
  }

  async updatePayoutStatus(payoutId, status) {
    await db.query(
      'UPDATE payouts SET status = $1, updated_at = NOW() WHERE id = $2',
      [status, payoutId],
    );
  }
}

const handler = new WebhookHandler();

app.post('/webhooks/zuba', async (req, res) => {
  const signature = req.headers['x-zuba-signature'];
  const timestamp = req.headers['x-zuba-timestamp'];
  const payload = req.body.toString();

  if (!handler.verifySignature(payload, signature, timestamp)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(payload);

  try {
    await handler.handleEvent(event);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook processing failed:', error);
    res.status(500).json({ error: 'Processing failed' });
  }
});

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

## Next Steps

* Learn about [error handling](/guides/error-handling) strategies for webhook failures
* Explore the [API reference](/api-reference) for complete event schemas
* Set up monitoring and alerting for production webhook endpoints
* Implement webhook event replay for failed processing scenarios
