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.
What are Webhooks?
Section titled “What are Webhooks?”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.
Benefits
Section titled “Benefits”- 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
Quick Start
Section titled “Quick Start”1. Create Your Webhook Endpoint
Section titled “1. Create Your Webhook Endpoint”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
localhostor*.local, and private IP addresses are rejected) - Handle the webhook payload
2. Configure Webhook in Dashboard
Section titled “2. Configure Webhook in Dashboard”- Log in to your Zelta Pay Dashboard
- Open Webhooks in the sidebar (shown to the admin and owner roles only)
- 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
- Click Enviar ping to send a signed
webhook.pingto 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.
Implementation Examples
Section titled “Implementation Examples”Node.js Example (Express)
Section titled “Node.js Example (Express)”const express = require('express');const app = express();
// Middleware to get raw body for signature verificationapp.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 });});Cloudflare Workers Example
Section titled “Cloudflare Workers Example”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' } }); } }};Hono with Cloudflare Workers Example
Section titled “Hono with Cloudflare Workers Example”import { Hono } from 'hono';import { cors } from 'hono/cors';
type Bindings = { WEBHOOK_SECRET: string;};
const app = new Hono<{ Bindings: Bindings }>();
// Enable CORSapp.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;Webhook Events
Section titled “Webhook Events”Zelta Pay sends different types of webhook events to notify your application about important changes.
Event Types
Section titled “Event Types”Payment link events (account webhook):
payment.success- A customer completed a paymentpayment.failed- A payment attempt ended without payment (decline, error, cancelled or expired wallet payment)receipt.created- The PDF receipt of a payment was generatedwebhook.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.
Event Structure
Section titled “Event Structure”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.
Processing Events
Section titled “Processing Events”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.}Webhook Headers
Section titled “Webhook Headers”Every webhook request includes standardized headers for identification, security, and delivery tracking. The most important headers for processing webhooks are:
| Header | Description | Example |
|---|---|---|
Zeltapay-Event-Id | Unique event identifier (a UUID, same as eventId in the body; stable per transaction for payment.success) | 9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f |
Zeltapay-Event-Type | Event type | payment.success |
Zeltapay-Timestamp | Unix timestamp | 1640995200 |
Zeltapay-Signature | HMAC signature | t=1640995200, v1=abc123... |
Zeltapay-Delivery-Attempt | Attempt number | 1 |
For a complete list of headers and their descriptions, see the Webhook Events reference.
Delivery and Retry Logic
Section titled “Delivery and Retry Logic”Zelta Pay uses a robust delivery system to ensure webhook events reach your endpoint. The system includes:
Key Features
Section titled “Key Features”- 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
Response Handling
Section titled “Response Handling”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
Best Practices
Section titled “Best Practices”// Return appropriate status codesapp.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
Section titled “Idempotency”Idempotency ensures that processing the same webhook event multiple times produces the same result. This is crucial for webhook reliability.
Why Idempotency Matters
Section titled “Why Idempotency Matters”- 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.successfor the same transaction keeps the sameeventIdwhen it is sent again
Basic Implementation
Section titled “Basic Implementation”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);}Database-Level Idempotency
Section titled “Database-Level Idempotency”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.
Best Practices
Section titled “Best Practices”1. Respond Quickly
Section titled “1. Respond Quickly”Keep webhook processing fast: each attempt waits up to 60 seconds for your response, and a timeout is retried:
// Node.js - Respond immediately, process asynchronouslyapp.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 tasksexport 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' } }); } }};2. Handle Errors Gracefully
Section titled “2. Handle Errors Gracefully”// Node.js - Proper error handlingapp.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' }); }});3. Monitor Webhook Health
Section titled “3. Monitor Webhook Health”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 }); }}Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”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
Debug Mode
Section titled “Debug Mode”Enable debug logging to troubleshoot:
// Node.js debug loggingfunction 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 loggingexport 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 }); }};Next Steps
Section titled “Next Steps”Now that you understand webhooks, explore these resources:
- Signature Verification Guide - Complete signature verification implementation
- Webhook Delivery Guide - Delivery mechanisms and retry logic
- Webhook Idempotency Guide - Prevent duplicate processing
- Webhook Events - Event types and payloads
- Use Cases - See webhooks in action