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

The choice is how much retry timing you own. Stripe Smart Retries is the hands-off default with network-wide signals; a fixed backoff ignores the codes; a decline-code-routed schedule gives full control; and code-plus-advice-aware is the highest recovery. The map ranks them.

Retry timing approaches Stripe Smart Retries is hands-off, fixed backoff ignores codes, a decline-code schedule gives control, and code-plus-network-advice is the highest recovery. Smart Retries hands-off no control low volume Fixed backoff baseline ignores codes medium risk Code-routed full control high recovery days to build Code + advice highest recovery lowest risk the target
Code-plus-network-advice is the highest recovery and lowest issuer risk — but Smart Retries wins when you lack the volume to tune.
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.”

The volume threshold where custom timing starts to beat Smart Retries is roughly where you accumulate a few hundred failed attempts per decline code per month. Below that, your per-code recovery curves are noise — a insufficient_funds recovery rate computed from 40 failures has a confidence interval wide enough to swallow any timing gain you think you measured. Stripe’s model pools signal across its entire book, so it sees the payday distribution for a given subscription_id’s issuer even when you have only handled that issuer twice. The moment your own data is dense enough to estimate a recovery curve per code with a tight interval, the calculus flips: you can now exploit portfolio-specific structure Stripe averages away, such as a B2B book that bills on net-30 cycles where retries should land near month-end rather than near consumer paydays.

The economics that actually decide the approach

Retry timing is not free, and the cost is not the per-attempt gateway fee. Each retry consumes a slice of your decline ratio — the fraction of your submitted authorizations that get refused. Visa and Mastercard both track this per merchant descriptor, and once you cross their thresholds (Visa’s excessive-decline monitoring starts caring around a 15% decline ratio with meaningful volume) you face fines and, worse, having your BIN-level authorization rates quietly throttled. A fixed backoff that retries every invoice_id four times regardless of code can double your decline ratio overnight because roughly a third of your failures are hard declines that will never approve. So the payoff of code-routing is not only incremental recovery; it is spending your finite retry budget on the attempts that can actually approve and withholding it from the ones that only add refusals to your ratio.

Cross-gateway consistency as the real driver

The strongest reason to own timing is running more than one processor. If you route North American volume through Stripe and EU volume through Adyen, Smart Retries only governs the Stripe leg, and the two processors will disagree about when to retry the same failure class. That inconsistency shows up as a jagged recovery rate that swings with your routing mix rather than with issuer behavior, and it makes the retry side of your dunning impossible to reason about. A single decline-code-routed scheduler sitting above both gateways normalizes each processor’s error taxonomy into the same soft/hard/update buckets, so subscription_id recovery no longer depends on which processor happened to touch the charge.

Step-by-Step Implementation

The five steps capture both codes, classify into three buckets, schedule per code, honor network advice as an override, then feed recovery data back. The classification diagram shows why the three buckets behave completely differently.

Decline classification Soft declines schedule retries, hard declines suspend without retry, and update-required declines route to a card-update prompt. Decline code classify Soft → retry insufficient_funds Hard → suspend do_not_honor Update → prompt expired_card
Three buckets, three behaviors — the classification decides everything downstream, and a network advice code can override it.

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.

The classification sets are deliberately explicit rather than pattern-matched. Resist the temptation to bucket by substring or by the numeric ISO 8583 response code alone, because the same wire-level code maps to different meanings across networks and even across issuers on the same network. Response code 05 is the canonical do_not_honor, but some acquirers fold soft velocity limits into 05 as well, which is exactly why the gateway’s normalized decline_code string is a safer routing key than the raw processor code. Keep the unknown-code branch conservative: default to a single soft retry rather than a full schedule, and log every code that falls through the sets so you can promote it into the right bucket once you have seen enough of it. A code you have never classified quietly recovering at 2% is a code you should be treating as hard, and the only way you find it is by making the fall-through visible in the recovery query.

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)

