Onboarding
Welcome email sequence: template and code
Welcome every new user, then nudge only the ones who haven't finished setup.
Passing, tested 2026-10-19 · Email, Inbox
How it works
When to send welcome emails
A welcome email sequence greets new users and helps them finish setup. It sends a welcome email at signup, a setup nudge the next day by email and Inbox to anyone who hasn't finished, and a tips email on day 3. Finishing setup skips the nudge, and you can cancel the rest anytime.
- SignupYour app starts the journey
- Right awayEmail
- Day 1Setup nudge by emailBoth skipped once setup is done. The Inbox copy also needs the welcome email to be unopened
- Day 3Tips email
Stops early: Finishing setup skips the nudge and the Inbox copy, and your app can cancel the rest at any time.
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 signup
Email.
Hi Dana,
Thanks for signing up. The fastest way to get going is to connect your first data source. It takes about five minutes.
Reply to this email if anything gets in your way.
Other subject lines: "Welcome aboard, Dana", "You're in. Here's where to start"
Step 2: 1 day after signup
Setup nudge by email. Both skipped once setup is done. The Inbox copy also needs the welcome email to be unopened.
Hi Dana,
You're one step away: connect your first data source. Pick up where you left off.
In-app Inbox
Start here: connect your first data source.
Other subject lines: "Finish setting up Acme"
Step 3: 3 days after signup
Tips email.
Hi Dana,
Here's what people set up first in Acme:
1. Invite your team so everyone sees the same updates.
2. Turn on Slack alerts for the events you care about.
3. Set up a weekly summary email.
Other subject lines: "Your first week with Acme"
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 sequence when someone signs up, with the journey ID setup.ts printed.// The templates read the first name 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 startWelcomeSequence(journeyId: string, userId: string) {const { runId } = await client.journeys.invoke(journeyId, {user_id: userId,});return runId;}
Python
# Start the sequence when someone signs up, with the journey ID setup.ts printed.# The templates read the first name from the user's Courier profile, so# there's no data to pass.from courier import Courierclient = Courier()def start_welcome_sequence(journey_id: str, user_id: str) -> str:response = client.journeys.invoke(template_id=journey_id, user_id=user_id)return response.run_id
3. Stop it early
Node.js
// Record finished setup on the profile, so the day 1 nudge and Inbox copy are skipped.// To stop the whole sequence instead, cancel the run.import Courier from "@trycourier/courier";const client = new Courier();export async function markSetupComplete(userId: string) {await client.profiles.create(userId, {profile: { setup_completed_at: new Date().toISOString() },});}export async function stopWelcomeSequence(userId: string) {await client.journeys.cancel({ cancelation_token: `welcome-${userId}` });}
Python
# Record finished setup on the profile, so the day 1 nudge and Inbox copy are skipped.# To stop the whole sequence instead, cancel the run.from datetime import datetime, timezonefrom courier import Courierclient = Courier()def mark_setup_complete(user_id: str) -> None:client.profiles.create(user_id,profile={"setup_completed_at": datetime.now(timezone.utc).isoformat()},)def stop_welcome_sequence(user_id: str) -> None:client.journeys.cancel(cancelation_token=f"welcome-{user_id}")
Full definition: journey.json, setup.ts, and the 4 templates
The welcome goes out right away. A day later, anyone who hasn't finished setup gets the nudge, plus an Inbox copy of the welcome if they never opened it. Two days after that, everyone gets the tips email.
Swap Acme, the setup link, and the tips for your own before you run setup.
journey.json
{"name": "Welcome email sequence","cancelation_token": "welcome-{{recipient}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"id": "welcome-email","type": "send","channel": "email","message": {"template": "@welcome-email"}},{"type": "delay","mode": "duration","duration": "P1D"},{"type": "send","channel": "email","message": {"template": "@setup-nudge-email"},"conditions": ["profile.setup_completed_at", "does not exist"]},{"type": "send","channel": "inbox","message": {"template": "@welcome-inbox"},"conditions": {"AND": [["profile.setup_completed_at", "does not exist"],["send_status.welcome-email", "was not", "OPENED"]]}},{"type": "delay","mode": "duration","duration": "P2D"},{"type": "send","channel": "email","message": {"template": "@tips-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/welcome-email.json
{"channel": "email","notification": {"name": "Welcome (email)","tags": ["recipe", "welcome-email-sequence"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Welcome to Acme, {{default profile.first_name \"there\"}}"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "Thanks for signing up. The fastest way to get going is to connect your first data source. It takes about five minutes."},{"type": "action","content": "Get started","href": "https://app.acme.com/setup"},{"type": "text","content": "Reply to this email if anything gets in your way."}]}]}}}
templates/setup-nudge-email.json
{"channel": "email","notification": {"name": "Setup nudge (email)","tags": ["recipe", "welcome-email-sequence"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your Acme setup is almost done"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "You're one step away: connect your first data source. Pick up where you left off."},{"type": "action","content": "Finish setup","href": "https://app.acme.com/setup"}]}]}}}
templates/welcome-inbox.json
{"channel": "inbox","notification": {"name": "Welcome (Inbox)","tags": ["recipe", "welcome-email-sequence"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "inbox","elements": [{"type": "meta","title": "Welcome to Acme"},{"type": "text","content": "Start here: connect your first data source."},{"type": "action","content": "Get started","href": "https://app.acme.com/setup"}]}]}}}
templates/tips-email.json
{"channel": "email","notification": {"name": "First-week tips (email)","tags": ["recipe", "welcome-email-sequence"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "3 things to set up in your first week"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "Here's what people set up first in Acme:"},{"type": "text","content": "1. Invite your team so everyone sees the same updates."},{"type": "text","content": "2. Turn on Slack alerts for the events you care about."},{"type": "text","content": "3. Set up a weekly summary email."},{"type": "action","content": "Open Acme","href": "https://app.acme.com/setup"}]}]}}}
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.setup_completed_at | ISO 8601 datetime | 2026-10-21T18:30:00Z | From the profile. Your app sets it when setup is done |
FAQ
Frequently asked questions
How many emails should a welcome email sequence have?
Three in the first few days covers it: the welcome, one nudge for people who stall, and a tips email. Anything more that early tends to read as noise.
How do I skip the nudge for people who already finished setup?
When someone finishes setup, set setup_completed_at on their Courier profile. The nudge and the Inbox copy both check that field and skip anyone who has it.
Why does the welcome also go to the Inbox?
If someone hasn't finished setup and didn't open the welcome email by day 1, the same message shows up in your app's Inbox the next time they're in your product.
How do I stop a welcome sequence early?
Cancel the run with the token welcome-<user_id>. That stops anything still waiting, which is useful when an account is deleted.
Recipes
Related recipes
Point each stuck user at their next unfinished setup step, not a generic nudge.
Nudge new users who haven't tried your key feature, and compare two versions of the first email.
Get new signups to confirm their email, and stop reminding them the moment they do.
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.