Grace Period & Retry Logic
A grace period turns an involuntary payment failure into a recoverable event instead of an instant cancellation, and the retry logic behind it is where most SaaS revenue recovery lives or dies. Built as part of Frontend Checkout UX & Dunning Recovery Flows, this subsystem must balance three forces that pull against each other: recovering revenue, respecting card-network velocity limits, and not annoying a customer whose card simply had a temporary hold. The wrong retry cadence trips issuer fraud heuristics; the wrong grace window either churns recoverable accounts or extends free service to dead cards.
The recovery engine depends on a clean vault. Token lifecycle management and Secure Card Vaulting & Tokenization are prerequisites for off-session retries that do not re-prompt the customer or trigger an avoidable SCA challenge. Everything here assumes charges are merchant-initiated against stored credentials.
Prerequisites
Architecture & Data Flow
A failed renewal moves the subscription into a grace period, the decline code determines whether a retry is even scheduled, and each retry either recovers the account or advances the FSM toward suspension. The state machine, not a cron job, owns the lifecycle; the scheduler only enqueues the next attempt.
Implementation Walkthrough
1. Model the lifecycle as a deterministic FSM
Transitions are explicit and version-guarded; concurrent mutations are rejected via optimistic locking.
enum SubState { ACTIVE='active', PAST_DUE='past_due', SUSPENDED='suspended', CANCELED='canceled' }
const ALLOWED: Record<SubState, SubState[]> = {
[SubState.ACTIVE]: [SubState.PAST_DUE],
[SubState.PAST_DUE]: [SubState.ACTIVE, SubState.SUSPENDED, SubState.CANCELED],
[SubState.SUSPENDED]: [SubState.ACTIVE, SubState.CANCELED],
[SubState.CANCELED]: [],
};
function assertTransition(from: SubState, to: SubState) {
if (!ALLOWED[from].includes(to)) throw new Error(`invalid ${from}->${to}`); // โ reject
}
2. Classify the decline
A hard decline must never be retried; a soft decline enters the schedule. The detailed code taxonomy lives in Smart Retry Timing With Card Issuer Decline Codes.
HARD_DECLINES = {"stolen_card", "lost_card", "fraudulent", "pickup_card", "do_not_honor"}
def is_hard_decline(decline_code: str) -> bool:
return decline_code in HARD_DECLINES # โ no retry path
3. Schedule soft-decline retries with backoff and jitter
Spread retries across the grace window and add per-issuer jitter so a renewal cohort does not hammer one issuer simultaneously.
import random
from datetime import datetime, timedelta, timezone
BACKOFF_DAYS = [0, 3, 7, 14] # within a 14-day grace window
def next_retry_at(attempt: int) -> datetime | None:
if attempt >= len(BACKOFF_DAYS):
return None # โ ๏ธ exhausted โ advance FSM to suspended
jitter_h = random.uniform(-6, 6) # ยฑ6h to break up cohorts
return datetime.now(timezone.utc) + timedelta(days=BACKOFF_DAYS[attempt], hours=jitter_h)
4. Pause and authenticate on SCA
When the issuer demands step-up, off-session retries fail. Pause the queue and send the customer a secure authentication link rather than retrying blindly.
def on_retry_result(result: dict) -> None:
if result.get("requires_action"):
pause_retry_queue(result["subscription_id"]) # โ ๏ธ stop retrying
send_authentication_link(result["customer_id"]) # MIT/COF exemption failed
elif result["status"] == "succeeded":
transition(result["subscription_id"], SubState.ACTIVE) # โ
recovered
5. Commit only after signature verification
Retry-result webhooks must pass HMAC verification and idempotency before the ledger moves.
def handle_retry_webhook(payload: bytes, signature: str, store, ledger) -> None:
expected = hmac.new(WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("invalid signature") # โ reject spoofed payload
event = json.loads(payload)
if store.exists(event["id"]):
return # โ
duplicate
with db.transaction():
ledger.apply(event)
store.mark(event["id"], ttl=604800)
Edge Cases & Failure Modes
| Scenario | Mitigation |
|---|---|
| Gateway timeout leaves attempt in ambiguous state | Reconcile via idempotency key; never blind-retry |
| Card expires mid-grace | Trigger network account updater; request portal update |
| Synchronized retry wave trips issuer velocity | Per-issuer jitter; cap concurrent retries per BIN |
| Portal payment update races a scheduled retry | Cancel pending retry job on successful update |
| Out-of-order retry webhooks | Sequence validation; buffer until predecessor commits |
| SCA step-up on off-session retry | Pause queue; send authentication link; resume on success |
Performance & Scale
The retry queue is the scaling pressure point. At 100k subscriptions, a fixed-interval schedule (everyone retried at exactly day 3) produces a spike that exceeds gateway and issuer limits โ per-issuer jitter flattens it. Index the scheduler table on (next_retry_at, status) so the dispatcher pulls due jobs with a single range scan. Enforce a circuit breaker per gateway: if the failure rate exceeds 15% in a rolling 5-minute window, halt retries and fail open to a secondary processor rather than amplifying an outage. Cap retries per BIN per hour to stay under network velocity thresholds, and defer tax recalculation until a charge settles rather than re-running it on every failed attempt.
Testing Strategy
Drive the FSM with a mock clock to assert that a subscription advances to suspended exactly when the grace window expires, not a second early. Replay the same retry webhook twice and assert one ledger entry. Feed a do_not_honor decline and assert zero retry jobs are enqueued; feed insufficient_funds and assert the backoff schedule materializes with jitter inside expected bounds. Forge an invalid HMAC and assert rejection before any state change. Simulate an SCA requires_action result and assert the queue pauses and an authentication link is sent rather than a blind retry.
Frequently Asked Questions
How should retries handle an SCA challenge during the grace period?
Off-session retries rely on merchant-initiated-transaction and credential-on-file exemptions. When the issuer still demands step-up, pause the retry queue, send the customer a secure authentication link via the portal, and resume scheduling only after successful verification or timeout โ never retry blindly into a requires_action response.
What retry cadence balances recovery against issuer penalties? A tiered backoff with jitter works well: an immediate retry for soft declines, then day 3, day 7, and day 14 within a two-week grace window. Hard declines bypass retries entirely. The decline code should drive timing โ see Smart Retry Timing With Card Issuer Decline Codes.
How do I stop double billing when retry webhooks arrive out of order? Key idempotency on the gateway event id and validate a monotonic sequence per subscription. Buffer out-of-order events until the preceding transition is committed, and wrap every ledger move in a single transaction so duplicates and reorders converge to one outcome.
Should tax be recalculated on every failed retry? No. Recalculate only on successful settlement or when the grace period crosses a fiscal boundary or jurisdiction change. Re-running tax on each failure adds API load and risks rounding drift in the ledger.