Retention
Customer health score Slack alert: template and code
Tell your customer success team in Slack the day an account's health drops.
Passing, tested 2026-10-19 · Slack, Email
How it works
When to send health score alerts
A customer health score Slack alert tells your customer success team the day an account's health drops below 50. It posts in your Slack channel with the score, what changed, and a link to the account, then emails the account's CSM a day later. If the score recovers first, the email doesn't go out.
- Health score dropsYour health-score job starts the journey
- Score dropsSlack post
- Day 1Email to the CSM
Stops early: A recovered score cancels the CSM email if it hasn't gone out yet.
The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.
Messages
What each step sends
Step 1: When an account's health score drops below 50
Slack post.
Slack
Health alert: Northwind dropped to 42.
CSM: Sam
What changed: Logins down, 2 unresolved support tickets
Step 2: 1 day after the Slack post
Email to the CSM.
Hi Sam,
Northwind's health score dropped to 42, and we posted it in Slack. What changed: Logins down, 2 unresolved support tickets. If you've already reached out, you can ignore this.
Other subject lines: "Still at risk: Northwind is at 42"
Different cutoff? Change the 50 in the start code. Different follow-up gap? Change the P1D wait.
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 customer success channel's ID. It stores both once on the "cs-alerts" tenant that the journey's Slack node 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 health score drops
Node.js
// Start the alert when an account's health score drops, with the journey ID// setup.ts printed. Call it from your health-score job. The journey runs as the// account's primary contact. It only starts for a score below 50 and returns// null otherwise. The Slack post goes right away, and the journey waits 1 day// before the email.// The Slack node names the tenant from setup-slack.ts, so there's nothing about// Slack to pass here.import Courier from "@trycourier/courier";const client = new Courier();// Alert when an account's health score is below this.const ALERT_BELOW = 50;export async function startHealthAlert(journeyId: string,contact: { id: string },account: { id: string; name: string; healthScore: number; drivers: string },csm: { name: string; email: string }) {if (account.healthScore >= ALERT_BELOW) return null;const { runId } = await client.journeys.invoke(journeyId, {user_id: contact.id,data: {account_id: account.id,account_name: account.name,health_score: account.healthScore,drivers: account.drivers,csm_name: csm.name,csm_email: csm.email,},});return runId;}
Python
# Start the alert when an account's health score drops, with the journey ID# setup.ts printed. Call it from your health-score job. The journey runs as the# account's primary contact. It only starts for a score below 50 and returns# None otherwise. The Slack post goes right away, and the journey waits 1 day# before the email.# The Slack node names the tenant from setup-slack.ts, so there's nothing about# Slack to pass here.from typing import Optionalfrom courier import Courierclient = Courier()# Alert when an account's health score is below this.ALERT_BELOW = 50def start_health_alert(journey_id: str, contact: dict, account: dict, csm: dict) -> Optional[str]:if account["health_score"] >= ALERT_BELOW:return Noneresponse = client.journeys.invoke(template_id=journey_id,user_id=contact["id"],data={"account_id": account["id"],"account_name": account["name"],"health_score": account["health_score"],"drivers": account["drivers"],"csm_name": csm["name"],"csm_email": csm["email"],},)return response.run_id
3. Stop it when the score recovers
Node.js
// Stop the CSM follow-up when the score recovers: call this from your// health-score job when an account climbs back above your cutoff. It cancels// every active run for the account, and does nothing if the alert already finished.import Courier from "@trycourier/courier";const client = new Courier();export async function stopHealthAlert(accountId: string) {await client.journeys.cancel({ cancelation_token: `health-${accountId}` });}
Python
# Stop the CSM follow-up when the score recovers: call this from your# health-score job when an account climbs back above your cutoff. It cancels# every active run for the account, and does nothing if the alert already finished.from courier import Courierclient = Courier()def stop_health_alert(account_id: str) -> None:client.journeys.cancel(cancelation_token=f"health-{account_id}")
Full definition: journey.json, setup.ts, setup-slack.ts, and the 2 templates
Your health-score job calls the start function on every score update, and it starts the journey only when the score is below 50. The journey posts to Slack right away, waits 1 day, then emails the CSM.
The Slack post uses the token and channel stored on the "cs-alerts" tenant, so your job never touches them. The email goes to csm_email.
journey.json
{"name": "Customer health score alert","cancelation_token": "health-{{data.account_id}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "send","channel": "slack","message": {"template": "@health-alert-slack","context": {"tenant_id": "cs-alerts"}}},{"type": "delay","mode": "duration","duration": "P1D"},{"type": "send","channel": "email","message": {"template": "@health-followup-email","to": {"email_override": "{{data.csm_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 ("cs-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 customer success 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 = "cs-alerts";await client.tenants.update(tenantId, {name: "Customer success alerts (Slack)",user_profile: {slack: {access_token: requireEnv("SLACK_BOT_TOKEN"),channel: requireEnv("SLACK_ALERT_CHANNEL"),},},});console.log(JSON.stringify({ tenantId }));
templates/health-alert-slack.json
{"channel": "slack","notification": {"name": "Health score alert (Slack)","tags": ["recipe", "health-score"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "slack","elements": [{"type": "text","content": "Health alert: {{account_name}} dropped to {{health_score}}."},{"type": "text","content": "CSM: {{csm_name}}"},{"type": "text","content": "What changed: {{drivers}}"},{"type": "action","content": "Open the account","href": "https://admin.acme.com/accounts/{{account_id}}"}]}]}}}
templates/health-followup-email.json
{"channel": "email","notification": {"name": "Health score follow-up (email)","tags": ["recipe", "health-score"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "{{account_name}}'s health score dropped to {{health_score}}"},{"type": "text","content": "Hi {{csm_name}},"},{"type": "text","content": "{{account_name}}'s health score dropped to {{health_score}}, and we posted it in Slack. What changed: {{drivers}}. If you've already reached out, you can ignore this."},{"type": "action","content": "Open the account","href": "https://admin.acme.com/accounts/{{account_id}}"}]}]}}}
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 | Builds the cancellation token and the account link |
| account_name | string | Northwind | |
| health_score | number | 42 | Your start code alerts only when it's below 50 |
| drivers | string | Logins down, 2 unresolved support tickets | What moved the score |
| csm_name | string | Sam | |
| csm_email | sam@example.com | Where the follow-up goes |
FAQ
Frequently asked questions
How do I send a Slack alert when a customer's health score drops?
Start a journey from your health-score job when an account's score falls, with a Slack send node that posts to your customer success channel. Your start code checks the score and starts the journey only when it's below your cutoff.
Where does the Slack bot token go?
In the Slack setup script that ships with this recipe, which stores your bot token once on a Courier tenant that stands for your company's Slack workspace. The journey's Slack node names that tenant, "cs-alerts", so the journey holds no token and your start call passes nothing about Slack. You rotate the token by running the script 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 the way the script does, and pass that tenant's ID as context.tenant_id when you start the run.
How do I email the account's CSM as well?
Add a send node with a recipient override that points at the CSM's address from your data. Here it's csm_email, so each account's alert reaches its own CSM.
How do I stop the follow-up when the score recovers?
Tag each run with the account ID and cancel that token when your job sees the score back above your cutoff. The Slack post has already gone out, so it cancels the email that's still waiting.
Recipes
Related recipes
Docs
How each part works
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.