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:
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 anidempotency-replayed: trueresponse 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:
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/decisionwithorder_id: "order_456". - Retry after a failure: same
order_id, plus aprevious_attemptblock. - 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_idand 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.
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:
- Deduplicate checkout submissions on
order_idbefore calling PayInference. - Use your PSP's idempotency mechanism for the actual charge.
- Record
decision_idwith the charge, so the outcome report and any later investigation reference the decision that drove it.