Skip to content

Webhooks

Webhooks allow you to receive real-time notifications when payments are completed, fail, or get their receipt. 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, for example, 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, within 60 seconds
  • Be a public HTTPS URL (HTTP URLs, internal hostnames such as localhost or *.local, and private IP addresses are rejected)
  • Handle the webhook payload
  1. Log in to your Zelta Pay Dashboard
  2. Open Webhooks in the sidebar (shown to the admin and owner roles only)
  3. In the Nuevo webhook card, enter your public HTTPS URL and click Crear. The dashboard only allows it once the account has an active payment provider
  4. Click Enviar ping to send a signed webhook.ping to your endpoint

An account has a single account-level webhook, and there is no event selection: it receives every event of the account. Ignore the types you don’t handle.

Integration projects have their own webhook: open Proyectos → your project, and set it in the Webhook card of the project’s overview. The project’s Webhooks tab lists every delivery with its status. Each webhook (account-level or project) has its own signing secret.

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.

Payment link events (account webhook):

  • payment.success - A customer completed a payment
  • payment.failed - A payment attempt ended without payment (decline, error, cancelled or expired wallet payment)
  • receipt.created - The PDF receipt of a payment was generated
  • webhook.ping - Sent when you click Enviar ping in the dashboard

Links created with a project-scoped API key send payment.success, payment.failed and receipt.created to the project’s webhook first, and fall back to the account webhook when the project has none.

Integration project events (sent only to the project webhook, with no fallback to the account webhook): customer.created, charge.succeeded, charge.failed, charge.deferred, charge.unknown, subscription.created, subscription.resumed, subscription.past_due, subscription.cancelled, subscription.billing_blocked, subscription.items_updated, subscription.customer_updated, invoice.paid, invoice.payment_failed and payment_link.rejected.

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 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:
// Every event type is sent to the webhook: ignore the ones you don't handle
console.log('Unhandled 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,
zeltaOrderId: transaction.orderId,
// metadata is omitted when the link was created without it
yourOrderId: 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 identifier (a UUID, same as eventId in the body; stable per transaction for payment.success)9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f
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 reference.

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

  • Fast delivery - Events are normally sent within a few seconds (the queue batches events for up to 2 seconds); there is no latency guarantee
  • Automatic retries - Up to 6 attempts (1 + 5 retries), 15 seconds apart, so all attempts happen within a few minutes. Don’t count on retries to cover an endpoint outage longer than that
  • Dead Letter Queue - Deliveries that fail for good are marked as dead-lettered (visible on the project’s Webhooks tab for project webhooks) and reported to Zelta ops. They cannot be replayed, so reconcile missed events through the API

Your webhook endpoint should return appropriate HTTP status codes:

  • 2xx (Success) - Event acknowledged and removed from queue
  • 408, 429, 5xx, a timeout (no response within 60 seconds) or a network error - Retried after a fixed 15 seconds, up to 6 attempts in total
  • Any other status (other 4xx, and 3xx because redirects are not followed) - Dead-lettered immediately, with no retry
// 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, no retry
return res.status(400).json({ error: 'Bad request' });
}
// Server error - event will be retried after 15 seconds
return res.status(500).json({ error: 'Internal server error' });
}
});

For complete details about retries, the Dead Letter Queue, 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
  • Lost acknowledgements - Your endpoint processed the event but the 2xx response did not arrive in time (timeout), so it is retried
  • Server restarts - Webhook processed before restart, retried after
  • Race conditions - Multiple webhook deliveries arrive simultaneously
  • Re-sent payments - payment.success for the same transaction keeps the same eventId when it is sent again
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;
// Your own order id, if you set it in the link's metadata (optional)
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: each attempt waits up to 60 seconds for your response, and a timeout is retried:

// 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 publicly accessible over HTTPS
  • Check the webhook is enabled
  • Check firewall settings
  • Test with Enviar ping in the dashboard
  • Don’t answer with a redirect (3xx): redirects are not followed and the event is dead-lettered

Signature verification failing

  • Check webhook secret is correct (each webhook has its own secret, and changing the URL generates a new one)
  • 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: