Recipes

Failed payments and dunning

Set the retry schedule for declined cards, see what a failed renewal looks like, recover it when the customer adds a card, and decide what happens at the end.

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:

setup.ts
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" });

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());
Response
{
  "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
  }
}
FieldValuesWhat it does
enabledboolean, default trueTurns dunning on or off. Off, a failed charge leaves the invoice owed and nothing retries.
modefixed (default), smartfixed 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.
retries1 to 8 { "after_days": n }, default 1, 3, 5, 7Counts 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.invoicenot_paid (default), void, write_off, reverseWhat 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.subscriptioncancel (default), retainWhether the subscription ends. Independent of the invoice: you can write the invoice off and keep the customer.
dunning_period_days1 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_failuresboolean, default trueFixed mode: a hard decline ends dunning at once instead of retrying a card the bank refused outright.
collect_on_payment_method_updateboolean, default trueCharges open invoices the moment the customer adds a card, instead of at the next retry.
adaptive_timingboolean, default falseSmart mode: times each retry by the decline reason instead of by retries.
dun_one_time_invoicesboolean, default falseAlso dun invoices that do not belong to a subscription.
dun_offline_invoicesboolean, default trueSend 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,
});
Response
{
  "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.

When you create a subscription and its first charge declines, the request answers 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());
Response
{
  "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",
});
Response
{
  "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());
Response
{
  "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:

CallWhat it does
POST /v1/businesses/{biz}/invoices/{inv}/retryRetries collection now, out of schedule. The same as collect.
POST /v1/businesses/{biz}/invoices/{inv}/pause_dunningHolds the schedule until the until time you send. The invoice stays owed.
POST /v1/businesses/{biz}/invoices/{inv}/resume_dunningRestores the schedule a pause replaced.
POST /v1/businesses/{biz}/invoices/{inv}/stop_dunningStops all further attempts. The invoice stays owed.
POST /v1/businesses/{biz}/invoices/{inv}/write_offGives 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:

EventUse it to
payment_failedTell the customer early, while they can still fix it.
invoice_dunning_startedStart your own grace period or banner.
invoice_dunning_exhaustedApply your downgrade or suspension.
invoice_paidRestore whatever you restricted.
payment_source_expiringAsk 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

ErrorCauseFix
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 keyA 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 failureskip_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 owedcollect_on_payment_method_update is false, or the new card also declined.Turn the setting on, or retry the invoice.
Copyright © 2026