Skip to main content
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:
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.

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 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:
{
  "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"
  }
}
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

Event-specific data fields

payout.* events
FieldTypeDescription
idstring (UUID)Payout ID
clientRefstringYour reference from the create request
amountstringMajor-unit decimal string (e.g. "1000.00")
currencystringISO 4217 currency code
statusstringPayout status after the transition
completedAtstring | nullISO 8601 completion timestamp; null until settlement
order.* events
FieldTypeDescription
idstring (UUID)Order ID
clientRefstring | nullYour reference from the create request
statusstringOrder status after the transition
fromCurrencystringSell (source) currency
toCurrencystringBuy (delivered) currency
fromAmountstringTotal source debit, major-unit decimal string
toAmountstringAmount delivered, major-unit decimal string
ratestringAll-in rate: fromCurrency per 1 toCurrency
failureReasonstring | nullexecution_failed or review_expired; set only on order.failed
uetrstring | nullOutbound wire reference; set on completion over a wire rail
settlementobject | nullFunding instructions for settlement-funded orders (settlementId, method, requiredAmount, currency, reference, status, instructions, expiresAt); null on balance-funded orders
fundingDeadlineAtstringISO 8601 funding deadline
executionDeadlineAtstring | nullISO 8601 desk execution deadline, set once funds are received
fundedAtstring | nullISO 8601 funding timestamp
completedAtstring | nullISO 8601 completion timestamp
createdAtstringISO 8601 creation timestamp
account.created
FieldTypeDescription
idstring (UUID)Account record ID
parentAccountIdstring (UUID)The master account the record was created under
accountTypestringsub for records created via POST /v1/accounts
statusstringAccount status (pending on create)
countrystringISO 3166 alpha-2 country code
createdAtstringISO 8601 creation timestamp
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'),
  );
}
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
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}`))
}
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();
    }
}

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

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:
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 (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.
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.sandbox.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