Skip to main content

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.

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
  • payout.cancelled - Payout was cancelled

Other Events

  • webhook.test - Test event sent via the test endpoint

Event Payload

Every webhook delivery is a JSON POST request with this envelope:
{
  "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "payout.paid",
  "createdAt": "2026-03-23T14:30:00.000Z",
  "test": false,
  "data": {
    "id": "pay_abc123",
    "clientRef": "your-reference-123",
    "amount": "1000.00",
    "currency": "USD",
    "status": "paid",
    "completedAt": "2026-03-23T14:29:58.000Z"
  }
}
FieldTypeDescription
idstringUnique event ID (evt_ prefix)
typestringEvent type
createdAtstringISO 8601 timestamp of event creation
testbooleantrue if sent from the test webhook endpoint
dataobjectEvent-specific payload
Each request includes these headers:
HeaderDescription
Content-Typeapplication/json
X-Zuba-SignatureHMAC-SHA256 hex signature
X-Zuba-TimestampUnix timestamp (seconds) used in signature

Setting Up Webhook Endpoints

Basic Webhook Handler

Create an endpoint to receive webhook notifications:
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:
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)
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.

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

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'),
  );
}

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.
Make sure your server’s clock is synchronized with NTP. A clock that drifts significantly could cause valid webhooks to be rejected.

IP Allowlisting (Optional)

Restrict webhooks to known IP addresses:
const ALLOWED_IPS = [
  '185.199.108.0/22', // Replace with actual Zuba webhook IPs
  '140.82.112.0/20', // Replace with actual additional ranges
];

function isIPAllowed(ip) {
  // Implementation depends on your IP checking library
  return ALLOWED_IPS.some((range) => isIPInRange(ip, range));
}

app.use('/webhooks', (req, res, next) => {
  const clientIP = req.ip || req.connection.remoteAddress;

  if (!isIPAllowed(clientIP)) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  next();
});

Handling Webhook Failures

Idempotency

Ensure webhooks can be safely retried:
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:
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 with exponential backoff. A delivery is successful when your endpoint responds with a 2xx status code within 30 seconds.
AttemptApproximate delay
1Immediate
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:
curl "https://api-test.zuba.com/v1/webhooks/{endpoint_id}/deliveries" \
  -H "Authorization: Bearer $TOKEN"
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.

Database Integration

Storing Webhook Events

Track webhook events for debugging and compliance:
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()
);
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:
# 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:
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:
# .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.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:
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:
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

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 strategies for webhook failures
  • Explore the API reference for complete event schemas
  • Set up monitoring and alerting for production webhook endpoints
  • Implement webhook event replay for failed processing scenarios