Smart Retry Timing With Card Issuer Decline Codes

Retrying a failed payment on a fixed schedule treats every failure the same, but the issuer already told you what went wrong โ€” and timing the retry to the decline code is the difference between recovering revenue and getting your merchant account flagged for excessive retries. An insufficient_funds decline often clears after payday; a do_not_honor rarely clears at all and retrying it just annoys the issuer. This guide sits within Grace Period & Retry Logic, and it is where you decide whether to let Stripe Smart Retries own timing or build a custom scheduler that reads decline and network advice codes directly.

You reach this problem once you measure recovery rate per decline code and discover the variance is enormous โ€” some codes recover above 60% with the right timing, others near zero no matter what you do. Spending retries on the recoverable codes and suppressing the rest is the whole game.

Trade-offs

Approach Recovery lift Control Build effort Issuer-risk
Stripe Smart Retries (ML-timed) High, hands-off None over timing None Low (Stripe-tuned)
Fixed backoff (day 1/3/7/14) Baseline Low Hours Medium (ignores codes)
Decline-code-routed schedule High Full Days Low if codes respected
Code + network-advice-aware Highest Full 1โ€“2 weeks Lowest

Stripe Smart Retries is the right default when you do not have the volume to tune timing yourself โ€” it uses network-wide signals you cannot see. Build custom when you need cross-gateway consistency, want retries coordinated with your own Dunning Email Sequences for Churn Reduction, or run gateways without smart-retry features. The non-negotiable in every custom build: honor network advice codes that say โ€œdo not retry.โ€

Step-by-Step Implementation

1. Capture the decline and network advice code on every attempt

Both signals come back on the failed charge. Persist them on the attempt row โ€” you cannot route what you did not record.

def record_failure(attempt_id: str, gateway_error: dict) -> None:
    db.execute(
        """UPDATE payment_attempts
           SET status='failed', decline_code=%s, network_advice=%s
           WHERE payment_attempt_id=%s""",
        (
            gateway_error.get("decline_code"),          # e.g. insufficient_funds
            gateway_error.get("network_advice_code"),   # e.g. 02 do-not-try-again
            attempt_id,
        ),
    )

2. Classify the decline

Three buckets drive entirely different behavior: soft (retry), hard (suspend), and update-required (ask the customer).

SOFT   = {"insufficient_funds", "try_again_later", "processing_error", "issuer_unavailable"}
HARD   = {"do_not_honor", "stolen_card", "lost_card", "fraudulent", "pickup_card",
          "revocation_of_authorization"}
UPDATE = {"expired_card", "incorrect_cvc", "incorrect_number", "invalid_account"}

def classify(decline_code: str) -> str:
    if decline_code in HARD:   return "hard"     # โœ— never retry
    if decline_code in UPDATE: return "update"   # โš ๏ธ needs customer action
    if decline_code in SOFT:   return "soft"     # โœ… schedule retry
    return "soft"  # unknown codes default to a conservative single retry

Note that do_not_honor is a hard decline in practice even though it sounds generic โ€” issuers return it for a wide range of refusals and it rarely clears on retry, unlike insufficient_funds which frequently does.

3. Schedule retries with code-specific timing

insufficient_funds is timed toward likely payday; processing_error retries fast; update-required codes do not retry at all until the card changes.

from datetime import datetime, timedelta, timezone
import random

# Days after failure, tuned per code from observed recovery curves.
SCHEDULE = {
    "insufficient_funds":  [2, 5, 9, 14],   # spread toward pay cycles
    "try_again_later":     [1, 3, 7],
    "processing_error":    [0, 1, 3],        # transient โ€” retry quickly
    "issuer_unavailable":  [0, 1, 2],
}

def schedule_for(decline_code: str, attempt: int) -> datetime | None:
    days = SCHEDULE.get(decline_code, [3])   # conservative default
    if attempt >= len(days):
        return None                          # exhausted
    jitter_h = random.uniform(-4, 4)         # per-issuer cohort spread
    return datetime.now(timezone.utc) + timedelta(days=days[attempt], hours=jitter_h)

4. Honor network advice codes

Network advice codes (Visa/Mastercard) override your schedule: code 02/21 means stop retrying; 03 means a hard error. Respecting them keeps your retry ratio clean with the networks.

NO_RETRY_ADVICE = {"02", "03", "21"}  # do-not-try-again / revoke / hard error

def may_retry(classification: str, network_advice: str | None) -> bool:
    if network_advice in NO_RETRY_ADVICE:
        return False  # โœ— network explicitly forbids โ€” overrides everything
    return classification == "soft"

5. Track recovery per code and tune

Recovery rate per decline code is the feedback loop that tunes the schedule. Drop codes that never recover.

SELECT decline_code,
       COUNT(*)                                                        AS failures,
       SUM(CASE WHEN recovered THEN 1 ELSE 0 END)::float
         / NULLIF(COUNT(*), 0)                                         AS recovery_rate
FROM payment_attempts
WHERE attempted_at > NOW() - INTERVAL '90 days' AND status = 'failed'
GROUP BY decline_code
ORDER BY recovery_rate DESC;

Verification & Testing

Assert that a do_not_honor decline schedules zero retries and transitions the subscription toward suspension, while insufficient_funds produces the full payday-weighted schedule. Inject a network advice code of 02 on a soft decline and assert may_retry returns false โ€” the advice code must override the soft classification. Use a mock clock to verify retry timestamps land on the configured days plus jitter inside the expected ยฑ4h band. Replay the failure-record path twice for the same attempt and assert the decline code is written once, not duplicated. Run the recovery-rate query against seeded data and assert codes are ranked so you can confirm the tuning loop sees the right signal.

Gotchas & Production Pitfalls

  • Treating do_not_honor as soft. It is the most common decline and sounds retryable, but it rarely clears and retrying it inflates your decline ratio with the networks. Classify it hard unless data for your portfolio proves otherwise.
  • Ignoring network advice codes. The networks penalize merchants who retry transactions flagged โ€œdo not try again.โ€ Reading only the gateway decline code and skipping the network advice code is the single most common compliance miss in custom retry logic.
  • Retrying update-required codes blindly. expired_card and incorrect_number will fail identically on every retry until the customer changes the card. Route these straight to a card-update prompt and pair with the account updater service instead of burning retries.
  • Uniform jitter that still clusters. Global jitter spreads a cohort, but if you do not jitter per issuer/BIN you can still spike a single issuer. Bucket jitter by issuer so no one issuer sees a synchronized wave.
  • No feedback loop. A static schedule decays as portfolios and issuer behavior shift. Without the per-code recovery query feeding back into the timing tables, the schedule slowly stops matching reality. Coordinate timing changes with your Dunning Email Sequences for Churn Reduction so emails and retries stay aligned.