Skip to main content

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:

  1. Uptime monitoring - Ensure your endpoint is accessible
  2. Response time - Alert if responses exceed 10s
  3. Error rate - Alert if 5xx responses exceed threshold
  4. 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