Failed Payments and Dunning: Declines, Retries, Access States, and a Tested Policy
Which declines to retry, how to limit access while a payment is overdue, what to tell customers, and a tested Go state machine that handles duplicate payment events.
A recurring card payment can fail even when the customer fully intends to keep paying. Cards expire, get replaced after fraud, hit a corporate spending limit, or need the cardholder to approve a charge. Dunning is what your product does next: retrying when it makes sense, telling the right person, limiting access if the problem is not fixed, and restoring everything the moment it is.
Most of that is policy, not code, and the policy should be written down before anyone writes a retry loop. This guide covers the kinds of declines, retries, account updaters, access states, and messages. It ends with a small, tested state machine that applies a versioned policy and handles the duplicate and out-of-order payment events that billing webhooks produce.
Not every decline should be retried
Payment providers return a decline code with each failure. Stripe documents its codes and the recommended next step for each in Stripe decline codes. They fall into two groups:
| Group | Examples | What to do |
|---|---|---|
| Retrying will not help | Card reported lost or stolen, account closed, invalid number | Stop retrying. Ask the customer for a different payment method |
| Retrying may help | Insufficient funds, issuer temporarily unavailable, generic decline | Retry on a schedule; tell the customer if it keeps failing |
| Customer action needed | Authentication required | Send the customer to approve the payment; retrying without them will not succeed |
Retrying the first group is not only useless. Card networks penalize excessive retries of declined transactions, and Stripe's declines documentation mentions features designed to avoid those excessive-retry penalties. Use the provider's classification rather than maintaining your own list.
Let the provider retry, and measure it
If your provider offers automatic retries, start there. Stripe's Smart Retries choose retry times for you within a window you configure. A custom schedule is worth building only if you can compare its recovery rate with the provider's on your own customers, which takes months of data.
Whichever runs the retries, your application needs to know three things for each failed invoice: whether retries are still happening, when the next one is, and when the policy deadline is.
Prevent failures you can see coming
- Automatic card updates. Card networks can send providers new numbers and expiry dates for replaced cards. Stripe describes this in its cards overview: it requires the issuer to participate, and it is widely supported in the United States. Coverage elsewhere varies, so do not assume it fixes every expiring card.
- Expiry reminders. For cards that will expire before the next renewal, show a notice to billing administrators in the product and send one email, with a link to update the card.
Decide what access looks like while payment is overdue
Write the access policy as data, versioned, and chosen per plan or contract. A common three-step policy:
- Past due, full access. Retries are running; billing contacts get a notice. Most failures resolve here.
- Restricted. After a grace period, users can still sign in, read, and export their data, but cannot create new work or invite users. This prompts action without holding data hostage.
- Suspended. After a second period, access stops. Data is kept for the retention period your terms state, then deleted.
Enterprise contracts often replace this with invoicing terms and a manual process; self-serve plans usually use it as written. For restricted mode, some APIs return HTTP 402 Payment Required. RFC 9110 reserves 402 "for future use", so if you use it, document its meaning in your API reference, or use 403 with a specific error code instead.
Messages people act on
Send payment-failure messages to the people who can fix them, usually billing administrators, not every user. Each message should say what failed (invoice number and amount), why if the provider gave a reason customers can act on, what happens next and on which date, and how to fix it.
Link to your application's billing page and create the provider's payment-update session after the user signs in and you have checked that they may manage billing. Avoid emailing long-lived links that update payment methods without sign-in. Stripe's customer portal can handle the update screen itself.
A small state machine that survives duplicate events
Payment events arrive through webhooks, which can be duplicated and reordered, as the webhook guide shows with a lab. The access decision should therefore be a function of the stored account, the event, the time, and the policy, with no other inputs, so that the same inputs always give the same result.
The package below implements the three-step policy. It ignores a second failure for the same invoice (so retries do not restart the grace clock), ignores a success for an unrelated invoice, and returns no actions when an event is repeated. A periodic Tick moves accounts forward as deadlines pass.
// Package dunning decides a subscription's access state after payment
// events. It is a pure function of the stored account, one event, the time,
// and a versioned policy, so it can be tested exhaustively and replayed.
package dunning
import "time"
type State string
const (
Active State = "active"
PastDue State = "past_due" // full access, payment being retried
Restricted State = "restricted" // read and export only
Suspended State = "suspended" // no access; data retained per policy
)
type Kind string
const (
PaymentFailed Kind = "payment_failed"
PaymentSucceeded Kind = "payment_succeeded"
Tick Kind = "tick" // periodic re-evaluation
)
type Event struct {
Kind Kind
InvoiceID string
HardDecline bool // provider says retrying will not help, e.g. a lost or stolen card
}
// Policy is versioned data, chosen per plan or contract.
type Policy struct {
Version string
GraceFull time.Duration // past_due with full access
GraceRestricted time.Duration // then restricted before suspension
}
type Account struct {
State State
FailedInvoice string
FailedAt time.Time
PolicyVersion string
}
type Action string
const (
NotifyBillingContact Action = "notify_billing_contact"
AskForNewCard Action = "ask_for_new_payment_method"
RestrictWrites Action = "restrict_writes"
SuspendAccess Action = "suspend_access"
RestoreAccess Action = "restore_access"
)
// Next returns the new account and the actions to perform. Applying the same
// event twice yields the same account and no new actions the second time.
func Next(a Account, e Event, now time.Time, p Policy) (Account, []Action) {
switch e.Kind {
case PaymentSucceeded:
if a.State == Active || (a.FailedInvoice != "" && e.InvoiceID != a.FailedInvoice) {
return a, nil // unrelated or duplicate success
}
return Account{State: Active, PolicyVersion: p.Version}, []Action{RestoreAccess}
case PaymentFailed:
if a.State != Active && a.FailedInvoice == e.InvoiceID {
return a, nil // duplicate failure for the invoice already being handled
}
if a.State != Active {
return a, nil // keep the earlier deadline; do not restart the clock
}
next := Account{State: PastDue, FailedInvoice: e.InvoiceID, FailedAt: now, PolicyVersion: p.Version}
acts := []Action{NotifyBillingContact}
if e.HardDecline {
acts = append(acts, AskForNewCard)
}
return next, acts
case Tick:
elapsed := now.Sub(a.FailedAt)
switch {
case a.State == PastDue && elapsed >= p.GraceFull+p.GraceRestricted:
a.State = Suspended
return a, []Action{SuspendAccess}
case a.State == PastDue && elapsed >= p.GraceFull:
a.State = Restricted
return a, []Action{RestrictWrites, NotifyBillingContact}
case a.State == Restricted && elapsed >= p.GraceFull+p.GraceRestricted:
a.State = Suspended
return a, []Action{SuspendAccess}
}
}
return a, nil
}
The tests walk one invoice through failure, duplicate webhooks, a failed retry, restriction, an unrelated success, recovery, and a duplicate success. A second test covers a hard decline and recovery after suspension. Both pass with Go 1.26 (go test reported ok on November 3, 2026).
package dunning
import (
"slices"
"testing"
"time"
)
var policy = Policy{Version: "smb-2026-11", GraceFull: 7 * 24 * time.Hour, GraceRestricted: 7 * 24 * time.Hour}
func at(days int) time.Time {
return time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(days) * 24 * time.Hour)
}
func TestLifecycle(t *testing.T) {
steps := []struct {
day int
ev Event
want State
acts []Action
}{
{0, Event{Kind: PaymentFailed, InvoiceID: "in_1"}, PastDue, []Action{NotifyBillingContact}},
{0, Event{Kind: PaymentFailed, InvoiceID: "in_1"}, PastDue, nil}, // duplicate webhook
{3, Event{Kind: PaymentFailed, InvoiceID: "in_1"}, PastDue, nil}, // retry failed: no new clock
{6, Event{Kind: Tick}, PastDue, nil},
{7, Event{Kind: Tick}, Restricted, []Action{RestrictWrites, NotifyBillingContact}},
{8, Event{Kind: Tick}, Restricted, nil},
{9, Event{Kind: PaymentSucceeded, InvoiceID: "in_other"}, Restricted, nil}, // unrelated invoice
{10, Event{Kind: PaymentSucceeded, InvoiceID: "in_1"}, Active, []Action{RestoreAccess}},
{10, Event{Kind: PaymentSucceeded, InvoiceID: "in_1"}, Active, nil}, // duplicate success
}
a := Account{State: Active}
for i, s := range steps {
var acts []Action
a, acts = Next(a, s.ev, at(s.day), policy)
if a.State != s.want || !slices.Equal(acts, s.acts) {
t.Fatalf("step %d (%s day %d): got %s %v, want %s %v", i, s.ev.Kind, s.day, a.State, acts, s.want, s.acts)
}
}
}
func TestSuspensionAndHardDecline(t *testing.T) {
a, acts := Next(Account{State: Active}, Event{Kind: PaymentFailed, InvoiceID: "in_2", HardDecline: true}, at(0), policy)
if !slices.Contains(acts, AskForNewCard) {
t.Fatal("hard decline should ask for a new payment method")
}
a, _ = Next(a, Event{Kind: Tick}, at(14), policy) // past both grace periods at once
if a.State != Suspended {
t.Fatalf("got %s, want suspended", a.State)
}
a, acts = Next(a, Event{Kind: PaymentSucceeded, InvoiceID: "in_2"}, at(20), policy)
if a.State != Active || !slices.Equal(acts, []Action{RestoreAccess}) {
t.Fatalf("payment after suspension: got %s %v", a.State, acts)
}
}
Store the policy version on the account when the failure starts, so a policy change does not alter the deadline for a failure already in progress, and log each transition with the event that caused it.
Test the whole flow before a real card fails
Dunning paths run rarely and over weeks, so they are where bugs survive longest. Stripe provides two tools that make them testable in minutes, both described in its testing documentation:
- A card that attaches but never pays. The test card
4000 0000 0000 0341can be attached to a customer successfully, but every charge to it fails (Testing). Put it on a test subscription to start the failed-payment path without waiting for a real decline. - Test clocks. A test clock lets you advance simulated time for the customers attached to it, so renewals, retries, and invoice finalization happen when you move the clock rather than on the real calendar. Stripe's guide notes you can advance up to two billing intervals at a time, for example two months for a monthly subscription (Test your Billing integration).
A useful test script: create a customer on a test clock with the failing card, subscribe them, and advance the clock past each retry and each policy deadline. After every step, check your own database for the account state, the actions your code took (notices sent, access restricted), and the webhook events you stored. Then swap in a working card and confirm access is restored in one step. Run the same script after any change to the policy or the state machine.
Restoring access
When payment succeeds, restore access at once, before any background job runs, and tell the same people you warned. If writes were restricted, make sure queued work the customer could not submit is not lost; ask them to retry, or keep it in a pending state. Then check entitlements: a plan change during the past-due period may need to take effect now. The entitlements guide covers propagating that change without stale caches.
Where to start
Write your current behavior down as a policy: grace periods, what restricted mode blocks, who gets which message, and how long data is kept after suspension. If it does not exist as a document, it does not exist consistently in code either. Then confirm that hard declines stop retries and ask for a new card, and replay a week of payment webhooks, shuffled and duplicated, through your state logic to check that every account ends in the same state.
Frequently Asked Questions
How long should the grace period be?
Long enough for a billing contact who is away for a week to notice and fix the problem. One to two weeks of full access followed by a week or two of restricted access is common for self-serve plans; contracts may set their own terms.
Should we email every user when payment fails?
No. Notify billing administrators and owners, who can fix it. Telling every user can embarrass the customer and does not help. Show an in-product notice to administrators as well.
What should happen to data after suspension?
Keep it for the period your terms state, let the customer export it, and delete it on schedule afterwards. Tell the customer the deletion date in the suspension notice.
Can a customer pay by invoice instead of card to avoid this?
Many enterprise plans do. Invoice payment terms replace automatic retries with reminders and collections, but you still need a policy for overdue invoices and the same state machine to apply it.
Should restricted mode block API calls?
Block calls that create work or cost, and keep read and export endpoints working. Return a clear, documented error so integrations can alert someone rather than retrying blindly.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
Stripe decline codes
(Stripe)
• Link checked 2026-11-03
Decline codes and recommended next steps.
-
Declines
(Stripe)
• Link checked 2026-11-03
Why payments are declined, including excessive-retry penalties.
-
Testing (test card numbers)
(Stripe)
• Link checked 2026-11-03
Test card 4000 0000 0000 0341 attaches to a customer but every charge fails.
-
Test your Billing integration (test clocks)
(Stripe)
• Link checked 2026-11-03
Advancing simulated time for subscriptions, up to two intervals at a time.
-
Automate payment retries (Smart Retries)
(Stripe)
• Link checked 2026-11-03
Provider-managed retry timing for failed subscription payments.
-
Cards overview (automatic card updates)
(Stripe)
• Link checked 2026-11-03
Card updates require issuer participation; widely supported in the United States.
-
Provide a customer portal to your customers
(Stripe)
• Link checked 2026-11-03
Hosted payment method updates.
-
RFC 9110: HTTP Semantics (402 Payment Required)
(Internet Engineering Task Force)
• Link checked 2026-11-03
402 is reserved for future use.
Revision history
- — Added a tested Go state machine for access states. Removed the claim that decline codes are ISO 8583 codes, an inconsistent expiry-banner rule, the instruction to enable card updaters as a Stripe setting, and sources that pointed to unrelated home pages. Noted that RFC 9110 reserves HTTP 402. Added an end-to-end test procedure using a failing test card and Stripe test clocks.
- — Removed unsupported recovery percentages and fixed schedules.