Onboarding
Team invite email with reminder: template and code
Invite a teammate, remind them if they don't join, and tell the inviter if they still haven't.
Passing, tested 2026-10-19 · Email, Inbox
How it works
When to send invite reminders
A team invite reminder makes sure invited teammates actually join. It sends the invite email when someone adds a teammate, reminds the invitee after 3 days, and leaves an Inbox note for the person who sent the invite after 7 days if it's still pending. Accepting or revoking the invite stops the rest.
- Invite sentYour app starts the journey
- Right awayInvite email to the invitee
- Day 3Reminder email to the invitee
- Day 7Inbox note to the inviter
Stops early: Accepting the invite cancels every message still to come.
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 the invite is sent
Invite email to the invitee.
Hi,
Dana invited you to join Growth on Acme.
Other subject lines: "Join Growth on Acme"
Step 2: 3 days after the invite
Reminder email to the invitee.
Hi,
Dana invited you to Growth on Acme a few days ago. Join to see what the team is working on.
Other subject lines: "Dana is waiting for you on Acme"
Step 3: 7 days after the invite
Inbox note to the inviter.
In-app Inbox
Your invite to Growth is still open. Resend it, or check with them that it arrived.
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 sends an invite
Node.js
// Start the invite when someone invites a teammate, with the journey ID setup.ts printed.// The journey runs as the inviter, so the templates read their first name from// their Courier profile. The emails go to the invitee's address.import Courier from "@trycourier/courier";const client = new Courier();export async function startInvite(journeyId: string,inviterId: string,invite: { id: string; email: string; teamName: string }) {const { runId } = await client.journeys.invoke(journeyId, {user_id: inviterId,data: {invite_id: invite.id,invitee_email: invite.email,team_name: invite.teamName,},});return runId;}
Python
# Start the invite when someone invites a teammate, with the journey ID setup.ts printed.# The journey runs as the inviter, so the templates read their first name from# their Courier profile. The emails go to the invitee's address.from courier import Courierclient = Courier()def start_invite(journey_id: str, inviter_id: str, invite: dict) -> str:response = client.journeys.invoke(template_id=journey_id,user_id=inviter_id,data={"invite_id": invite["id"],"invitee_email": invite["email"],"team_name": invite["team_name"],},)return response.run_id
3. Stop it when the invite is accepted
Node.js
// Stop the reminders when the invite is accepted or revoked.import Courier from "@trycourier/courier";const client = new Courier();export async function stopInvite(inviteId: string) {await client.journeys.cancel({ cancelation_token: `invite-${inviteId}` });}
Python
# Stop the reminders when the invite is accepted or revoked.from courier import Courierclient = Courier()def stop_invite(invite_id: str) -> None:client.journeys.cancel(cancelation_token=f"invite-{invite_id}")
Full definition: journey.json, setup.ts, and the 3 templates
The invite email goes to the invitee's address right away, and a reminder follows on day 3. On day 7, the person who sent the invite gets an Inbox note.
The journey runs as the inviter, so the invitee doesn't need a Courier profile or an account yet.
journey.json
{"name": "Team invite with reminder","cancelation_token": "invite-{{data.invite_id}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "send","channel": "email","message": {"template": "@invite-email","to": {"email_override": "{{data.invitee_email}}"}}},{"type": "delay","mode": "duration","duration": "P3D"},{"type": "send","channel": "email","message": {"template": "@invite-reminder-email","to": {"email_override": "{{data.invitee_email}}"}}},{"type": "delay","mode": "duration","duration": "P4D"},{"type": "send","channel": "inbox","message": {"template": "@inviter-inbox"}},{"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/invite-email.json
{"channel": "email","notification": {"name": "Team invite (email)","tags": ["recipe", "team-invite-reminder-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "{{default profile.first_name \"A teammate\"}} invited you to {{team_name}} on Acme"},{"type": "text","content": "Hi,"},{"type": "text","content": "{{default profile.first_name \"A teammate\"}} invited you to join {{team_name}} on Acme."},{"type": "action","content": "Accept invite","href": "https://app.acme.com/invite/{{invite_id}}"}]}]}}}
templates/invite-reminder-email.json
{"channel": "email","notification": {"name": "Invite reminder (email)","tags": ["recipe", "team-invite-reminder-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your invite to {{team_name}} is waiting"},{"type": "text","content": "Hi,"},{"type": "text","content": "{{default profile.first_name \"A teammate\"}} invited you to {{team_name}} on Acme a few days ago. Join to see what the team is working on."},{"type": "action","content": "Accept invite","href": "https://app.acme.com/invite/{{invite_id}}"}]}]}}}
templates/inviter-inbox.json
{"channel": "inbox","notification": {"name": "Invite not accepted (Inbox, to the inviter)","tags": ["recipe", "team-invite-reminder-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "inbox","elements": [{"type": "meta","title": "{{invitee_email}} hasn't joined yet"},{"type": "text","content": "Your invite to {{team_name}} is still open. Resend it, or check with them that it arrived."},{"type": "action","content": "Resend invite","href": "https://app.acme.com/settings/team"}]}]}}}
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 |
|---|---|---|---|
| invite_id | string | inv_7Kq2 | Builds the cancellation token and the accept link |
| invitee_email | sam@example.com | Where the invite and reminder go | |
| team_name | string | Growth | |
| profile.first_name | string | Dana | From the inviter's profile. "A teammate" is the fallback when it's missing |
FAQ
Frequently asked questions
How many team invite reminders should I send?
One reminder to the invitee after a few days, then hand it back to the person who invited them. A second automated reminder rarely works better than a nudge from someone they know.
Does the invitee need an account before they get the invite?
No. The invite and reminder go to the email address you pass in, so the invitee doesn't need a Courier profile or an account in your app yet.
How do I stop the reminders when someone accepts an invite?
Tag each run with the invite ID, then cancel that token when the invite is accepted or revoked.
Recipes
Related recipes
Welcome every new user, then nudge only the ones who haven't finished setup.
Point each stuck user at their next unfinished setup step, not a generic nudge.
Turn a burst of comments into one notification instead of one per comment.
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.