Skip to content
API DocsDocs

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):

Payment Failed Recovery

#Decision Tree: Transaction Stuck in PENDING

Transaction Stuck in PENDING

#Decision Tree: 3DS Challenge Flow

3DS Challenge Flow

Critical: The successRedirectUrl is 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 successRedirectUrl is for user experience only — never use it as payment confirmation.

SignalAction
Webhook = SUCCEEDFulfill order
Webhook = FAILEDDo not fulfill — notify customer
No webhook receivedPoll GET /payments/card/status/{id}
Webhook and poll disagreeContact support with transactionId

#Retry Strategy

#When to Retry

Decline Code RangeRetry?Max AttemptsBackoff
1, 9, 12, 13 (transient)Yes3Exponential: 2s, 4s, 8s
61, 63, 70, 72, 73 (gateway)Yes3Exponential: 5s, 15s, 30s
3, 4, 5, 6, 10, 19-23 (customer)NoPrompt customer to retry manually
8, 14, 17, 24-31, 65 (merchant)NoFix payload first
7, 28, 29, 62, 64, 66-69 (config)NoContact 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

MistakeConsequenceFix
Trusting redirect URLs as payment confirmationOrders fulfilled for failed paymentsAlways wait for webhook
Not deduplicating webhooksOrders processed twice, double fulfillmentDeduplicate on transactionId + transactionStatus
Using a new requestId on retryCustomer charged multiple timesReuse the same requestId
Retrying customer-action errors (3DS, funds)Wastes API calls, same resultPrompt customer instead
Processing webhook before responding 200Timeouts cause duplicate deliveriesRespond 200 first, process async
Not polling after extended PENDINGStuck orders, angry customersPoll after 5 min, escalate after 2 hrs
Was this helpful?