# Dunning email sequence: template and code

Source: https://www.courier.com/recipes/dunning-email-sequence (manifest: https://www.courier.com/recipes/dunning-email-sequence/manifest.json)

Recover failed payments with three reminders that stop the moment the card goes through.

A dunning email sequence recovers failed payments before they turn into involuntary churn. When a payment fails, it emails the customer right away, follows up on day 3 by email and SMS, and sends a final notice by email and Inbox on day 7. A successful payment stops whatever is left.

Tested 2026-10-19: passing.

The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.

## When to send dunning emails

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | Right after the payment fails | Email | Your payment didn't go through |
| 2 | 3 days after the first email | Email, plus SMS for anyone with a phone number | Action needed: update your payment details |
| 3 | 4 days after step 2, 7 days after the first email | Email, Inbox | Final notice: update your payment details |

## Copy

### Payment failed (email)

Subject: "Your payment didn't go through", "We couldn't process your payment"

Hi {{default profile.first_name "there"}},

We couldn't charge your card {{format "$%.2f" (divide amount 100)}} for your {{plan_name}} plan. Update your payment details to keep your account running.

Already fixed it? You can ignore this email.

Button: Update payment details (https://app.acme.com/billing)

### Payment still failing (email)

Subject: "Action needed: update your payment details", "Your {{plan_name}} payment is still failing"

Hi {{default profile.first_name "there"}},

Your payment of {{format "$%.2f" (divide amount 100)}} still hasn't gone through. Updating your card takes about a minute.

Button: Update payment details (https://app.acme.com/billing)

### Payment still failing (SMS)

Acme: your payment of {{format "$%.2f" (divide amount 100)}} didn't go through. Update your card: https://app.acme.com/billing Reply STOP to opt out.

### Final notice (email)

Subject: "Final notice: update your payment details", "Last reminder: your payment of {{format "$%.2f" (divide amount 100)}}"

Hi {{default profile.first_name "there"}},

We still haven't been able to collect {{format "$%.2f" (divide amount 100)}}. Update your payment details by {{datetime-format access_change_at "MMMM d" (default profile.timezone "America/New_York")}} to keep your {{plan_name}} account active.

Button: Update payment details (https://app.acme.com/billing)

### Final notice (Inbox)

Subject: "Your payment still hasn't gone through"

Update your payment details by {{datetime-format access_change_at "MMMM d" (default profile.timezone "America/New_York")}} to keep your account active.

Button: Update payment details (https://app.acme.com/billing)

## The journey

```json
{
  "name": "Dunning email sequence",
  "cancelation_token": "invoice-{{data.invoice_id}}",
  "nodes": [
    {
      "type": "trigger",
      "trigger_type": "api-invoke"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@payment-failed-email"
      }
    },
    {
      "type": "delay",
      "mode": "duration",
      "duration": "P3D"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@payment-day3-email"
      }
    },
    {
      "type": "send",
      "channel": "sms",
      "message": {
        "template": "@payment-day3-sms"
      },
      "conditions": ["profile.phone_number", "exists"]
    },
    {
      "type": "delay",
      "mode": "duration",
      "duration": "P4D"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@payment-final-email"
      }
    },
    {
      "type": "send",
      "channel": "inbox",
      "message": {
        "template": "@payment-final-inbox"
      }
    },
    {
      "type": "exit"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// Creates this recipe's journey from journey.json and the templates/ folder,
// then publishes it. Run it once per workspace:
//
//   COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
//
// It prints the journey ID and the template IDs it created.
import Courier from "@trycourier/courier";
import { readFileSync, readdirSync } from "node:fs";

const client = new Courier();
const here = new URL(".", import.meta.url);
const readJson = (path: string) =>
  JSON.parse(readFileSync(new URL(path, here), "utf8"));

const spec = readJson("journey.json");

// 1. Create the journey, then the templates its send nodes use.
const journey = await client.journeys.create({
  name: spec.name,
  nodes: [spec.nodes[0], spec.nodes.at(-1)],
});

const templateIds: Record<string, string> = {};
for (const file of readdirSync(new URL("templates/", here))) {
  const key = file.replace(/\.json$/, "");
  const template = await client.journeys.templates.create(
    journey.id,
    readJson(`templates/${file}`)
  );
  await client.journeys.templates.publish(template.id, {
    templateId: journey.id,
  });
  templateIds[key] = template.id;
}

// 2. Save the nodes. In journey.json, "@<template>" stands for the ID of the
// template with that file name. Node IDs must be unique across a workspace,
// so any node ID journey.json names (used by "send_status.<id>" conditions)
// gets this journey's ID added.
const nodeIds = [
  ...JSON.stringify(spec.nodes).matchAll(/"id":"([\w-]+)"/g),
].map((m) => m[1]);
let nodes = JSON.stringify(spec.nodes).replace(/"@([\w-]+)"/g, (_, key) =>
  JSON.stringify(templateIds[key])
);
for (const id of nodeIds)
  nodes = nodes.replace(
    new RegExp(`(?<=[".])${id}(?=")`, "g"),
    `${id}-${journey.id}`
  );
await client.journeys.replace(journey.id, {
  name: spec.name,
  cancelation_token: spec.cancelation_token,
  nodes: JSON.parse(nodes),
});

// 3. Publish, so new runs use this version.
await client.journeys.publish(journey.id);
console.log(JSON.stringify({ journeyId: journey.id, templateIds }));
```

## Start it when a payment fails

```typescript
// Start the sequence when a payment fails, with the journey ID setup.ts printed.
// Call it from your payment webhook (invoice.payment_failed in Stripe), after
// verifying the signature. Stripe sends that event for every failed attempt on
// an invoice, and can deliver the same event more than once, so the invoice ID
// is the idempotency key: Courier starts one sequence per invoice and returns
// the first run's ID for every repeat. The key is held for 30 days, longer than
// the sequence, so a later failed attempt on the same invoice doesn't start
// another. accessChangeAt is the date your own policy changes the account if
// the payment still hasn't gone through, and the templates format it as a date.
// The first email sends right away, and the journey's waits (3 days, then 4)
// time the rest. amount is in cents, as Stripe sends it, and the templates
// format it as dollars. Names come from the user's Courier profile.
import Courier from "@trycourier/courier";

const client = new Courier();
const DAY = 24 * 60 * 60 * 1000;

export async function startPaymentRecovery(
  journeyId: string,
  userId: string,
  invoice: { id: string; amount: number; plan: string; accessChangeAt: Date }
) {
  const now = Date.now();
  const { runId } = await client.journeys.invoke(journeyId, {
    user_id: userId,
    data: {
      invoice_id: invoice.id,
      plan_name: invoice.plan,
      amount: invoice.amount,
      access_change_at: invoice.accessChangeAt.toISOString(),
    },
    "Idempotency-Key": `dunning-${invoice.id}`,
    "x-idempotency-expiration": new Date(now + 30 * DAY).toISOString(),
  });
  return runId;
}
```

```python
# Start the sequence when a payment fails, with the journey ID setup.ts printed.
# Call it from your payment webhook (invoice.payment_failed in Stripe), after
# verifying the signature. Stripe sends that event for every failed attempt on
# an invoice, and can deliver the same event more than once, so the invoice ID
# is the idempotency key: Courier starts one sequence per invoice and returns
# the first run's ID for every repeat. The key is held for 30 days, longer than
# the sequence, so a later failed attempt on the same invoice doesn't start
# another. access_change_at is the date your own policy changes the account if
# the payment still hasn't gone through (a timezone-aware datetime), and the
# templates format it as a date. The first email sends right away, and the
# journey's waits (3 days, then 4) time the rest. amount is an int in cents, as
# Stripe sends it, and the templates format it as dollars. Names come from the
# user's Courier profile.
from datetime import datetime, timedelta, timezone

from courier import Courier

client = Courier()


def start_payment_recovery(journey_id: str, user_id: str, invoice: dict) -> str:
    now = datetime.now(timezone.utc)
    access_change: datetime = invoice["access_change_at"]
    response = client.journeys.invoke(
        template_id=journey_id,
        user_id=user_id,
        data={
            "invoice_id": invoice["id"],
            "plan_name": invoice["plan"],
            "amount": invoice["amount"],
            "access_change_at": access_change.isoformat(),
        },
        idempotency_key=f"dunning-{invoice['id']}",
        x_idempotency_expiration=(now + timedelta(days=30)).isoformat(),
    )
    return response.run_id
```

## Stop it when the invoice is paid

```typescript
// Stop the reminders when the invoice is paid (invoice.paid in Stripe).
import Courier from "@trycourier/courier";

const client = new Courier();

export async function stopPaymentRecovery(invoiceId: string) {
  await client.journeys.cancel({ cancelation_token: `invoice-${invoiceId}` });
}
```

```python
# Stop the reminders when the invoice is paid (invoice.paid in Stripe).
from courier import Courier

client = Courier()


def stop_payment_recovery(invoice_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"invoice-{invoice_id}")
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/payment-failed-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "Payment failed (email)",
    "tags": ["recipe", "dunning-email-sequence"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Your payment didn't go through"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}},"
            },
            {
              "type": "text",
              "content": "We couldn't charge your card {{format \"$%.2f\" (divide amount 100)}} for your {{plan_name}} plan. Update your payment details to keep your account running."
            },
            {
              "type": "action",
              "content": "Update payment details",
              "href": "https://app.acme.com/billing"
            },
            {
              "type": "text",
              "content": "Already fixed it? You can ignore this email."
            }
          ]
        }
      ]
    }
  }
}
```

templates/payment-day3-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "Payment still failing (email)",
    "tags": ["recipe", "dunning-email-sequence"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Action needed: update your payment details"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}},"
            },
            {
              "type": "text",
              "content": "Your payment of {{format \"$%.2f\" (divide amount 100)}} still hasn't gone through. Updating your card takes about a minute."
            },
            {
              "type": "action",
              "content": "Update payment details",
              "href": "https://app.acme.com/billing"
            }
          ]
        }
      ]
    }
  }
}
```

templates/payment-day3-sms.json

```json
{
  "channel": "sms",
  "notification": {
    "name": "Payment still failing (SMS)",
    "tags": ["recipe", "dunning-email-sequence"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "sms",
          "elements": [
            {
              "type": "text",
              "content": "Acme: your payment of {{format \"$%.2f\" (divide amount 100)}} didn't go through. Update your card: https://app.acme.com/billing Reply STOP to opt out."
            }
          ]
        }
      ]
    }
  }
}
```

templates/payment-final-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "Final notice (email)",
    "tags": ["recipe", "dunning-email-sequence"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Final notice: update your payment details"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}},"
            },
            {
              "type": "text",
              "content": "We still haven't been able to collect {{format \"$%.2f\" (divide amount 100)}}. Update your payment details by {{datetime-format access_change_at \"MMMM d\" (default profile.timezone \"America/New_York\")}} to keep your {{plan_name}} account active."
            },
            {
              "type": "action",
              "content": "Update payment details",
              "href": "https://app.acme.com/billing"
            }
          ]
        }
      ]
    }
  }
}
```

