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.

  1. Trial starts
    Your app starts the journey
  2. Day 5
    Slack alert or email nudge
    Slack 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

Acme APP

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

Open the account

Email

From: Acme
Need a hand getting started, Dana?

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.

Pick up where you left off

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.ts
COURIER_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 Courier
client = 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 Courier
client = 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.

Add a stalled trial alert to this app, sent through Courier: 5 days into a trial, post in our sales Slack channel when an account with 10 or more seats is still stalled, and email smaller stalled trials instead. Follow the steps at https://www.courier.com/recipes/stalled-trial-slack-alert.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 and admin links, and the activation endpoint URL (in journey.json) 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_STALLED_TRIAL_JOURNEY_ID. 2. Run setup-slack.ts from the same page with COURIER_API_KEY, SLACK_BOT_TOKEN, and SLACK_ALERT_CHANNEL (our sales channel's ID) from our environment. It creates the "sales-alerts" tenant that the Slack node already names. 3. Add an HTTPS endpoint that takes a POST with {"account_id": "..."} and returns the fields marked "Returned by your endpoint" in the Data fields table, from our real account data, like activationStatus in start.ts. 4. Find where this codebase starts a trial and start the journey there, as the account owner. Pass the fields in the Data fields table, point the fetch node at the endpoint from step 3, and make sure the owner's first_name, last_name, and email are written to their Courier profile at signup. 5. Find where an account activates or upgrades and cancel the journey with the token trial-check-<account_id>. 6. Match this codebase's language, file layout, and error handling. Never hardcode the API key or the Slack token. 7. When you're done, start one run for a test user and show me the run's steps.

Data

Data fields

FieldTypeExampleNote
account_idstringacct_8F2kSent to your endpoint, builds the cancellation token, and ends the account link
account_namestringGlobexReturned by your endpoint
profile.first_namestringDanaFrom the owner's profile. Write it when the user signs up. The emails say "there" when it's missing
profile.last_namestringLeeFrom the owner's profile
profile.emailemaildana@globex.comFrom the owner's profile
seatsnumber12Returned by your endpoint
activity_summarystring2 projects created, 0 teammates invitedReturned 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.

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.