Activation
Stalled trial Slack alert: template and code
Hand stuck, high-value trials to a person instead of sending another email.
Passing, tested 2026-10-19 · Slack, Email
How it works
When to send a stalled trial alert
A stalled trial Slack alert tells your sales team about big free trials that haven't activated. Five days into a trial, if the account has 10 or more seats, it posts in your sales channel with the account and its owner. Smaller trials get a friendly email instead, and activating cancels the check.
- Trial startsYour app starts the journey
- Day 5Slack alert or email nudgeSlack for 10 or more seats, email for smaller trials
Stops early: Activating cancels the run, so nobody in sales chases an account that's already active.
The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.
Messages
What each step sends
5 days after the trial starts
Slack alert or email nudge. Slack for 10 or more seats, email for smaller trials.
Slack
Stalled trial: Globex has 12 seats and hasn't activated yet.
Owner: Dana Lee (dana@globex.com)
So far: 2 projects created, 0 teammates invited
Hi Dana,
You're a few days into your Acme trial. If something's in the way, reply to this email and we'll help you get it sorted.
Other subject lines: "How's your Acme trial going?", "Anything in your way, Dana?"
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.
Then run setup-slack.ts with your Slack bot token and your sales channel's ID. It stores both once on the "sales-alerts" tenant that the Slack node in journey.json names.
Terminal
COURIER_API_KEY=pk_... node --experimental-strip-types setup.tsCOURIER_API_KEY=pk_... SLACK_BOT_TOKEN=xoxb-... SLACK_ALERT_CHANNEL=C012AB3CD node --experimental-strip-types setup-slack.ts
2. Start it when a trial starts
Node.js
// Start the check when a trial starts, with the journey ID setup.ts printed.// The journey runs as the account owner, so the email nudge goes to them, and the// templates read the owner's name and email from their Courier profile. The// Slack node names the tenant from setup-slack.ts, so there's nothing about// Slack to pass here, and the fetch node asks your endpoint for the account's name and seats.import Courier from "@trycourier/courier";const client = new Courier();export async function startStalledTrialCheck(journeyId: string,ownerId: string,accountId: string) {const { runId } = await client.journeys.invoke(journeyId, {user_id: ownerId,data: { account_id: accountId },});return runId;}// What your activation endpoint returns. The journey POSTs// {"account_id": "..."} to the URL on the fetch node and branches on seats.export function activationStatus(account: {name: string;seats: number;projects: number;invites: number;}) {return {account_name: account.name,seats: account.seats,activity_summary: `${account.projects} projects created, ${account.invites} teammates invited`,};}
Python
# Start the check when a trial starts, with the journey ID setup.ts printed.# The journey runs as the account owner, so the email nudge goes to them, and the# templates read the owner's name and email from their Courier profile. The# Slack node names the tenant from setup-slack.ts, so there's nothing about# Slack to pass here, and the fetch node asks your endpoint for the account's name and seats.from courier import Courierclient = Courier()def start_stalled_trial_check(journey_id: str, owner_id: str, account_id: str) -> str:response = client.journeys.invoke(template_id=journey_id,user_id=owner_id,data={"account_id": account_id},)return response.run_id# What your activation endpoint returns. The journey POSTs# {"account_id": "..."} to the URL on the fetch node and branches on seats.def activation_status(account: dict) -> dict:return {"account_name": account["name"],"seats": account["seats"],"activity_summary": (f"{account['projects']} projects created, "f"{account['invites']} teammates invited"),}
3. Stop it when the account activates
Node.js
// Stop the check when the account activates or upgrades, so nobody in sales// chases an account that's already doing fine.import Courier from "@trycourier/courier";const client = new Courier();export async function stopStalledTrialCheck(accountId: string) {await client.journeys.cancel({cancelation_token: `trial-check-${accountId}`,});}
Python
# Stop the check when the account activates or upgrades, so nobody in sales# chases an account that's already doing fine.from courier import Courierclient = Courier()def stop_stalled_trial_check(account_id: str) -> None:client.journeys.cancel(cancelation_token=f"trial-check-{account_id}")
Full definition: journey.json, setup.ts, setup-slack.ts, and the 2 templates
After a 5-day wait, a fetch node asks your endpoint for the account's name, seats, and activity summary. Accounts with 10 or more seats get a Slack post in your sales channel. Smaller ones get an email nudge.
The Slack send holds no token. It names a tenant, "sales-alerts", that stores your bot token and channel, so rotating the token never touches the journey.
journey.json
{"name": "Stalled trial alert","cancelation_token": "trial-check-{{data.account_id}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "delay","mode": "duration","duration": "P5D"},{"type": "fetch","method": "post","url": "https://app.acme.com/api/trials/activation","merge_strategy": "overwrite","headers": {"Content-Type": "application/json"},"body": "{\"account_id\": \"{{data.account_id}}\"}"},{"type": "branch","paths": [{"label": "10 or more seats","conditions": ["data.seats", "greater than or equal", "10"],"nodes": [{"type": "send","channel": "slack","message": {"template": "@stalled-trial-slack","context": {"tenant_id": "sales-alerts"}}}]}],"default": {"label": "Smaller trials","nodes": [{"type": "send","channel": "email","message": {"template": "@trial-nudge-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 }));
setup-slack.ts
// Stores your Slack bot token and alert channel once, on a Courier tenant that// stands for your company's Slack workspace. The Slack node in journey.json// names this tenant ("sales-alerts") and has no token or channel of its own: it// uses the ones on the tenant. Run it once, after setup.ts, and again to rotate// the token://// COURIER_API_KEY=pk_... SLACK_BOT_TOKEN=xoxb-... SLACK_ALERT_CHANNEL=C012AB3CD \// node --experimental-strip-types setup-slack.ts//// SLACK_ALERT_CHANNEL is the channel ID for your sales channel. To use another// tenant ID, change it here and in the Slack node's context in journey.json.import Courier from "@trycourier/courier";const client = new Courier();const requireEnv = (name: string) => {const value = process.env[name];if (!value) throw new Error(`Missing environment variable ${name}`);return value;};const tenantId = "sales-alerts";await client.tenants.update(tenantId, {name: "Sales alerts (Slack)",user_profile: {slack: {access_token: requireEnv("SLACK_BOT_TOKEN"),channel: requireEnv("SLACK_ALERT_CHANNEL"),},},});console.log(JSON.stringify({ tenantId }));
templates/stalled-trial-slack.json
{"channel": "slack","notification": {"name": "Stalled trial alert (Slack)","tags": ["recipe", "stalled-trial-slack-alert"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "slack","elements": [{"type": "text","content": "Stalled trial: {{account_name}} has {{seats}} seats and hasn't activated yet."},{"type": "text","content": "Owner: {{profile.first_name}} {{profile.last_name}} ({{profile.email}})"},{"type": "text","content": "So far: {{activity_summary}}"},{"type": "action","content": "Open the account","href": "https://admin.acme.com/accounts/{{account_id}}"}]}]}}}
templates/trial-nudge-email.json
{"channel": "email","notification": {"name": "Trial nudge (email)","tags": ["recipe", "stalled-trial-slack-alert"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Need a hand getting started, {{default profile.first_name \"there\"}}?"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "You're a few days into your Acme trial. If something's in the way, reply to this email and we'll help you get it sorted."},{"type": "action","content": "Pick up where you left off","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 |
|---|---|---|---|
| account_id | string | acct_8F2k | Sent to your endpoint, builds the cancellation token, and ends the account link |
| account_name | string | Globex | Returned by your endpoint |
| profile.first_name | string | Dana | From the owner's profile. Write it when the user signs up. The emails say "there" when it's missing |
| profile.last_name | string | Lee | From the owner's profile |
| profile.email | dana@globex.com | From the owner's profile | |
| seats | number | 12 | Returned by your endpoint |
| activity_summary | string | 2 projects created, 0 teammates invited | Returned by your endpoint |
FAQ
Frequently asked questions
How do I get a Slack alert when a trial user hasn't activated?
Wait until the day you'd expect activation, cancel the run when the account activates, and ask your app for the account's details if it's still running. A send node with a Slack address posts to your sales channel. This journey does it on day 5, for trials with 10 or more seats.
What's a PQL Slack alert?
A post in your sales channel when a trial account looks worth a conversation: a product-qualified lead. Here the signal is a large trial that hasn't activated, and the post names the owner and what they've done so far, so sales can reach out with context.
Where does the Slack bot token go?
Run the Slack setup command once. It stores your bot token and channel on a Courier tenant called sales-alerts, which the Slack send node already names, so the journey holds no token and your start call passes nothing about Slack. To rotate the token, run it again. Set SLACK_ALERT_CHANNEL to the channel ID, like C012AB3CD (copy it from the channel's link in Slack), and make sure the bot can post there. To post into a customer's Slack instead, create one tenant per customer, each holding that customer's bot token and channel, and pass that tenant's ID as context.tenant_id when you start the run.
Can I change the seat threshold?
Yes. Change the 10 in the branch condition in the Journeys editor. The condition compares the values as numbers, so an account with 9 seats stays under it.
Recipes
Related recipes
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.