Retry and idempotency
If the delivery to your endpoint fails with a transient error (timeout, network error, HTTP 5xx, 429 or 408), INFI automatically redelivers with increasing backoff — up to 6 attempts over ~9 hours:
1min → 5min → 30min → 2h → 6h
You no longer lose an event because of a momentary instability on your side.
What is retried (and what is not)
| Your endpoint response | Behavior |
|---|---|
2xx | Success — delivery closed. |
| Timeout (8s) or network error | Transient → retry with backoff. |
5xx, 429, 408 | Transient → retry with backoff. |
4xx (400, 401, 403, 422…) | Permanent — your endpoint refused the event. Not retried (hammering would not help). Fix the cause (auth, signature validation, route) and use manual resend. |
| Exhausted the 6 attempts | Marked as exhausted. After fixing the endpoint, use manual resend. |
Each redelivery uses a new timestamp — and therefore a new signature (to pass replay protections). The eventId remains the same. Always re-validate HMAC using the X-Infi-Timestamp received in that request; never compare the signature with a previous one. See Signature.
Recommended strategy: event-driven
With automatic retry, the webhook is reliable enough to be the primary path. You do not need aggressive polling.
- React to the webhook as soon as it arrives — confirm the deposit and update your system. The
transaction.paidevent already brings everything you need:transactionId,status,amountCents,netCents,paidAt,endToEndId,payer. - Maintain your balance locally by summing
netCentsof eachtransaction.paid(and subtracting withdrawals), instead of pollingGET /v1/balancein a loop. - Light reconcile as a safety net: a
GET /v1/transactionssweep every 1–2 minutes (not every few seconds) covers anything that escaped — for example, if your endpoint was down beyond the retry window.
Querying GET /v1/balance or GET /v1/transactions/:id every few seconds is unnecessary with webhook + retry, and consumes rate limit for nothing. Prefer to react to the webhook and reconcile sparsely.
Idempotency on your side
INFI guarantees a unique eventId per event (e.g., evt_1715000000000_abcdef12), delivered:
- In the
X-Infi-Event-Idheader. - In the
eventIdfield of the body.
The same eventId arrives when:
- The delivery is automatically redelivered (retry after transient failure).
- Multiple URLs of the same merchant match the event (fanout).
- The delivery is manually resent from the dashboard.
So, dedup’ing by eventId is mandatory — store the processed ones and ignore repetitions:
async function handleWebhook(req) {
const eventId = req.headers['x-infi-event-id'] || req.body.eventId
if (await db.processedEvents.has(eventId)) return // already processed
await db.processedEvents.add(eventId, ttl: '7d')
// apply transition
await applyTransition(req.body.transactionId, req.body.status)
}Since webhook and reconcile (polling) may confirm the same transition, also process it idempotently by transactionId + status:
async function applyTransition(transactionId, newStatus) {
const current = await db.orders.getByTx(transactionId)
if (current?.status === newStatus) return // already applied
await db.orders.setStatus(transactionId, newStatus)
await onStatusChanged(transactionId, newStatus)
}Caveats
- Short timeout: 8s per attempt. Do not do heavy work in the handler — respond
200fast and process in background/queue. A slow handler turns into a timeout → unnecessary retry. - Return the right status:
2xx= received (closes).5xx/timeout = “try again” (becomes a retry).4xx= “do not send me this” (closes as permanent, no retry). Use this to your advantage. - Dashboard history: every delivery — including failed ones and each automatic retry — is kept under Webhooks → Delivery history for 60 days, with the exact payload and attempt timeline. Useful for post-mortem debugging.
- Manual resend: if a delivery exhausted attempts or was refused (4xx), fix the endpoint and trigger a manual resend — manual attempts appear with
manual: truein the history.