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:

  1. Past due, full access. Retries are running; billing contacts get a notice. Most failures resolve here.
  2. 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.
  3. 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 0341 can 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.

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.