# Onboarding incomplete reminder email: template and code

Source: https://www.courier.com/recipes/onboarding-incomplete-reminder-email (manifest: https://www.courier.com/recipes/onboarding-incomplete-reminder-email/manifest.json)

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

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.

Tested 2026-10-19: passing.

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

## When to send onboarding reminder emails

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 1 day after signup | Email, Inbox | Your next step in Acme |
| 2 | 3 days after signup | Email | Still want to {{default profile.next_step "finish setting up"}}? |

## Copy

### Next setup step (email)

Subject: "Your next step in Acme", "Next up in Acme: {{profile.next_step}}", "{{profile.first_name}}, here's your next setup step"

Hi {{default profile.first_name "there"}},

You're partway through setting up Acme. Next up: {{default profile.next_step "finish setting up"}}.

Button: Pick up where you left off (https://app.acme.com/setup)

### Next setup step (Inbox)

Subject: "Next step: {{default profile.next_step "finish setting up"}}"

Finish setting up Acme from here.

Button: Continue setup (https://app.acme.com/setup)

### Setup still waiting (email)

Subject: "Still want to {{default profile.next_step "finish setting up"}}?", "Your Acme setup is waiting for you"

Hi {{default profile.first_name "there"}},

Your Acme setup is right where you left it. The next step is to {{default profile.next_step "finish setting up"}}.

Stuck on something? Reply to this email.

Button: Continue setup (https://app.acme.com/setup)

## The 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"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// 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 }));
```

## Start it when someone signs up

```typescript
// 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})
```

## Stop it when they finish setup

```typescript
// 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}")
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/setup-step-email.json

```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

```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

```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."
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
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 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

### 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.

## Related docs

- [Build an onboarding sequence](https://www.courier.com/docs/guides/build-an-onboarding-sequence)
- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Welcome email sequence](https://www.courier.com/recipes/welcome-email-sequence): Welcome every new user, then nudge only the ones who haven't finished setup.
- [Feature adoption emails](https://www.courier.com/recipes/feature-adoption-email-sequence): Nudge new users who haven't tried your key feature, and compare two versions of the first email.
- [Stalled trial alert](https://www.courier.com/recipes/stalled-trial-slack-alert): Hand stuck, high-value trials to a person instead of sending another email.
