Retry integration
When a payment attempt fails, the question is whether trying again is worth anything, and where. PayInference answers it per attempt, using failure classification, provider health, and your retry policy.
The loop#
- First attempt fails (timeout, soft decline, technical error).
- Report the outcome for the failed attempt to
POST /v1/outcomes. - Request a new decision for the same
order_id, now includingprevious_attempt. - Execute the new instruction:
retry,failover,use_default_route, orblock.
// Attempt 1 failed with a timeout on stripe.
await payinference.recordOutcome({
decision_id: firstDecision.decision_id,
order_id: 'order_456',
provider_used: 'stripe',
outcome: 'timeout',
provider_latency_ms: 5000,
amount: 14900,
currency: 'USD',
});
// Ask again with the failure context.
const second = await payinference.decide({
order_id: 'order_456', // same order_id ties the attempts together
transaction: { amount: 14900, currency: 'USD', country: 'US', payment_method: 'card' },
available_providers: ['stripe', 'adyen', 'paypal'],
previous_attempt: {
provider: 'stripe',
outcome: 'timeout',
provider_reason_code: 'gateway_timeout',
},
});
previous_attempt.outcome accepts timeout, declined, or error.
What comes back#
| Instruction | Meaning for the retry |
|---|---|
retry |
The failure was technical (timeout or error), your policy has a retry_on_timeout rule, and the failed provider still looks healthy. Execute on route.primary_provider (RETRY_ALLOWED_BY_POLICY) |
failover |
The failed provider itself is unhealthy (degraded, critical, or unavailable); execute on the different provider in route.primary_provider (FAILOVER_FROM_UNHEALTHY_PROVIDER). Needs no retry rule |
route |
No retry rule applied (for example the previous outcome was declined with no no_retry match); a fresh routing decision was made |
use_default_route |
No provider survived health and policy filtering, but your policy default applies (NO_HEALTHY_PROVIDER_AVAILABLE, DEFAULT_ROUTE_APPLIED) |
block |
Retrying is suppressed: the provider_reason_code matched a policy no_retry list (RETRY_SUPPRESSED_BY_POLICY), or no eligible provider and no default exists |
Two behaviors worth knowing:
- With
use_different_provideron your retry rule (the default), the provider fromprevious_attemptis excluded from scoring whenever the failure was technical, soretryandfailovertypically route to a different provider than the one that failed. retryandfailoverfire on technical failures only. Adeclinedprevious attempt does not trigger them; declines either produce a normalroutedecision or ablockwhen the decline code is on ano_retrylist. That is exactly why reportingsoft_declinedvshard_declinedoutcomes and passingprovider_reason_codematters.
Policy controls#
Two policy actions govern retries (see Policy Manager):
{
"rule_id": "retry_timeouts_once",
"name": "Retry timeouts on a different provider",
"priority": 10,
"enabled": true,
"when": {},
"then": { "type": "retry_on_timeout", "max_retries": 1, "use_different_provider": true }
}
{
"rule_id": "never_retry_fatal",
"name": "Never retry fatal decline codes",
"priority": 5,
"enabled": true,
"when": {},
"then": {
"type": "no_retry",
"provider_reason_codes": ["stolen_card", "pickup_card", "fraudulent"]
}
}
max_retries caps at 3. no_retry matches on the raw provider_reason_code you pass in previous_attempt and in outcomes.
Why outcome classification matters here#
Retry quality depends on the difference between soft_declined (retry may succeed: issuer unavailable, insufficient funds) and hard_declined (retry never succeeds: stolen card, closed account). Report the canonical outcome values rather than the legacy declined, and always pass the raw provider_reason_code. See Outcome feedback.
Guardrails#
- Bound your loop. Policy caps retries, but keep your own attempt counter per
order_idas defense in depth. - One decision per attempt. Do not reuse a prior decision for a retry; the situation changed, ask again.
- Report every attempt's outcome, including the final one, so the decision log shows the full story of the order.
- Do not retry HTTP errors from PayInference itself. A 4xx is an answer. See Idempotency and retries.