# Customer health score Slack alert: template and code

Source: https://www.courier.com/recipes/customer-health-score-slack-alert (manifest: https://www.courier.com/recipes/customer-health-score-slack-alert/manifest.json)

Tell your customer success team in Slack the day an account's health drops.

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.

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 health score alerts

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When an account's health score drops below 50 | Slack |  |
| 2 | 1 day after the Slack post | Email | {{account_name}}'s health score dropped to {{health_score}} |

Different cutoff? Change the 50 in the start code. Different follow-up gap? Change the P1D wait.

## Copy

### Health score alert (Slack)

Health alert: {{account_name}} dropped to {{health_score}}.

CSM: {{csm_name}}

What changed: {{drivers}}

Button: Open the account (https://admin.acme.com/accounts/{{account_id}})

### Health score follow-up (email)

Subject: "{{account_name}}'s health score dropped to {{health_score}}", "Still at risk: {{account_name}} is at {{health_score}}"

Hi {{csm_name}},

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

Button: Open the account (https://admin.acme.com/accounts/{{account_id}})

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

## Create it in your workspace

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.

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

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

setup-slack.ts

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

## Start it when a health score drops

```typescript
// 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 Optional

from courier import Courier

client = Courier()

# Alert when an account's health score is below this.
ALERT_BELOW = 50


def start_health_alert(
    journey_id: str, contact: dict, account: dict, csm: dict
) -> Optional[str]:
    if account["health_score"] >= ALERT_BELOW:
        return None
    response = 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
```

## Stop it when the score recovers

```typescript
// 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 Courier

client = Courier()


def stop_health_alert(account_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"health-{account_id}")
```

## Templates

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

templates/health-alert-slack.json

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

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

## Hand it to your agent

```text
Add a customer health score alert to this app, sent through Courier: when an account's health score drops below 50, post in our customer success Slack channel right away and email the account's CSM a day later, unless the score recovers. Follow the steps at https://www.courier.com/recipes/customer-health-score-slack-alert.md and use the Courier skill for SDK details.

1. Create the journey and its two templates in my Courier workspace by running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_HEALTH_JOURNEY_ID. Replace the placeholder account link (https://admin.acme.com/accounts/) in the templates with our admin URL.
2. Run setup-slack.ts from the same page with COURIER_API_KEY, SLACK_BOT_TOKEN, and SLACK_ALERT_CHANNEL (our customer success channel's ID) from our environment. It creates the "cs-alerts" tenant that the Slack node in journey.json already names.
3. Find the job or code that computes customer health scores and start the journey, as the account's primary contact, when an account's score drops. Pass the fields in the Data fields table from our own data, with health_score as a number. The start code skips scores of 50 or above.
4. Find where the job sees a score recover above 50 and cancel the journey with the token health-<account_id>.
5. Match this codebase's language, file layout, and error handling. Never hardcode the API key or the Slack token.
6. When you're done, start one run for a test account with a score of 42 and show me the run's steps. The Slack post sends right away and the run then waits a day for the email, so cancel the test run once you've checked it.
```

## 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 | email | sam@example.com | Where the follow-up goes |

## FAQ

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

## Related docs

- [Send node recipients and Slack](https://www.courier.com/docs/journeys/nodes/send)
- [Slack integration](https://www.courier.com/docs/integrations/direct-message/slack)
- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [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.
- [Usage limit alerts](https://www.courier.com/recipes/usage-limit-alert-email): Warn customers as they near a plan limit, and tell your own team when they hit it.
- [Win-back sequence](https://www.courier.com/recipes/win-back-email-sequence): Bring back users who've gone quiet, and restart the clock whenever they log in.
