Use idempotency keys to safely retry POST requests without creating duplicate resources. Include an Idempotency-Key header with a unique value (UUID recommended).
Webhooks may be delivered more than once (due to retries, network issues, or infrastructure failover). Your handler must be idempotent.
import { db } from './database';async function handleWebhook(event: WebhookEvent): Promise<boolean> { // 1. Check if this event was already processed const existing = await db.webhookEvent.findUnique({ where: { event_id: event.id }, }); if (existing) { console.log(`Event ${event.id} already processed, skipping`); return true; // Already handled } // 2. Process the event inside a transaction await db.$transaction(async (tx) => { // Record the event FIRST to claim it await tx.webhookEvent.create({ data: { event_id: event.id, type: event.type, processed_at: new Date(), }, }); // Then perform business logic switch (event.type) { case 'order.paid': await tx.order.update({ where: { id: event.data.id }, data: { payment_status: 'paid' }, }); break; // ... other event types } }); return true;}
Database schema for deduplication:
CREATE TABLE webhook_events ( event_id VARCHAR(64) PRIMARY KEY, -- e.g., "evt_abc123" type VARCHAR(128) NOT NULL, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), payload JSONB);-- Add a TTL index to auto-clean old records (PostgreSQL + pg_cron)-- DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
Use a database transaction that inserts the event ID and performs the business logic atomically. This ensures that if processing fails, the event is not marked as handled and will be retried.
app.post('/webhooks/hc', express.raw({ type: 'application/json' }), async (req, res) => { // 1. Verify signature if (!verifyWebhookSignature(req.body, req.headers['x-webhook-signature'], secret)) { return res.status(401).send('Invalid signature'); } // 2. Return 200 immediately -- process asynchronously res.status(200).send('OK'); // 3. Process in the background (after response is sent) const event = JSON.parse(req.body.toString()); processEventAsync(event).catch((err) => { console.error(`Failed to process event ${event.id}:`, err); });});
Return a 2xx response as quickly as possible. If your handler takes more than 30 seconds, the delivery will be considered failed and retried. Move heavy processing to a background queue.
async function handleOrderUpdate(event: WebhookEvent) { const order = await db.order.findUnique({ where: { id: event.data.id } }); if (!order) { // Order not in our system yet -- store the event for later processing await db.pendingEvent.create({ data: { event_id: event.id, payload: event } }); return; } // Only apply if the event is newer than our last update const eventTime = new Date(event.created_at); if (order.last_webhook_at && eventTime <= order.last_webhook_at) { console.log(`Skipping stale event ${event.id}`); return; } await db.order.update({ where: { id: event.data.id }, data: { status: event.data.status, last_webhook_at: eventTime, }, });}
If you receive an event for a resource that does not exist in your system yet:
Store the event in a pending queue
When the prerequisite event arrives (e.g., order.created), process it first
To receive webhooks on your local machine during development:
# 1. Start ngrok to expose your local serverngrok http 3000# 2. Copy the HTTPS URL (e.g., https://abc123.ngrok.io)# 3. Register the webhook with your ngrok URLcurl -X POST https://api.headlesscommerce.io/v1/admin/webhooks \ -H "Authorization: Bearer sk_test_your_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://abc123.ngrok.io/webhooks/hc", "events": ["order.created", "order.paid"] }'
Remember to update or delete your development webhooks when you are done testing. Stale endpoints will accumulate failed deliveries and may be auto-deactivated.