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"
}
}
| 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:
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
- Extract the
X-Zuba-Signature and X-Zuba-Timestamp headers
- Concatenate the timestamp, a literal
., and the raw request body
- Compute the HMAC-SHA256 of that string using your signing secret
- Compare your computed signature against the header value using a constant-time comparison
- 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.
| 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:
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
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