Onboarding

Onboarding incomplete reminder email: template and code

Point each stuck user at their next unfinished setup step, not a generic nudge.

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

How it works

When to send onboarding reminder emails

An onboarding incomplete reminder gets users who stalled during setup to finish, by naming the exact step they're on. It emails them on day 1 and day 3 after signup, with an Inbox note on day 1, reading their next step from their profile. Finishing setup stops the reminders.

  1. Signup
    Your app starts the journey
  2. Day 1
    Email and Inbox
    Names the step on the profile when it sends
  3. Day 3
    Email
    Names the step they're on by then

Stops early: Finishing setup cancels the run, so every reminder still to come is dropped. Cancel it the same way when the account is deleted.

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

Messages

What each step sends

Step 1: 1 day after signup

Email and Inbox. Names the step on the profile when it sends.

Email

From: Acme
Your next step in Acme

Hi Dana,

You're partway through setting up Acme. Next up: connect your first data source.

Pick up where you left off

In-app Inbox

Next step: connect your first data source

Finish setting up Acme from here.

Continue setup

Other subject lines: "Next up in Acme: connect your first data source", "Dana, here's your next setup step"

Step 2: 3 days after signup

Email. Names the step they're on by then.

Email

From: Acme
Still want to connect your first data source?

Hi Dana,

Your Acme setup is right where you left it. The next step is to connect your first data source.

Stuck on something? Reply to this email.

Continue setup

Other subject lines: "Your Acme setup is waiting for you"

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 someone signs up

Node.js

// Start the reminders when someone signs up, with the journey ID setup.ts printed.
// The templates read the first name and the next step from the user's Courier
// profile, so there's no data to pass.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function startSetupReminders(journeyId: string, userId: string) {
const { runId } = await client.journeys.invoke(journeyId, {
user_id: userId,
});
return runId;
}
// Call this each time the user's next unfinished setup step changes. The
// reminders read the profile when they send, so each one names the step the
// user is on right then. To end the reminders, see stop.ts.
export async function recordNextStep(userId: string, nextStep: string) {
await client.profiles.create(userId, { profile: { next_step: nextStep } });
}

Python

# Start the reminders when someone signs up, with the journey ID setup.ts printed.
# The templates read the first name and the next step from the user's Courier
# profile, so there's no data to pass.
from courier import Courier
client = Courier()
def start_setup_reminders(journey_id: str, user_id: str) -> str:
response = client.journeys.invoke(template_id=journey_id, user_id=user_id)
return response.run_id
# Call this each time the user's next unfinished setup step changes. The
# reminders read the profile when they send, so each one names the step the
# user is on right then. To end the reminders, see stop.py.
def record_next_step(user_id: str, next_step: str) -> None:
client.profiles.create(user_id, profile={"next_step": next_step})

3. Stop it when they finish setup

Node.js

// Call this when the user finishes setup, or when the account closes. It
// cancels every reminder still waiting for the user, and does nothing if the
// sequence already finished.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function stopSetupReminders(userId: string) {
await client.journeys.cancel({ cancelation_token: `setup-${userId}` });
}

Python

# Call this when the user finishes setup, or when the account closes. It
# cancels every reminder still waiting for the user, and does nothing if the
# sequence already finished.
from courier import Courier
client = Courier()
def stop_setup_reminders(user_id: str) -> None:
client.journeys.cancel(cancelation_token=f"setup-{user_id}")
Full definition: journey.json, setup.ts, and the 3 templates

Your app writes next_step to the user's profile as setup progresses. Day 1 sends an email and an Inbox item, and day 3 sends a last email, each naming the step the user is on when it sends.

When the user finishes setup, your app cancels the run. If no step was ever written, the copy says "finish setting up".

journey.json

{
"name": "Onboarding incomplete reminders",
"cancelation_token": "setup-{{recipient}}",
"nodes": [
{
"type": "trigger",
"trigger_type": "api-invoke"
},
{
"type": "delay",
"mode": "duration",
"duration": "P1D"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@setup-step-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@setup-step-inbox"
}
},
{
"type": "delay",
"mode": "duration",
"duration": "P2D"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@setup-last-email"
}
},
{
"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/setup-step-email.json

{
"channel": "email",
"notification": {
"name": "Next setup step (email)",
"tags": ["recipe", "onboarding-incomplete-reminder-email"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Your next step in Acme"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "You're partway through setting up Acme. Next up: {{default profile.next_step \"finish setting up\"}}."
},
{
"type": "action",
"content": "Pick up where you left off",
"href": "https://app.acme.com/setup"
}
]
}
]
}
}
}

templates/setup-step-inbox.json

{
"channel": "inbox",
"notification": {
"name": "Next setup step (Inbox)",
"tags": ["recipe", "onboarding-incomplete-reminder-email"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "Next step: {{default profile.next_step \"finish setting up\"}}"
},
{
"type": "text",
"content": "Finish setting up Acme from here."
},
{
"type": "action",
"content": "Continue setup",
"href": "https://app.acme.com/setup"
}
]
}
]
}
}
}

templates/setup-last-email.json

{
"channel": "email",
"notification": {
"name": "Setup still waiting (email)",
"tags": ["recipe", "onboarding-incomplete-reminder-email"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Still want to {{default profile.next_step \"finish setting up\"}}?"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "Your Acme setup is right where you left it. The next step is to {{default profile.next_step \"finish setting up\"}}."
},
{
"type": "action",
"content": "Continue setup",
"href": "https://app.acme.com/setup"
},
{
"type": "text",
"content": "Stuck on something? Reply to this email."
}
]
}
]
}
}
}

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 onboarding reminders to this app, sent through Courier: on day 1 and day 3 after signup, email anyone who hasn't finished setup, naming their next unfinished step. Follow the steps at https://www.courier.com/recipes/onboarding-incomplete-reminder-email.md and use the Courier skill for SDK details. 1. Create the journey and its templates in my Courier workspace by replacing Acme and the setup link in the templates with our own, then running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_SETUP_REMINDERS_JOURNEY_ID. 2. Find where this codebase creates a new user and call startSetupReminders from start.ts there. Pass only the user ID, and make sure first_name is written to their Courier profile at signup. 3. Find where a user completes each onboarding step and call recordNextStep from start.ts with their next unfinished step from our real onboarding checklist. 4. Find where a user finishes setup or deletes their account and call stopSetupReminders from stop.ts, which cancels the journey with the token setup-<user_id>. 5. Match this codebase's language, file layout, and error handling. Never hardcode the API key. 6. When you're done, start one run for a test user and show me the run's steps.

Data

Data fields

FieldTypeExampleNote
profile.first_namestringDanaFrom the profile. Write it when the user signs up. The emails say "there" when it's missing
profile.next_stepstringconnect your first data sourceYour app writes it as setup progresses. The reminders name it, and say "finish setting up" when it's missing

FAQ

Frequently asked questions

How do I remind users which onboarding step they skipped?

Write the next unfinished step to the user's Courier profile as setup progresses, and name it in the email. The reminders read the profile when they send, so the email names the step they're on now, not the one they were on at signup.

What does my app need to write to the profile?

A next_step phrase like "connect your first data source" whenever the user's next step changes. When they finish setup, cancel the run with the token setup-<user_id> and the remaining reminders are dropped.

What happens if the user's next step is missing?

The email falls back to "finish setting up", so nobody gets a blank step.

Do I need an endpoint for the journey to call?

No. Your app pushes progress to the profile as it happens, and Courier reads it when each reminder sends. Nothing calls back into your app.

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.