Webhook Best Practices
Production patterns for reliable webhook processing: timeouts, retries, idempotency, and tenant routing.
Timeout & Retry Behavior
Timeout: 10 seconds. Respond within 10 seconds or delivery fails.
Pattern: Acknowledge immediately, process asynchronously.
app.post('/webhooks/debitura', async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).json({ received: true }); // Acknowledge immediately
await queue.enqueue('webhook-processing', req.body); // Process async
});
For signature verification details, see Webhook Security.
Retry Schedule
Debitura retries with exponential backoff and ~10% jitter:
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 60s +/- 10% | ~1 min |
| 3 | 120s +/- 10% | ~3 min |
| 4 | 240s +/- 10% | ~7 min |
| 5 | 480s +/- 10% | ~15 min |
| 6 | 960s +/- 10% | ~31 min |
| 7 | 1800s +/- 10% | ~1 hour |
| 8 | 1800s +/- 10% | ~1.5 hours |
Maximum: 8 attempts over ~1.5 hours.
Auto-Disable Conditions
Webhooks automatically disable after:
-
8 consecutive failures (all retries exhausted)
disabledReason: "Exceeded maximum retry attempts (8 failures)"- Fix the endpoint, then re-enable via API
-
410 Gone response (any attempt)
disabledReason: "Endpoint returned 410 Gone"- Update the webhook URL or delete the subscription
To create or manage webhook subscriptions, see Webhook Setup.
Idempotency with event.id
Use event.id to prevent duplicate processing:
async function processWebhook(webhook) {
const eventId = webhook.id;
// Check if already processed
const exists = await db.processedEvents.findOne({ eventId });
if (exists) {
console.log(`Event ${eventId} already processed`);
return; // Safe duplicate
}
// Process + record atomically
await db.transaction(async (tx) => {
await tx.processedEvents.insert({ eventId, processedAt: new Date() });
if (webhook.event === 'case.created') {
await tx.cases.insert({
caseId: webhook.data.caseId,
externalTenantId: webhook.data.externalTenantId,
reference: webhook.data.reference
});
}
});
}
Database schema:
CREATE TABLE processed_webhook_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
event_type VARCHAR(100)
);
CREATE INDEX idx_processed_events_created ON processed_webhook_events(processed_at);
-- Cleanup old events (run weekly)
DELETE FROM processed_webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
externalTenantId Routing
All events include data.externalTenantId—your identifier for the client. Use it to route events to the correct tenant in your system.
async function processWebhook(webhook) {
const { event, data } = webhook;
const { externalTenantId } = data;
// Load client
const client = await db.clients.findOne({ externalTenantId });
if (!client) {
console.error(`Unknown client: ${externalTenantId}`);
return;
}
// Route by event type
switch (event) {
case 'case.created':
await handleCaseCreated(client, data);
break;
case 'case.updated':
await handleCaseUpdated(client, data);
break;
case 'client.onboarding.contract_signed':
await handleContractSigned(client, data);
break;
}
}
For the complete list of event types and payload schemas, see Webhooks Overview.