---
title: "Failed payments and dunning"
description: "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."
canonical_url: "https://twominutebilling.com/help/developers/recipes/failed-payments"
---
# 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](https://twominutebilling.com/help/developers/getting-started/quickstart) first: you need a test API key in
`$TMBILLING_API_KEY`, your base URL in `$TMBILLING_URL`, and the client set up:

```ts [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.

<code-group>

```ts [TypeScript]
const biz = await tmb.businesses.create({ name: "Fernhill Analytics", default_currency: "USD" });
const customer = await tmb.customers.create(biz.id, {
  email: "dana.whitfield@example.com",
  first_name: "Dana",
  last_name: "Whitfield",
  currency: "USD",
});
await tmb.paymentMethods.create(biz.id, customer.id, { token: "4000000000009995" });
```

```bash [curl]
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": "dana.whitfield@example.com",
    "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" }'
```

</code-group>

## 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`.

<code-group>

```ts [TypeScript]
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());
```

```bash [curl]
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
    }
  }'
```

</code-group>

```json [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
  }
}
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Values
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        enabled
      </code>
    </td>
    
    <td>
      boolean, default <code>
        true
      </code>
    </td>
    
    <td>
      Turns dunning on or off. Off, a failed charge leaves the invoice owed and nothing retries.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mode
      </code>
    </td>
    
    <td>
      <code>
        fixed
      </code>
      
       (default), <code>
        smart
      </code>
    </td>
    
    <td>
      <code>
        fixed
      </code>
      
       retries on your offsets. <code>
        smart
      </code>
      
       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.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retries
      </code>
    </td>
    
    <td>
      1 to 8 <code>
        { "after_days": n }
      </code>
      
      , default 1, 3, 5, 7
    </td>
    
    <td>
      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.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        final_action.invoice
      </code>
    </td>
    
    <td>
      <code>
        not_paid
      </code>
      
       (default), <code>
        void
      </code>
      
      , <code>
        write_off
      </code>
      
      , <code>
        reverse
      </code>
    </td>
    
    <td>
      What the unpaid invoice becomes when retries run out. <code>
        reverse
      </code>
      
       settles it with an adjustment credit note, so the uncollected period is not counted as revenue.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        final_action.subscription
      </code>
    </td>
    
    <td>
      <code>
        cancel
      </code>
      
       (default), <code>
        retain
      </code>
    </td>
    
    <td>
      Whether the subscription ends. Independent of the invoice: you can write the invoice off and keep the customer.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        dunning_period_days
      </code>
    </td>
    
    <td>
      1 to 365, or <code>
        null
      </code>
      
       (default)
    </td>
    
    <td>
      Days after the due date at which the final action applies, whatever retries are left. <code>
        null
      </code>
      
       lets the schedule alone decide.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        skip_retries_for_hard_failures
      </code>
    </td>
    
    <td>
      boolean, default <code>
        true
      </code>
    </td>
    
    <td>
      Fixed mode: a hard decline ends dunning at once instead of retrying a card the bank refused outright.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        collect_on_payment_method_update
      </code>
    </td>
    
    <td>
      boolean, default <code>
        true
      </code>
    </td>
    
    <td>
      Charges open invoices the moment the customer adds a card, instead of at the next retry.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        adaptive_timing
      </code>
    </td>
    
    <td>
      boolean, default <code>
        false
      </code>
    </td>
    
    <td>
      Smart mode: times each retry by the decline reason instead of by <code>
        retries
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        dun_one_time_invoices
      </code>
    </td>
    
    <td>
      boolean, default <code>
        false
      </code>
    </td>
    
    <td>
      Also dun invoices that do not belong to a subscription.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        dun_offline_invoices
      </code>
    </td>
    
    <td>
      boolean, default <code>
        true
      </code>
    </td>
    
    <td>
      Send reminders on the same schedule for invoices with auto-collection off. No charge is attempted for these.
    </td>
  </tr>
</tbody>
</table>

## Make a payment fail

Raise an invoice and collect it now. The charge declines, and the invoice enters dunning.

<code-group>

```ts [TypeScript]
const invoice = await tmb.invoices.create(biz.id, {
  customer: customer.id,
  lines: [{ description: "Onboarding package", amount_minor: 15000 }],
  collect: true,
});
```

```bash [curl]
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_…
```

</code-group>

```json [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.

<note title="A declined first payment does not create a subscription">

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.

</note>

## 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.

<code-group>

```ts [TypeScript]
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());
```

```bash [curl]
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" }'
```

</code-group>

```json [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](https://twominutebilling.com/help/developers/recipes/checkout-and-portal), tmbilling charges their open
invoices straight away, paused or not. Here the test card `4242424242424242` stands in for the
customer's new card.

<code-group>

```ts [TypeScript]
const card = await tmb.paymentMethods.create(biz.id, customer.id, {
  token: "4242424242424242",
});
```

```bash [curl]
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" }'
```

</code-group>

```json [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.

<code-group>

```ts [TypeScript]
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());
```

```bash [curl]
curl "$TMBILLING_URL/v1/businesses/$BIZ/retries" \
  -H "Authorization: Bearer $TMBILLING_API_KEY"
```

</code-group>

```json [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:

<table>
<thead>
  <tr>
    <th>
      Call
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        POST /v1/businesses/{biz}/invoices/{inv}/retry
      </code>
    </td>
    
    <td>
      Retries collection now, out of schedule. The same as <code>
        collect
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/businesses/{biz}/invoices/{inv}/pause_dunning
      </code>
    </td>
    
    <td>
      Holds the schedule until the <code>
        until
      </code>
      
       time you send. The invoice stays owed.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/businesses/{biz}/invoices/{inv}/resume_dunning
      </code>
    </td>
    
    <td>
      Restores the schedule a pause replaced.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/businesses/{biz}/invoices/{inv}/stop_dunning
      </code>
    </td>
    
    <td>
      Stops all further attempts. The invoice stays owed.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/businesses/{biz}/invoices/{inv}/write_off
      </code>
    </td>
    
    <td>
      Gives up on the money through an adjustment credit note, so the ledger still explains it.
    </td>
  </tr>
</tbody>
</table>

## What tmbilling does on every failure

<steps>

### 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.

</steps>

## Check it worked

Subscribe your webhook endpoint to these events:

<table>
<thead>
  <tr>
    <th>
      Event
    </th>
    
    <th>
      Use it to
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        payment_failed
      </code>
    </td>
    
    <td>
      Tell the customer early, while they can still fix it.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        invoice_dunning_started
      </code>
    </td>
    
    <td>
      Start your own grace period or banner.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        invoice_dunning_exhausted
      </code>
    </td>
    
    <td>
      Apply your downgrade or suspension.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        invoice_paid
      </code>
    </td>
    
    <td>
      Restore whatever you restricted.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        payment_source_expiring
      </code>
    </td>
    
    <td>
      Ask for a new card before the old one fails.
    </td>
  </tr>
</tbody>
</table>

See the [event reference](https://twominutebilling.com/help/developers/webhooks/events) 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](https://twominutebilling.com/help/guides/test-with-time-travel).

## Common errors

<table>
<thead>
  <tr>
    <th>
      Error
    </th>
    
    <th>
      Cause
    </th>
    
    <th>
      Fix
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        402
      </code>
      
       <code>
        We did not create the subscription. The first payment failed: …
      </code>
    </td>
    
    <td>
      The first charge of a new subscription declined, so nothing was created.
    </td>
    
    <td>
      Ask the buyer for another card and send the request again, or start the subscription with a trial.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        400
      </code>
      
       naming a <code>
        dunning_config
      </code>
      
       key
    </td>
    
    <td>
      A key that the setting does not have, or a value out of range (<code>
        after_days
      </code>
      
       is 1 to 90, at most 8 retries).
    </td>
    
    <td>
      Send only the keys in the table above.
    </td>
  </tr>
  
  <tr>
    <td>
      Retries stop after the first failure
    </td>
    
    <td>
      <code>
        skip_retries_for_hard_failures
      </code>
      
       is on and the decline was hard, or smart mode found the decline cannot succeed.
    </td>
    
    <td>
      Expected. Ask the customer for a new card.
    </td>
  </tr>
  
  <tr>
    <td>
      The customer added a card but the invoice is still owed
    </td>
    
    <td>
      <code>
        collect_on_payment_method_update
      </code>
      
       is <code>
        false
      </code>
      
      , or the new card also declined.
    </td>
    
    <td>
      Turn the setting on, or retry the invoice.
    </td>
  </tr>
</tbody>
</table>

## Related

- [Dunning and failed payment recovery](https://twominutebilling.com/product/recovery)
- [Recover failed payments in the dashboard](https://twominutebilling.com/help/guides/recover-failed-payments)


## Sitemap

See the full [sitemap](https://twominutebilling.com/sitemap.md) for all pages.
