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