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.

Grace period retry state machine A failed charge enters a grace period; soft declines schedule retries, hard declines suspend immediately, and exhausted retries lead to cancellation. active charge fails past_due grace window retry soft decline suspended hard / expired recovered or canceled
Soft declines loop through retries within the grace window; hard declines and exhausted retries drive the subscription to suspension and cancellation.

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.