Monetization

Dunning email sequence: template and code

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

Passing, tested 2026-10-19 · Email, SMS, Inbox

How it works

When to send dunning emails

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.

  1. Payment fails
    Your webhook starts the journey
  2. Right away
    Email
  3. Day 3
    Email
    Plus SMS for anyone with a phone number
  4. Day 7
    Final notice by email and Inbox

Stops early: A successful payment cancels every message still to come.

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

Messages

What each step sends

Step 1: Right after the payment fails

Email.

Email

From: Acme
Your payment didn't go through

Hi Dana,

We couldn't charge your card $49.00 for your Pro plan. Update your payment details to keep your account running.

Already fixed it? You can ignore this email.

Update payment details

Other subject lines: "We couldn't process your payment"

Step 2: 3 days after the first email

Email. Plus SMS for anyone with a phone number.

Email

From: Acme
Action needed: update your payment details

Hi Dana,

Your payment of $49.00 still hasn't gone through. Updating your card takes about a minute.

Update payment details

SMS

Acme: your payment of $49.00 didn't go through. Update your card: https://app.acme.com/billing Reply STOP to opt out.

Other subject lines: "Your Pro payment is still failing"

Step 3: 4 days after step 2, 7 days after the first email

Final notice by email and Inbox.

Email

From: Acme
Final notice: update your payment details

Hi Dana,

We still haven't been able to collect $49.00. Update your payment details by October 25 to keep your Pro account active.

Update payment details

In-app Inbox

Your payment still hasn't gone through

Update your payment details by October 25 to keep your account active.

Update payment details

Other subject lines: "Last reminder: your payment of $49.00"

Build

Add it to your app

1. Create the journey

Run setup.ts once with your API key. It creates the templates and the journey, publishes it, and prints the journey ID you pass to step 2. Copy setup.ts, journey.json, and the templates folder from the full definition at the end of this section.

Terminal

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

2. Start it when a payment fails

Node.js

// 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

3. Stop it when the invoice is paid

Node.js

// 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}")
Full definition: journey.json, setup.ts, and the 5 templates

Two waits, five sends, and one condition: the SMS only goes to users with a phone number. Every run is tagged with the invoice ID, so a paid invoice stops it.

Stripe can repeat a failed-payment event, so the start code passes the invoice ID as an idempotency key and you get one sequence per invoice.

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"
}
]
}

setup.ts

// 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 }));

templates/payment-failed-email.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

{
"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

{
"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

{
"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

{
"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"
}
]
}
]
}
}
}

Agents

Hand it to your agent

Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.

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

Data fields

FieldTypeExampleNote
invoice_idstringin_1Q2w3EBuilds the cancellation token and the idempotency key
profile.first_namestringDanaFrom the profile. "there" is the fallback when it's missing
plan_namestringPro
amountinteger4900In cents, as Stripe sends it. The templates show it as dollars
access_change_atstring2026-10-25T17:00:00ZAn ISO timestamp. The date your own policy changes the account, which you pass in. It shows in the recipient's timezone

FAQ

Frequently asked questions

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.

Build this in your workspace

Run setup.ts against a free account and the journey is live in a minute. Prefer no code? Rebuild it in the Journeys editor.