Skip to content

Webhooks

Webhooks allow you to receive real-time notifications when payments are completed. This guide covers how to set up, configure, and handle webhook events from Zelta Pay.

Webhooks are HTTP callbacks that notify your application when specific events occur. Instead of polling the API for updates, Zelta Pay sends a POST request to your endpoint when a payment is completed.

  • Real-time notifications - Get instant updates when payments complete
  • Efficient - No need to poll the API repeatedly
  • Reliable - Built-in retry logic ensures delivery
  • Secure - HMAC signature verification prevents spoofing

Your webhook endpoint must:

  • Accept POST requests
  • Return a 2xx status code for successful processing
  • Be accessible via HTTPS (required for security)
  • Handle the webhook payload
  1. Log in to your Zelta Pay Dashboard
  2. Navigate to Webhooks
  3. Click Add Webhook
  4. Enter your webhook URL (must be HTTPS)
  5. Select the events you want to receive
  6. Test your webhook endpoint
const express = require('express');
const app = express();
// Middleware to get raw body for signature verification
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const payload = JSON.parse(req.body);
if (payload.type === 'payment.success') {
console.log('Payment completed:', payload.transaction.amount);
// Update your database, send confirmation email, etc.
}
res.status(200).json({ received: true });
});
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
try {
// Get headers
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
// Get raw body
const rawBody = await request.text();
// Parse payload
const payload = JSON.parse(rawBody);
if (payload.type === 'payment.success') {
console.log('Payment completed:', payload.transaction.amount);
// Update your database, send confirmation email, etc.
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Webhook processing error:', error);
return new Response(JSON.stringify({ error: 'Processing failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Bindings = {
WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Enable CORS
app.use('*', cors());
app.post('/webhook', async (c) => {
try {
// Get headers
const signature = c.req.header('zeltapay-signature');
const timestamp = c.req.header('zeltapay-timestamp');
if (!signature || !timestamp) {
return c.json({ error: 'Missing required headers' }, 400);
}
// Get raw body
const rawBody = await c.req.text();
// Parse payload
const payload = JSON.parse(rawBody);
if (payload.type === 'payment.success') {
console.log('Payment completed:', payload.transaction.amount);
// Update your database, send confirmation email, etc.
}
return c.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return c.json({ error: 'Processing failed' }, 500);
}
});
export default app;

Zelta Pay sends different types of webhook events to notify your application about important changes. The main events you’ll receive are:

  • payment.success - Sent when a customer successfully completes a payment
  • webhook.ping - Sent when you test your webhook endpoint

All webhook events follow a consistent structure with common fields and event-specific data. For complete details about event types, payloads, and field descriptions, see the Webhook Events API Reference.

function handleWebhookEvent(payload) {
switch (payload.type) {
case 'payment.success':
handlePaymentSuccess(payload);
break;
case 'webhook.ping':
console.log('Webhook ping received:', payload.message);
break;
default:
console.log('Unknown event type:', payload.type);
}
}
function handlePaymentSuccess(payload) {
const { transaction, customer, metadata } = payload;
console.log('Payment completed:', {
amount: transaction.amount,
currency: transaction.currency,
customer: customer.customerName,
orderId: metadata.orderId
});
// Update your database, send confirmation email, etc.
}

Every webhook request includes standardized headers for identification, security, and delivery tracking. The most important headers for processing webhooks are:

HeaderDescriptionExample
Zeltapay-Event-IdUnique event identifierevt_1234567890abcdef
Zeltapay-Event-TypeEvent typepayment.success
Zeltapay-TimestampUnix timestamp1640995200
Zeltapay-SignatureHMAC signaturet=1640995200, v1=abc123...
Zeltapay-Delivery-AttemptAttempt number1

For a complete list of headers and their descriptions, see the Webhook Events API Reference.

Zelta Pay uses a robust delivery system to ensure webhook events reach your endpoint. The system includes:

  • Immediate delivery - Events are sent within 1 second
  • Automatic retries - Up to 6 attempts over 24 hours
  • Exponential backoff - Smart retry timing with jitter
  • Dead Letter Queue - Failed deliveries are stored for manual review

Your webhook endpoint should return appropriate HTTP status codes:

  • 2xx (Success) - Event acknowledged and removed from queue
  • 4xx (Client Error) - Event moved to Dead Letter Queue
  • 5xx (Server Error) - Event retried with exponential backoff
// Return appropriate status codes
app.post('/webhook', (req, res) => {
try {
// Process webhook
processWebhookEvent(req.body);
// Success - event will be acknowledged
res.status(200).json({ received: true });
} catch (error) {
if (error.message.includes('validation')) {
// Client error - event goes to DLQ
return res.status(400).json({ error: 'Bad request' });
}
// Server error - event will be retried
return res.status(500).json({ error: 'Internal server error' });
}
});

For complete details about retry schedules, Dead Letter Queue management, and monitoring, see the Webhook Delivery Guide.

Idempotency ensures that processing the same webhook event multiple times produces the same result. This is crucial for webhook reliability.

  • Network issues - Webhook delivery fails, gets retried
  • Server restarts - Webhook processed before restart, retried after
  • Race conditions - Multiple webhook deliveries arrive simultaneously
  • Manual retries - Events manually retried from dashboard
const processedEvents = new Set();
function handleWebhookEvent(payload) {
const eventId = payload.eventId;
if (processedEvents.has(eventId)) {
console.log(`Duplicate event ignored: ${eventId}`);
return;
}
processedEvents.add(eventId);
// Process event...
processPaymentSuccess(payload);
}
async function processPaymentSuccess(payload) {
const eventId = payload.eventId;
const orderId = payload.metadata.orderId;
try {
await db.transaction(async (trx) => {
// Check if already processed
const existing = await trx('webhook_idempotency')
.where('event_id', eventId)
.first();
if (existing) {
console.log(`Event ${eventId} already processed`);
return;
}
// Mark as processing
await trx('webhook_idempotency').insert({
event_id: eventId,
status: 'processing'
});
// Process payment
await trx('orders')
.where('id', orderId)
.update({ status: 'paid' });
});
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
console.log(`Event ${eventId} already processed`);
return;
}
throw error;
}
}

For complete implementation strategies, database schemas, and best practices, see the Webhook Idempotency Guide.

Keep webhook processing fast to avoid timeouts:

// Node.js - Respond immediately, process asynchronously
app.post('/webhook', (req, res) => {
res.status(200).json({ received: true });
setImmediate(async () => {
try {
const payload = JSON.parse(req.body);
await processWebhookEvent(payload);
} catch (error) {
console.error('Async webhook processing error:', error);
}
});
});
// Cloudflare Workers - Use background tasks
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
const payload = await request.json();
// Respond immediately
const response = new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
// Process in background
ctx.waitUntil(processWebhookEvent(payload));
return response;
} catch (error) {
return new Response(JSON.stringify({ error: 'Processing failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
// Node.js - Proper error handling
app.post('/webhook', async (req, res) => {
try {
// Verify signature
if (!verifySignature(req.body.toString(), req.headers['zeltapay-timestamp'], req.headers['zeltapay-signature'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body);
await processWebhookEvent(payload);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
// Return 5xx for retryable errors
if (error.message.includes('database') || error.message.includes('network')) {
return res.status(500).json({ error: 'Internal server error' });
}
// Return 4xx for non-retryable errors
return res.status(400).json({ error: 'Bad request' });
}
});

Track key metrics to ensure webhook reliability:

class WebhookMonitor {
constructor() {
this.metrics = {
total: 0,
success: 0,
failure: 0,
retry: 0
};
}
recordDelivery(attempt, success) {
this.metrics.total++;
if (success) {
this.metrics.success++;
} else {
this.metrics.failure++;
}
if (attempt > 1) {
this.metrics.retry++;
}
this.logMetrics();
}
logMetrics() {
const successRate = (this.metrics.success / this.metrics.total) * 100;
const retryRate = (this.metrics.retry / this.metrics.total) * 100;
console.log('Webhook Metrics:', {
total: this.metrics.total,
successRate: `${successRate.toFixed(2)}%`,
retryRate: `${retryRate.toFixed(2)}%`,
failures: this.metrics.failure
});
}
}

Webhook not receiving events

  • Check webhook URL is correct and accessible
  • Verify HTTPS is enabled
  • Check firewall settings
  • Test with webhook ping

Signature verification failing

  • Check webhook secret is correct
  • Ensure raw body is used (not parsed JSON)
  • Verify timestamp format
  • Check signature header format

Duplicate events

  • Implement idempotency using eventId
  • Check for race conditions
  • Monitor delivery attempts

Enable debug logging to troubleshoot:

// Node.js debug logging
function debugVerification(req, secret) {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const rawBody = req.body.toString();
console.log('Debug verification:', {
signature,
timestamp,
rawBodyLength: rawBody.length,
secretLength: secret.length
});
}
// Cloudflare Workers debug logging
export default {
async fetch(request, env, ctx) {
if (request.method === 'POST' && new URL(request.url).pathname === '/webhook') {
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
const rawBody = await request.text();
console.log('Debug verification:', {
signature,
timestamp,
rawBodyLength: rawBody.length,
secretLength: env.WEBHOOK_SECRET?.length || 0,
userAgent: request.headers.get('user-agent'),
cfRay: request.headers.get('cf-ray')
});
}
return new Response('Not found', { status: 404 });
}
};

Now that you understand webhooks, explore these resources: