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.
| 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.
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.
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.
- Treating
do_not_honoras 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_cardandincorrect_numberwill 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.