PayinferenceDocs
payinference.com

Idempotency and retries

This page describes exactly what is and is not idempotent in the current API, and how to retry safely around that.

Decision idempotency keys#

POST /v1/decision supports an optional Idempotency-Key header. When you send one, retries of the same request replay the originally stored response instead of creating a new decision:

bash
curl -s http://localhost:4000/v1/decision \
  -H "content-type: application/json" \
  -H "x-api-key: $PAYINFERENCE_API_KEY" \
  -H "Idempotency-Key: req_01HX9N6QK8Y7F2V6K3S4A9M2B1" \
  -d '{ "merchant_id": "m_123", "order_id": "order_456", "transaction": { "amount": 14900, "currency": "USD", "country": "US", "payment_method": "card" }, "available_providers": ["stripe"] }'

The semantics:

  • The key is scoped to your workspace and hashed server-side; up to 255 characters. Use one key per payment attempt (a ULID or your attempt id), not per order.
  • A replayed response is byte-identical to the original, including decision_id, and carries an idempotency-replayed: true response header.
  • Replays are served for one hour after the first response. After that, the same key creates a new decision.
  • Two concurrent requests with the same key resolve to one stored decision; both callers observe the same response.
  • The idempotency store is best-effort by design: if it is unavailable, the decision is computed normally rather than failing checkout. Treat the key as retry deduplication, not as a transactional guarantee.

With the Node SDK:

ts
const decision = await payinference.decide(params, { idempotencyKey: attemptId });

When to use it: any time your client might retry after a timeout and you want to be certain you never act on two different decisions for one attempt. Without a key, each call creates a new decision_id (a point-in-time answer against fresher data), which is also fine as long as you execute exactly one of them.

Replay staleness#

A decision's ttl_ms (3 seconds) is much shorter than the replay window. A replayed response returns the original decision, including its original ttl_ms; if you are replaying long after the first call, treat it as the record of what was decided, not as fresh routing advice. For a genuinely new attempt, use a new key.

order_id ties attempts together#

Whatever your retry strategy, keep order_id stable for all attempts of the same order:

  • First attempt: POST /v1/decision with order_id: "order_456".
  • Retry after a failure: same order_id, plus a previous_attempt block.
  • The decision log then shows every decision for the order in sequence.

Outcomes are idempotent#

POST /v1/outcomes upserts by decision_id. One decision has at most one outcome; reporting again with the same decision_id updates the stored outcome instead of duplicating it.

Practical consequences:

  • Safe to retry outcome reports aggressively. Delivery matters more than exactly-once.
  • If your PSP result changes (for example a timeout later resolves to approved), report again with the same decision_id and the corrected outcome.

Client retry policy#

Recommended, and matching what the Node SDK does by default:

Call On timeout / network error On HTTP error (4xx/5xx response)
POST /v1/decision Do not burn checkout time on retries. Fall back to your merchant-defined safe default; if you do retry, reuse the same Idempotency-Key Never retry
POST /v1/outcomes Retry with backoff; it is idempotent Never retry
GET endpoints Retry with backoff Never retry

The SDK retries network errors only, never HTTP responses: an HTTP error is an answer, and replaying it changes nothing. For decide() the SDK defaults to zero retries and instead supports a local fallback decision, because a checkout should never wait through multiple timeout windows.

ts
const payinference = new PayInferenceClient({
  apiKey: process.env.PAYINFERENCE_API_KEY!,
  timeoutMs: 50,              // hard budget for decide()
  reportingTimeoutMs: 2000,   // outcomes and health reads
  retries: 2,                 // network-error retries for reporting calls
  fallback: { provider: 'stripe' },
});

Deduplicating on your side#

Idempotency keys dedupe the decision; put your own idempotency where money moves:

  1. Deduplicate checkout submissions on order_id before calling PayInference.
  2. Use your PSP's idempotency mechanism for the actual charge.
  3. Record decision_id with the charge, so the outcome report and any later investigation reference the decision that drove it.