EnglishWebhooksRetry and idempotency

Retry and idempotency

Automatic retry with backoff

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 responseBehavior
2xxSuccess — delivery closed.
Timeout (8s) or network errorTransient → retry with backoff.
5xx, 429, 408Transient → 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 attemptsMarked as exhausted. After fixing the endpoint, use manual resend.
Signature changes per attempt

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.

With automatic retry, the webhook is reliable enough to be the primary path. You do not need aggressive polling.

  1. React to the webhook as soon as it arrives — confirm the deposit and update your system. The transaction.paid event already brings everything you need: transactionId, status, amountCents, netCents, paidAt, endToEndId, payer.
  2. Maintain your balance locally by summing netCents of each transaction.paid (and subtracting withdrawals), instead of polling GET /v1/balance in a loop.
  3. Light reconcile as a safety net: a GET /v1/transactions sweep every 1–2 minutes (not every few seconds) covers anything that escaped — for example, if your endpoint was down beyond the retry window.
Avoid aggressive polling

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-Id header.
  • In the eventId field 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 200 fast 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: true in the history.