templates/payment-final-inbox.json

```json
{
  "channel": "inbox",
  "notification": {
    "name": "Final notice (Inbox)",
    "tags": ["recipe", "dunning-email-sequence"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "inbox",
          "elements": [
            {
              "type": "meta",
              "title": "Your payment still hasn't gone through"
            },
            {
              "type": "text",
              "content": "Update your payment details by {{datetime-format access_change_at \"MMMM d\" (default profile.timezone \"America/New_York\")}} to keep your account active."
            },
            {
              "type": "action",
              "content": "Update payment details",
              "href": "https://app.acme.com/billing"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
Add a dunning sequence for failed payments to this app, sent through Courier: an email right away, an email and SMS on day 3, and a final notice on day 7, stopped as soon as the invoice is paid. Follow the steps at https://www.courier.com/recipes/dunning-email-sequence.md and use the Courier skill for SDK details.

1. Create the journey and its templates in my Courier workspace by running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_DUNNING_JOURNEY_ID. Replace the placeholder product name (Acme) and billing link in the templates with ours.
2. Find our payment provider's webhook handler and start the journey when a payment fails (invoice.payment_failed in Stripe). Start the journey with the user's ID. Pass the fields in the Data fields table from our own data. Use the invoice ID as the idempotency key, as start.ts does, because Stripe sends invoice.payment_failed for every failed attempt and can repeat an event.
3. In the same handler, cancel the journey with the token invoice-<invoice_id> when the invoice is paid (invoice.paid in Stripe).
4. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
5. When you're done, start one run for a test user and show me the run's steps. The first email sends right away and the run then waits 3 days, so cancel the test run once you've checked it.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| invoice_id | string | in_1Q2w3E | Builds the cancellation token and the idempotency key |
| profile.first_name | string | Dana | From the profile. "there" is the fallback when it's missing |
| plan_name | string | Pro |  |
| amount | integer | 4900 | In cents, as Stripe sends it. The templates show it as dollars |
| access_change_at | string | 2026-10-25T17:00:00Z | An ISO timestamp. The date your own policy changes the account, which you pass in. It shows in the recipient's timezone |

## FAQ

### How many dunning emails should I send?

Three over a week covers most failures: one right away, one a few days later, and a final notice before anything changes on the account. To change the gaps, edit the wait durations in the Journeys editor.

### When should dunning emails stop?

The moment the payment succeeds. Tag each run with the invoice ID and cancel that token when the invoice is paid, so nobody gets a reminder for a bill they already fixed.

### How do I connect this to Stripe?

Your webhook route verifies Stripe's signature, then starts the journey on invoice.payment_failed and cancels it on invoice.paid. The Send a dunning email from Stripe guide shows the route.

### What if Stripe sends the failed-payment webhook more than once?

Stripe sends invoice.payment_failed for every failed attempt on an invoice, and can deliver any event twice. Pass the invoice ID as an idempotency key when you start the journey, and Courier returns the first run for every repeat instead of starting another sequence.

## Related docs

- [Send a dunning email from Stripe](https://www.courier.com/docs/guides/notify-from-stripe)
- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Expiring card reminders](https://www.courier.com/recipes/expiring-card-reminder-email): Get cards updated before they expire, so a payment never fails over a date.
- [Renewal reminders](https://www.courier.com/recipes/subscription-renewal-reminder-email): Remind annual customers before they renew, so the charge never comes as a surprise.
- [Payment receipt](https://www.courier.com/recipes/payment-receipt-email): Send a clean receipt after every payment, by email and in your app's Inbox.
