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.
- SignupYour app starts the journey
- Day 1Email and InboxNames the step on the profile when it sends
- Day 3EmailNames 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.
Hi Dana,
You're partway through setting up Acme. Next up: connect your first data source.
In-app Inbox
Finish setting up Acme from here.
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.
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.
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 Courierclient = 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 Courierclient = 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.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| profile.first_name | string | Dana | From the profile. Write it when the user signs up. The emails say "there" when it's missing |
| profile.next_step | string | connect your first data source | Your 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.
Recipes
Related recipes
Welcome every new user, then nudge only the ones who haven't finished setup.
Nudge new users who haven't tried your key feature, and compare two versions of the first email.
Hand stuck, high-value trials to a person instead of sending another email.
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.