Error Recovery Patterns
Decision trees for handling failed, stuck, and ambiguous transactions
4 min readUpdated Mar 26, 2026
Payment transactions can fail, get stuck, or return ambiguous results. This guide provides decision trees for every scenario so you know exactly what to do.
#Decision Tree: Payment Failed
When a payment returns FAILED status (synchronously or via webhook):
#Decision Tree: Transaction Stuck in PENDING
#Decision Tree: 3DS Challenge Flow
Critical: The
successRedirectUrlis for user experience only. Always treat the webhook callback as the authoritative payment result. A customer can be redirected to the success URL even if the payment ultimately fails (rare edge case with async processing).
#Webhook vs Redirect Disagree
Always trust the webhook. The
successRedirectUrlis for user experience only — never use it as payment confirmation.
| Signal | Action |
|---|---|
| Webhook = SUCCEED | Fulfill order |
| Webhook = FAILED | Do not fulfill — notify customer |
| No webhook received | Poll GET /payments/card/status/{id} |
| Webhook and poll disagree | Contact support with transactionId |
#Retry Strategy
#When to Retry
| Decline Code Range | Retry? | Max Attempts | Backoff |
|---|---|---|---|
| 1, 9, 12, 13 (transient) | Yes | 3 | Exponential: 2s, 4s, 8s |
| 61, 63, 70, 72, 73 (gateway) | Yes | 3 | Exponential: 5s, 15s, 30s |
| 3, 4, 5, 6, 10, 19-23 (customer) | No | — | Prompt customer to retry manually |
| 8, 14, 17, 24-31, 65 (merchant) | No | — | Fix payload first |
| 7, 28, 29, 62, 64, 66-69 (config) | No | — | Contact support |
#Retry with Idempotency
Always use the same requestId when retrying a failed payment. This ensures:
- You won't be charged twice if the first attempt actually succeeded
- Exirom returns the existing transaction result instead of creating a new one
async function payWithRetry(paymentData, token, maxRetries = 3) {
// Use a stable requestId for all retries
const requestId = paymentData.requestId;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const res = await fetch('https://sandbox.api.exirom.com/api/api/v1/payments/card', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(paymentData),
});
const result = await res.json();
// Terminal states — stop retrying
if (['SUCCEED', 'CUSTOMER_VERIFICATION'].includes(result.transactionStatus)) {
return result;
}
if (result.transactionStatus === 'FAILED') {
const retryable = [1, 9, 12, 13, 61, 63, 70, 72, 73];
if (!retryable.includes(result.declineCode) || attempt === maxRetries) {
return result; // Non-retryable or exhausted retries
}
}
// Exponential backoff
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}#Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Trusting redirect URLs as payment confirmation | Orders fulfilled for failed payments | Always wait for webhook |
| Not deduplicating webhooks | Orders processed twice, double fulfillment | Deduplicate on transactionId + transactionStatus |
Using a new requestId on retry | Customer charged multiple times | Reuse the same requestId |
| Retrying customer-action errors (3DS, funds) | Wastes API calls, same result | Prompt customer instead |
| Processing webhook before responding 200 | Timeouts cause duplicate deliveries | Respond 200 first, process async |
| Not polling after extended PENDING | Stuck orders, angry customers | Poll after 5 min, escalate after 2 hrs |
#Related Guides
- Webhook Best Practices — Deduplication, signatures, polling
- Decline Codes Reference — Full code list
- Idempotency — How
requestIdprevents duplicate charges - Callback Retry Mechanism — Retry schedule and behavior
Was this helpful?