When the card on file stops working
A failed renewal is not a single event. It is a decision tree with a clock attached, and most SaaS teams get the timing wrong in one direction or the other. Either they suspend too aggressively and churn paying customers over transient gateway errors, or they let failed accounts linger for weeks, absorbing infrastructure cost and creating a support backlog when the user finally notices.
We have shipped billing systems for a few SaaS products now, and the approach we keep arriving at is a three-day retry window, a seven-day grace period, and a hard suspension on day eleven. The numbers are not magic. They come from watching what actually happens when cards fail.
The retry schedule
Razorpay and Stripe both support automatic retries, but we do not use their defaults. The default retry logic tends to be aggressive on day one — three attempts within 24 hours — which is fine for a transient network error but useless for an expired card. The customer is not going to get a new card issued in four hours.
We run retries on day 1, day 3, and day 5 after the original failure. Each retry is a single attempt, not a burst. Between retries, we send one email. Not three. One.
RETRY_DAYS = [1, 3, 5]
async def process_retry(subscription_id: str, attempt: int):
result = await billing_gateway.charge(subscription_id)
if result.succeeded:
await mark_subscription_active(subscription_id)
await send_payment_recovered_email(subscription_id)
return
if attempt < len(RETRY_DAYS) - 1:
await schedule_retry(subscription_id, RETRY_DAYS[attempt + 1])
else:
await enter_grace_period(subscription_id)The first email goes out immediately on failure. It says the card was declined and links to the billing page. The second email goes out on the day 3 retry, and the third on the day 5 retry. Each email is slightly more direct in tone. By the third one, we are saying the account will be suspended if payment is not completed.
The grace period
After the last retry fails on day 5, the subscription enters a grace period. The account stays fully active. Data is accessible, the API responds, background jobs run. We do not throttle anything.
This is where we have seen other systems get sneaky. Read-only mode, feature gating, API rate limiting during grace — these are all ways to push the customer toward paying without technically suspending them. We do not do this. It generates support tickets, it breaks automations the customer has built on top of the API, and it makes the relationship adversarial at exactly the wrong moment.
The grace period exists because a meaningful percentage of failed payments are not negligence. The finance person is on leave. The card expired and the replacement is in a desk drawer. The company is switching banks and the new card has not arrived. Seven days is enough time for these scenarios to resolve without penalty.
When to actually suspend
On day 11 — five days of retries, seven days of grace — we suspend the account. Suspension means three things: API requests return 402, the dashboard shows a payment-required screen with a link to update billing, and background jobs stop running.
What suspension does not mean: we do not delete data. We do not offboard the user from the system. We do not remove their seat from the tenant. The data stays exactly where it was.
async def suspend_account(subscription_id: str):
sub = await get_subscription(subscription_id)
sub.status = SubscriptionStatus.SUSPENDED
sub.suspended_at = utc_now()
await db.commit()
await halt_background_jobs(sub.tenant_id)
await cache.set(f"tenant:{sub.tenant_id}:status", "suspended")We hold suspended data for 90 days. After that, we send a final notice and then run a deletion job that removes tenant records, file uploads, and cached outputs. The 90-day window is a business decision, not a technical one. It costs us storage, but it has saved two accounts in the last year that came back after a billing dispute was resolved internally on the customer side.
What we do not do
We do not attempt dunning calls. Phone calls for a SaaS product at this price point cost more in labor than they recover in revenue. We do not offer payment plans for a missed renewal — that is a different problem from a failed transaction. We do not prorate the grace period against the next billing cycle if the customer recovers. The subscription just resumes and the next renewal date stays where it was.
The one thing we would do differently for a higher-priced product — say, above Rs 50,000 per month — is add a manual outreach step before the final retry. At that price point, a phone call from the account manager is worth the cost. Below that, email is sufficient, and the system runs itself.