Failed payments and dunning
Cards decline. tmbilling treats a decline as the start of a schedule, not as an error: it retries the invoice on the days you set, charges a new card the moment the customer adds one, and applies your final action when the retries run out. This recipe sets the schedule, makes a payment fail with a test card, and recovers it.
Before you begin
Do the Quickstart first: you need a test API key in
$TMBILLING_API_KEY, your base URL in $TMBILLING_URL, and the client set up:
import { Tmbilling } from "@two-minute-billing/sdk";
const tmb = new Tmbilling({
apiKey: process.env.TMBILLING_API_KEY!,
baseUrl: process.env.TMBILLING_URL,
});
Set up a business and a customer whose card declines
In test mode, the card 4000000000009995 always fails with insufficient funds. That decline can
succeed later, so it runs the whole retry schedule.
const biz = await tmb.businesses.create({ name: "Fernhill Analytics", default_currency: "USD" });
const customer = await tmb.customers.create(biz.id, {
email: "[email protected]",
first_name: "Dana",
last_name: "Whitfield",
currency: "USD",
});
await tmb.paymentMethods.create(biz.id, customer.id, { token: "4000000000009995" });
curl -X POST $TMBILLING_URL/v1/businesses \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Fernhill Analytics", "default_currency": "USD" }'
export BIZ=biz_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"first_name": "Dana",
"last_name": "Whitfield",
"currency": "USD"
}'
export CUS=cus_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/payment_methods \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "token": "4000000000009995" }'
Set the retry schedule
Dunning is a business setting, dunning_config. Send only the keys you want to change. The rest
keep their defaults. The client does not wrap this setting yet, so the TypeScript example sends
the request with fetch.
Subscription invoices are dunned by default. This recipe makes a one-off invoice fail, so it also
turns on dun_one_time_invoices.
const res = await fetch(`${process.env.TMBILLING_URL}/v1/businesses/${biz.id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.TMBILLING_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
dunning_config: {
mode: "smart",
retries: [{ after_days: 1 }, { after_days: 3 }, { after_days: 7 }],
final_action: { invoice: "not_paid", subscription: "cancel" },
dun_one_time_invoices: true,
},
}),
});
if (!res.ok) throw new Error(await res.text());
curl -X PATCH $TMBILLING_URL/v1/businesses/$BIZ \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"dunning_config": {
"mode": "smart",
"retries": [{ "after_days": 1 }, { "after_days": 3 }, { "after_days": 7 }],
"final_action": { "invoice": "not_paid", "subscription": "cancel" },
"dun_one_time_invoices": true
}
}'
{
"object": "business",
"id": "biz_XXXXXXXXXXXXXXXXXXXXXX01",
"dunning_config": {
"mode": "smart",
"retries": [
{
"after_days": 1
},
{
"after_days": 3
},
{
"after_days": 7
}
],
"final_action": {
"invoice": "not_paid",
"subscription": "cancel"
},
"dun_one_time_invoices": true
}
}
| Field | Values | What it does |
|---|---|---|
enabled | boolean, default true | Turns dunning on or off. Off, a failed charge leaves the invoice owed and nothing retries. |
mode | fixed (default), smart | fixed retries on your offsets. smart reads the gateway's decline code: temporary refusals retry, refusals only the customer can clear hold the invoice open without retrying, and refusals that can never succeed stop at once. |
retries | 1 to 8 { "after_days": n }, default 1, 3, 5, 7 | Counts each offset from the previous failure, not from the due date. The example retries 1 day after the first failure, 3 days after the second and 7 days after the third. |
final_action.invoice | not_paid (default), void, write_off, reverse | What the unpaid invoice becomes when retries run out. reverse settles it with an adjustment credit note, so the uncollected period is not counted as revenue. |
final_action.subscription | cancel (default), retain | Whether the subscription ends. Independent of the invoice: you can write the invoice off and keep the customer. |
dunning_period_days | 1 to 365, or null (default) | Days after the due date at which the final action applies, whatever retries are left. null lets the schedule alone decide. |
skip_retries_for_hard_failures | boolean, default true | Fixed mode: a hard decline ends dunning at once instead of retrying a card the bank refused outright. |
collect_on_payment_method_update | boolean, default true | Charges open invoices the moment the customer adds a card, instead of at the next retry. |
adaptive_timing | boolean, default false | Smart mode: times each retry by the decline reason instead of by retries. |
dun_one_time_invoices | boolean, default false | Also dun invoices that do not belong to a subscription. |
dun_offline_invoices | boolean, default true | Send reminders on the same schedule for invoices with auto-collection off. No charge is attempted for these. |
Make a payment fail
Raise an invoice and collect it now. The charge declines, and the invoice enters dunning.
const invoice = await tmb.invoices.create(biz.id, {
customer: customer.id,
lines: [{ description: "Onboarding package", amount_minor: 15000 }],
collect: true,
});
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/invoices \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"customer": "'$CUS'",
"lines": [{ "description": "Onboarding package", "amount_minor": 15000 }],
"collect": true
}'
export INV=inv_…
{
"object": "invoice",
"id": "inv_XXXXXXXXXXXXXXXXXXXXXX01",
"number": "INV-1",
"status": "payment_due",
"currency": "USD",
"total_minor": 15000,
"amount_due_minor": 15000,
"attempt_count": 1,
"next_attempt_at": "2026-10-21T19:08:08.546Z",
"dunning_status": "in_progress",
"due_date": "2026-10-20T19:08:08.546Z"
}
The invoice is payment_due with dunning_status: "in_progress". next_attempt_at is when the
next retry runs, and attempt_count counts the attempts so far. Each attempt, failed or not, is a
transaction that carries the gateway's own decline code.
402 and
nothing is created. Dunning starts with the renewals. Dunning belongs to the invoice: a subscription
whose renewal is in dunning stays active, and there is no past_due status. You decide whether a
customer who is one day late keeps their access.Pause the schedule
When the customer tells you they are sorting out their bank, hold the retries until a date. The
invoice stays owed. resume_dunning restores the schedule the pause replaced.
const pause = await fetch(
`${process.env.TMBILLING_URL}/v1/businesses/${biz.id}/invoices/${invoice.id}/pause_dunning`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TMBILLING_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ until: "2027-01-15T00:00:00Z" }),
},
);
if (!pause.ok) throw new Error(await pause.text());
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/invoices/$INV/pause_dunning \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "until": "2027-01-15T00:00:00Z" }'
{
"object": "invoice",
"id": "inv_XXXXXXXXXXXXXXXXXXXXXX01",
"status": "payment_due",
"amount_due_minor": 15000,
"attempt_count": 1,
"next_attempt_at": "2027-01-15T00:00:00.000Z",
"dunning_status": "in_progress"
}
Recover it with a new card
When the customer adds a working card, for example through the
customer portal, tmbilling charges their open
invoices straight away, paused or not. Here the test card 4242424242424242 stands in for the
customer's new card.
const card = await tmb.paymentMethods.create(biz.id, customer.id, {
token: "4242424242424242",
});
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/payment_methods \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "token": "4242424242424242" }'
{
"object": "payment_method",
"id": "pm_XXXXXXXXXXXXXXXXXXXXXX01",
"brand": "visa",
"last4": "4242",
"status": "valid",
"role": "backup",
"is_default": false,
"collected_invoices": [
{
"invoice": "inv_XXXXXXXXXXXXXXXXXXXXXX01",
"status": "succeeded"
}
]
}
collected_invoices lists what the new card paid. The invoice is paid and its dunning ends. The
customer already had a default card, so the new one is saved as a backup. Make it the default
with POST …/payment_methods/{pm}/default if it should pay future invoices first.
Work the retry queue
Every invoice under dunning is in one list: the queue for whoever chases payments.
const queue = await fetch(`${process.env.TMBILLING_URL}/v1/businesses/${biz.id}/retries`, {
headers: { Authorization: `Bearer ${process.env.TMBILLING_API_KEY}` },
});
if (!queue.ok) throw new Error(await queue.text());
curl "$TMBILLING_URL/v1/businesses/$BIZ/retries" \
-H "Authorization: Bearer $TMBILLING_API_KEY"
{
"object": "list",
"data": [
{
"object": "retry",
"invoice": "inv_XXXXXXXXXXXXXXXXXXXXXX01",
"invoice_number": "INV-1",
"status": "paid",
"dunning_status": "succeeded",
"amount_due_minor": 15000,
"attempt_count": 2,
"next_attempt_at": null,
"customer": "cus_XXXXXXXXXXXXXXXXXXXXXX01",
"last_error_code": "insufficient_funds",
"last_error_text": "Insufficient funds",
"last_failure_retriable": true,
"payment_method_last4": "9995"
}
],
"has_more": false
}
The paid invoice now shows dunning_status: "succeeded", with the last decline kept for reference.
Filter the list with state=attention, recovered, given_up or all.
To act on one invoice:
| Call | What it does |
|---|---|
POST /v1/businesses/{biz}/invoices/{inv}/retry | Retries collection now, out of schedule. The same as collect. |
POST /v1/businesses/{biz}/invoices/{inv}/pause_dunning | Holds the schedule until the until time you send. The invoice stays owed. |
POST /v1/businesses/{biz}/invoices/{inv}/resume_dunning | Restores the schedule a pause replaced. |
POST /v1/businesses/{biz}/invoices/{inv}/stop_dunning | Stops all further attempts. The invoice stays owed. |
POST /v1/businesses/{biz}/invoices/{inv}/write_off | Gives up on the money through an adjustment credit note, so the ledger still explains it. |
What tmbilling does on every failure
It tries the backup cards
If the default card declines, tmbilling charges the customer's backup payment methods in the
same attempt, newest first, at most three cards in all. A customer with a backup card often never reaches
dunning.
It respects card-network rules
A card whose last decline forbids a retry (for example lost or stolen) is not charged again until its number or expiry changes. Automatic retries stop at 10 declines in 24 hours or 15 in 30 days per card. When the network says how long to wait, the next attempt waits at least that long.
It applies your final action
When the retries run out, the invoice becomes what final_action.invoice says, and the
subscription is cancelled or kept as final_action.subscription says.
Check it worked
Subscribe your webhook endpoint to these events:
| Event | Use it to |
|---|---|
payment_failed | Tell the customer early, while they can still fix it. |
invoice_dunning_started | Start your own grace period or banner. |
invoice_dunning_exhausted | Apply your downgrade or suspension. |
invoice_paid | Restore whatever you restricted. |
payment_source_expiring | Ask for a new card before the old one fails. |
See the event reference for the full list.
To watch a schedule run to the end in seconds, open the business's test mode in the dashboard and use Time Travel: see Test with Time Travel.
Common errors
| Error | Cause | Fix |
|---|---|---|
402 We did not create the subscription. The first payment failed: … | The first charge of a new subscription declined, so nothing was created. | Ask the buyer for another card and send the request again, or start the subscription with a trial. |
400 naming a dunning_config key | A key that the setting does not have, or a value out of range (after_days is 1 to 90, at most 8 retries). | Send only the keys in the table above. |
| Retries stop after the first failure | skip_retries_for_hard_failures is on and the decline was hard, or smart mode found the decline cannot succeed. | Expected. Ask the customer for a new card. |
| The customer added a card but the invoice is still owed | collect_on_payment_method_update is false, or the new card also declined. | Turn the setting on, or retry the invoice. |
Related
Checkout and portal
Create short-lived, single-use sessions so buyers can subscribe and customers can manage their own billing, without your API key ever reaching a browser.
India GST invoices
Charge GST from an Indian business: CGST and SGST inside your state, IGST across states, with your GSTIN and the HSN or SAC code on every invoice you issue.