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.
- Payment failsYour webhook starts the journey
- Right awayEmail
- Day 3EmailPlus SMS for anyone with a phone number
- Day 7Final 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.
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.
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.
Hi Dana,
Your payment of $49.00 still hasn't gone through. Updating your card takes about a minute.
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.
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.
In-app Inbox
Update your payment details by October 25 to keep your account active.
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, timezonefrom courier import Courierclient = 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 Courierclient = 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.
Data
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
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.
Recipes
Related recipes
Docs
How each part works
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.