# Stalled trial Slack alert: template and code

Source: https://www.courier.com/recipes/stalled-trial-slack-alert (manifest: https://www.courier.com/recipes/stalled-trial-slack-alert/manifest.json)

Hand stuck, high-value trials to a person instead of sending another email.

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.

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 a stalled trial alert

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 5 days after the trial starts | Slack or Email | Need a hand getting started, {{default profile.first_name "there"}}? |

## Copy

### Stalled trial alert (Slack)

Stalled trial: {{account_name}} has {{seats}} seats and hasn't activated yet.

Owner: {{profile.first_name}} {{profile.last_name}} ({{profile.email}})

So far: {{activity_summary}}

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

### Trial nudge (email)

Subject: "Need a hand getting started, {{default profile.first_name "there"}}?", "How's your Acme trial going?", "Anything in your way, {{profile.first_name}}?"

Hi {{default profile.first_name "there"}},

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.

Button: Pick up where you left off (https://app.acme.com/setup)

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

## Create it in your workspace

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.

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

## Start it when a trial starts

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

## Stop it when the account activates

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

## Templates

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

templates/stalled-trial-slack.json

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

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

## Hand it to your agent

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

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

## Related docs

- [Fetch data node](https://www.courier.com/docs/journeys/nodes/fetch-data)
- [Branch node](https://www.courier.com/docs/journeys/nodes/branch)
- [Slack recipients on a send node](https://www.courier.com/docs/journeys/nodes/send)
- [Slack integration](https://www.courier.com/docs/integrations/direct-message/slack)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Trial ending reminders](https://www.courier.com/recipes/trial-ending-email-sequence): Remind trial users before their trial ends, and stop the moment they upgrade.
- [Health score alert](https://www.courier.com/recipes/customer-health-score-slack-alert): Tell your customer success team in Slack the day an account's health drops.
- [Onboarding step reminders](https://www.courier.com/recipes/onboarding-incomplete-reminder-email): Point each stuck user at their next unfinished setup step, not a generic nudge.
