Skip to main content

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.

Express.js webhook handler
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:

AttemptDelayCumulative
10s0s
260s +/- 10%~1 min
3120s +/- 10%~3 min
4240s +/- 10%~7 min
5480s +/- 10%~15 min
6960s +/- 10%~31 min
71800s +/- 10%~1 hour
81800s +/- 10%~1.5 hours

Maximum: 8 attempts over ~1.5 hours.

Auto-Disable Conditions

Webhooks automatically disable after:

  1. 8 consecutive failures (all retries exhausted)

    • disabledReason: "Exceeded maximum retry attempts (8 failures)"
    • Fix the endpoint, then re-enable via API
  2. 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:

Idempotent webhook 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:

Event deduplication table
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.

Tenant-based event routing
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.