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.

  1. Signup
    Your app starts the journey
  2. Right away
    Email
  3. Day 1
    Setup nudge by email
    Both skipped once setup is done. The Inbox copy also needs the welcome email to be unopened
  4. Day 3
    Tips 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.

Email

From: Acme
Welcome to Acme, Dana

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.

Get started

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.

Email

From: Acme
Your Acme setup is almost done

Hi Dana,

You're one step away: connect your first data source. Pick up where you left off.

Finish setup

In-app Inbox

Welcome to Acme

Start here: connect your first data source.

Get started

Other subject lines: "Finish setting up Acme"

Step 3: 3 days after signup

Tips email.

Email

From: Acme
3 things to set up in your first week

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.

Open Acme

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 Courier
client = 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, timezone
from courier import Courier
client = 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.

Add a welcome email sequence to this app, sent through Courier: a welcome email at signup, a setup nudge the next day for people who haven't finished, and tips on day 3. Follow the steps at https://www.courier.com/recipes/welcome-email-sequence.md and use the Courier skill for SDK details. 1. Create the journey and its templates in my Courier workspace by replacing Acme, the setup link, and the tips in the template files 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_WELCOME_JOURNEY_ID. 2. Find where this codebase creates a new user and start the journey there. Pass only the user ID, and write first_name and email to the user's Courier profile at signup. 3. Find where a user finishes setup and set setup_completed_at on their Courier profile. Cancel the journey with the token welcome-<user_id> when an account is deleted. 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.

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.setup_completed_atISO 8601 datetime2026-10-21T18:30:00ZFrom 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.

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.