Delivery & Retry
The dispatcher operates with an at-least-once policy. Network failures and slow merchant responses can cause the same event to arrive more than once. This page covers the retry policy and idempotency patterns for the merchant side.
Retry policy
The dispatcher automatically retries when the response does not count as a success or when a timeout occurs.
| Field | Value |
|---|---|
| Success criteria | HTTP 200–299, or 409 Conflict (idempotent ack) |
| Response timeout | 10s (connect 3s) |
| Backoff formula | delay = min(1s × 2^retry_count, 1024s) |
| Max retries | 10 |
| Total cumulative wait | ~1023s (~17 minutes) |
Offload heavy work to a queue (e.g. Sidekiq, Asynq) and return 200 immediately. Anything over 10s triggers a retry.
Backoff schedule
| Attempt | retry_count | delay (s) | cumulative (s) |
|---|---|---|---|
| 1 | — | 0 | 0 |
| 2 | 0 | 1 | 1 |
| 3 | 1 | 2 | 3 |
| 4 | 2 | 4 | 7 |
| 5 | 3 | 8 | 15 |
| 6 | 4 | 16 | 31 |
| 7 | 5 | 32 | 63 |
| 8 | 6 | 64 | 127 |
| 9 | 7 | 128 | 255 |
| 10 | 8 | 256 | 511 |
| 11 | 9 | 512 | 1023 |
Response code semantics
| Status code | Dispatcher interpretation |
|---|---|
| 200–299 | Successfully received. Logged as DELIVERED in webhook_log. |
| 409 Conflict | Idempotent ack — interpreted as "already received". Marked DELIVERED, no retry. |
| Other 4xx | Triggers retry (redeliver after backoff). |
| 5xx / timeout | Triggers retry (redeliver after backoff). |
The dispatcher determines success or failure based on the HTTP status code alone. The merchant response body is stored in webhook_log up to 1KB and used only for debugging.
Idempotency / duplicate handling
Merchants should use the webhook-id header as a dedup key to safely
handle duplicates.
Example dedup table
CREATE TABLE webhook_log (
webhook_id CHAR(36) PRIMARY KEY,
payment_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
INDEX (payment_id, received_at)
);
Handling pattern
1. Verify signature (fail -> 401)
2. INSERT INTO webhook_log (...) ON CONFLICT (webhook_id) DO NOTHING
-> if 0 rows affected this is a duplicate; respond 200 OK (or 409 Conflict) immediately
3. Domain processing (inside a transaction)
4. 200 OK
If the merchant responds with HTTP 409 Conflict, the dispatcher interprets it as an "already received" signal and marks DELIVERED without retrying. Responding 409 instead of 200 for the duplicate-dedup case is safe, and it actually makes the audit log clearer by distinguishing "idempotent attempts" from "newly processed attempts" via the response code. Other 4xx/5xx codes trigger retries, so use them carefully.
Transaction pattern example
import { pool } from './db.js';
async function processWebhook(req, res) {
// 1. Assume signature verification has already passed in an earlier step.
const event = JSON.parse(req.body.toString('utf8'));
const webhookId = req.get('webhook-id');
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(
`INSERT INTO webhook_log (webhook_id, payment_id, event_type)
VALUES ($1, $2, $3)
ON CONFLICT (webhook_id) DO NOTHING`,
[webhookId, event.data.payment.id, event.type]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(409).send('already received'); // idempotent ack
}
// Domain processing
switch (event.type) {
case 'payment.completed': await markOrderPaid(event.data.payment); break;
case 'payment.failed': await markOrderFailed(event.data.payment); break;
case 'payment.expired': await restoreCart(event.data.payment); break;
case 'payment.cancelled': await markOrderCancelled(event.data.payment); break;
}
await client.query('COMMIT');
res.status(200).send('ok');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Recommended monitoring metrics
- Receive rate: webhooks received per unit time
- Signature verification failure rate: detects abnormal traffic (should converge to 0 in steady state)
- Duplicate rate: rate of repeated arrivals for the same
webhook-id(verify at-least-once is at normal levels) - Processing latency (p95): time from receipt to 200 response. If close to 10s, consider introducing a queue.
- Retry-reach rate: fraction of webhooks not acked on first try