Webhook Best Practices
Guidelines for reliable webhook handling.
Respond Quickly
Return a 2xx response as quickly as possible. Process events asynchronously after responding:
app.post('/webhooks/zkp2p', async (req, res) => {
// Verify signature...
const event = JSON.parse(req.body.toString());
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
processEventAsync(event).catch((error) => {
console.error('Failed to process webhook:', error);
});
});
async function processEventAsync(event: WebhookPayload) {
// Your business logic here
}
tip
If you don't respond within 30 seconds, the request will timeout and be retried.
Handle Duplicate Events
Use the X-Webhook-Id header to detect and skip duplicate events:
const processedEvents = new Set<string>(); // Use Redis in production
app.post('/webhooks/zkp2p', async (req, res) => {
const eventId = req.headers['x-webhook-id'] as string;
// Check if already processed
if (await isEventProcessed(eventId)) {
return res.status(200).send('Already processed');
}
// Mark as processing
await markEventProcessing(eventId);
// Process event...
// Mark as complete
await markEventProcessed(eventId);
res.status(200).send('OK');
});
For production, store processed event IDs in Redis or your database:
import Redis from 'ioredis';
const redis = new Redis();
const EVENT_TTL = 60 * 60 * 24 * 7; // 7 days
async function isEventProcessed(eventId: string): Promise<boolean> {
const exists = await redis.exists(`webhook:${eventId}`);
return exists === 1;
}
async function markEventProcessed(eventId: string): Promise<void> {
await redis.setex(`webhook:${eventId}`, EVENT_TTL, 'processed');
}
Make Handlers Idempotent
Design your handlers so running them multiple times produces the same result:
// Bad: Creates duplicate records
async function handleOrderFulfilled(event: WebhookPayload) {
await db.orders.create({
id: generateNewId(),
...event.data.order,
});
}
// Good: Uses event data as unique key
async function handleOrderFulfilled(event: WebhookPayload) {
await db.orders.upsert({
where: { id: event.data.order!.id },
update: { status: 'paid' },
create: {
id: event.data.order!.id,
status: 'paid',
...
},
});
}
Handle Out-of-Order Events
Events may arrive out of order. Use timestamps and status to handle this:
async function handleOrderEvent(event: WebhookPayload) {
const order = event.data.order;
if (!order) return;
const existing = await db.orders.findUnique({
where: { id: order.id },
});
// Skip if we have a newer status
if (existing && isNewerStatus(existing.status, order.status)) {
console.log('Skipping older status update');
return;
}
await db.orders.update({
where: { id: order.id },
data: { status: order.status },
});
}
function isNewerStatus(current: string, incoming: string): boolean {
const statusOrder = [
'SESSION_CREATED',
'SIGNAL_SENT',
'SIGNAL_MINED',
'PAYMENT_SENT',
'PROOF_VERIFIED',
'FULFILLED',
];
return statusOrder.indexOf(current) > statusOrder.indexOf(incoming);
}
Log Everything
Log webhook events for debugging:
import pino from 'pino';
const logger = pino();
app.post('/webhooks/zkp2p', (req, res) => {
const eventId = req.headers['x-webhook-id'];
const event = JSON.parse(req.body.toString());
logger.info({
eventId,
eventType: event.type,
sessionId: event.data.session.id,
orderId: event.data.order?.id,
}, 'Received webhook');
try {
// Process...
logger.info({ eventId }, 'Webhook processed successfully');
res.status(200).send('OK');
} catch (error) {
logger.error({ eventId, error }, 'Webhook processing failed');
res.status(500).send('Error');
}
});
Error Handling
Don't catch all errors - let some bubble up for retries:
async function processEvent(event: WebhookPayload) {
try {
switch (event.type) {
case 'order.fulfilled':
await handleFulfilled(event);
break;
}
} catch (error) {
// Log the error
console.error('Error processing webhook:', error);
// Rethrow so the endpoint returns 500 and triggers a retry
throw error;
}
}
Testing
Test your webhook handler with the test endpoint:
curl -X POST https://api.zkp2p-pay.xyz/api/webhooks/wh_abc123/test \
-H "X-API-Key: your_api_key"
Or use tools like ngrok during development:
# Expose local server
ngrok http 3000
# Use the ngrok URL for your webhook
# https://abc123.ngrok.io/webhooks/zkp2p
Monitoring
Set up monitoring for your webhook endpoint:
- Uptime monitoring - Ensure your endpoint is accessible
- Response time - Alert if responses exceed 10s
- Error rate - Alert if 5xx responses exceed threshold
- Queue depth - Monitor async processing backlog
Summary Checklist
- Verify signatures on all webhooks
- Check timestamp to prevent replay attacks
- Respond with 200 within 30 seconds
- Process events asynchronously
- Handle duplicate events (idempotency)
- Handle out-of-order events
- Log all webhook activity
- Monitor endpoint health
- Test with the test endpoint