The day offsets in SCHEDULE deserve their own justification rather than being copied from a blog post. The insufficient_funds sequence of [2, 5, 9, 14] is not evenly spaced on purpose: the first retry waits two days because retrying an underfunded account the same afternoon almost never approves and only spends a refusal, while the later attempts stretch out to overlap the two most common consumer pay cycles — a semimonthly cycle lands around the 15th and the end of month, and a biweekly cycle drifts, so a schedule that spans roughly two weeks catches most accounts once without piling attempts into the first 48 hours. By contrast processing_error uses [0, 1, 3] starting at day zero because a processing error is a transient system fault, not a funding problem; the account is good, the network hiccuped, and waiting helps nothing. The jitter of ±4 hours is small on purpose — it exists to desynchronize a cohort of subscriptions that all failed in the same nightly billing run, not to move an attempt off its intended day.

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.

The reason the advice code outranks your own classification is that it carries information your decline-code bucket does not. A insufficient_funds decline you correctly classified as soft can still arrive with advice code 02 when the issuer has decided this particular card should not be retried at all — for example because the account is being closed or the issuer has already seen too many attempts on it. Your soft classification is a portfolio-level prior; the advice code is issuer-specific truth about this customer_id’s card right now. When they conflict, the issuer wins, and may_retry returning false on that combination is not a lost recovery — it is a refusal you correctly declined to submit. Store the advice code even when it does not forbid a retry, because Mastercard’s Merchant Advice Code and Visa’s equivalent also distinguish “retry after a delay” from “new account information is available,” and the latter is your signal to route to the account updater rather than to schedule a blind retry.

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

The tests prove hard-declines schedule zero retries, network advice overrides a soft classification, schedule timing lands with jitter, and the recovery query ranks codes. The panel lists them.

Retry timing tests A do_not_honor schedules zero retries, a network advice code overrides a soft classification, timestamps land with jitter, and the recovery query ranks codes. Hard do_not_honor zero retries Advice override code 02 on soft may_retry false Timing mock clock ±4h band Recovery seeded data codes ranked
The network-advice override test is the compliance one — it proves the advice code beats your own classification.

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.

Beyond the per-function assertions, the two tests worth writing at the boundary are the exhaustion test and the idempotency test. For exhaustion, drive a single subscription_id through the full insufficient_funds schedule and assert that once attempt reaches the length of the day list, schedule_for returns None and the orchestrator moves the subscription to its terminal dunning state rather than looping forever — an off-by-one here is the classic bug that retries a card a fifth time it was never meant to see. For idempotency, fire the same gateway webhook for one payment_attempt_id twice, which happens routinely because processors redeliver, and assert you do not double-schedule the next retry; the guard belongs on the idempotency_key derived from the attempt and the gateway event id, not on wall-clock deduplication. It is also worth adding a property test that feeds random decline codes into classify and asserts the result is always one of the three buckets, so a future edit that adds a set cannot silently produce an unhandled classification that the scheduler then treats as soft by accident.

Gotchas & Production Pitfalls

The pitfalls are misclassification (do_not_honor as soft, blind update-code retries), ignoring network advice, weak jitter, and no feedback loop. The map groups them so each fix is one rule.

Retry timing pitfalls Treating do_not_honor as soft, ignoring network advice, retrying update-required codes, non-per-issuer jitter, and no feedback loop are the recurring pitfalls. do_not_honor treated soft → classify hard Advice ignored network penalty → read advice Update codes retried blindly → prompt customer Jitter still clusters → per-issuer No feedback schedule decays → recovery query
Five pitfalls — ignoring network advice is the compliance miss; misclassifying do_not_honor is the recovery miss.
  • 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.

Frequently Asked Questions

Which decline codes are worth retrying? Soft declines caused by funds or temporary issuer conditions. Hard declines — closed accounts, stolen cards, do-not-honour with a permanent flag — will not succeed and should route to a payment-method request instead.

Does retrying at a different time of day help? Yes, measurably, for funds-related declines. Aligning retries with common salary dates in the customer’s market recovers more than a fixed interval, and the effect is large enough to be visible in a modest sample.

Is there a limit on how many times a card can be retried? Card schemes discourage excessive retries and some networks monitor them. Four attempts across a few weeks is well within normal practice; dozens of attempts is not.

Should the decline code be shown to the customer? The category, not the raw code. “Your bank declined the payment — please try another card or contact them” is actionable; a numeric code is not.