> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message` from the Node SDK (`@trycourier/courier` v7 and later, where the client is the default import). Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> To translate a template, write one locale at a time with `PUT /notifications/{id}/locales/{localeId}`, which merges into the existing translations. `PUT /notifications/{id}/content` and `PUT /notifications/{id}/elements/{elementId}` overwrite translations along with the content, so send each element's `id` and `locales` back, and first remove every `checksum` and any `locales` key that starts with an underscore.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Send lifecycle messages across channels

> Turn a product event into lifecycle messages on Inbox, email, push, and SMS with one Journey: wait, check, send, and stop when the user converts.

Send the right message on the right channel when a user does something in your product, then stop the moment they convert.

Each lifecycle moment becomes one Journey. Your app sends one event, and Courier runs the waits, the checks, and the channel choices.

## See what you'll build

A trial lifecycle that starts when a user starts a trial and ends when they upgrade:

```mermaid theme={null}
flowchart LR
    A["Trial started"] --> B["Inbox and email welcome"]
    B -->|until reminder time| C{"Upgraded?"}
    C -->|No| D["Push, else SMS"]
    D -->|until trial ends| E["Recheck, then email"]
    C -->|Yes| F["Exit"]
```

The same pattern covers most lifecycle messages. Map each product event to its own Journey:

| Product event | Journey | Channels |
| - | - | - |
| `user_signed_up` | Onboarding and activation | Inbox, email |
| `trial_started` | Trial conversion | Inbox, email, push, SMS fallback |
| `payment_failed` | Dunning | Email, then SMS if unresolved |
| `user_inactive_14d` | Win-back | Email, then push |
| `plan_upgraded` | Thank-you and tour | Inbox, email |

Keep the decision logic in the Journey, not in your app. A product manager can then change timing and copy without a deploy. For the email-only version of this pattern, see [Build an onboarding sequence](https://www.courier.com/docs/guides/build-an-onboarding-sequence.md).

## Prerequisites

* [A Courier API key](https://app.courier.com/settings/api-keys)
* [A connected email provider](https://www.courier.com/docs/integrations/email/overview.md)
* [A connected SMS provider](https://www.courier.com/docs/integrations/sms/overview.md)
* [A connected push provider](https://www.courier.com/docs/integrations/push/overview.md)
* [Push delivery tracking in your app](https://www.courier.com/docs/integrations/push/overview.md#tracking)
* [Courier Inbox in your app](https://www.courier.com/docs/in-app/overview.md)
* [Your users stored in Courier](https://www.courier.com/docs/guides/import-your-users.md)
* A public HTTPS endpoint that returns a user's subscription status

## Build the Journey

You don't need every channel to follow along. Leave out the send node for any channel you haven't connected, and the rest of the Journey still runs. If you leave out push, drop the one-hour delay, the fetch after it, and the SMS node's `send_status` condition too, since that condition reads the push node's status.

**1. Create the Journey and its trigger**

Open [Journeys](https://app.courier.com/orchestration/journeys), select **New Journey**, name it `trial-lifecycle`, and choose the **API Invoke** trigger.

Add three fields to the trigger schema. Every node downstream can read them:

* `plan`: the plan the user is trialing
* `reminder_at`: when to send the reminder, as an ISO 8601 datetime. Your app computes it, for example three days before the trial ends
* `trial_ends_at`: when the trial ends, as an ISO 8601 datetime

Keep `reminder_at` in the future and at least two hours before `trial_ends_at`. That leaves room for the push, the one-hour SMS fallback, and the last-day email to go out in order. For a trial too short for that, move `reminder_at` closer to the start.

If you already send events through Segment, pick the **Segment** trigger instead, and the event's `properties` become the run's data. See [Start a journey from Segment](https://www.courier.com/docs/guides/notify-from-segment.md). Segment can deliver an event more than once, and a Segment-triggered run has no idempotency key, so a repeat starts a second run. If duplicates matter for this flow, invoke the Journey from your backend with an idempotency key instead.

**2. Welcome them in the app and by email**

Add a **send** node, choose **In-app / Inbox**, and select **+ Create** to write the message inline. Reference the run's data with variables such as `{{plan}}`.

Add a second send node on the **Email** channel with the same welcome and a link to your most important setup step.

Each send node owns a template scoped to this Journey. A run sends each template's published version, so publish every template before you publish the Journey.

**3. Wait until the reminder time**

Add a **delay** node, set the interval type to **Until**, and turn on **Dynamic Interval**. Select `data.reminder_at` as the field.

Each user's run now waits until their own reminder time, so your code holds no timers. For a gap longer than [one delay covers](https://www.courier.com/docs/journeys/overview.md#delays), chain delays.

**4. Check whether they already upgraded**

A run works from a snapshot of the data it started with, so `data.plan` still says the trial plan a week later. To read the user's current state, add a **fetch data** node that calls your own API, such as `https://api.example.com/users/{{recipient}}/subscription`. The response merges into the run's data.

Return the status in a field the trigger doesn't set, such as `subscription_status`. The default **Soft Merge** never overwrites a field the run already has, so a response field named `plan` would leave the stale value in place.

Protect the endpoint. Turn on **Headers** in the fetch node and send a secret, such as `Authorization: Bearer <your token>`, that your endpoint checks.

**A failed fetch ends the run.** A non-2xx response, a redirect, or no answer within 10 seconds stops the run with an error, and that user gets no reminders. Have the endpoint return `200` for every user in a trial, including ones it doesn't know yet.

Then add a **branch** node. Give the first path the condition `data.subscription_status` is equal `active`, and wire it to an exit. The default path continues to the reminders.

**5. Remind them on push, and fall back to SMS**

On the default path, add a send node on the **Push** channel with a short reminder. A user with no device tokens gets no push, and their message ends as `UNROUTABLE`. Any status short of `DELIVERED` passes the SMS condition below, so they get the SMS.

Add a **delay** of one hour after the push node. Your app reports delivery after the device receives the push, so a check made right after the send would find most pushes not yet delivered.

After the delay, add a **fetch data** node that calls the same subscription endpoint with merge strategy **Overwrite**, so an upgrade during that hour is seen.

Then add an **SMS** send node with the same reminder and two conditions in one group, so both must hold: `send_status.<push node ID>` **was not** `DELIVERED`, and `data.subscription_status` **is not equal** `active`. The node ID is the push node's `id`. Over the API, it comes back in the response when you create or replace the Journey, and it stays the same when you send the nodes back with their IDs. [`GET /journeys/{templateId}`](https://www.courier.com/docs/api-reference/journeys/fetch-a-journey-by-id) returns it once the Journey is published. Users still on the trial whose push didn't arrive within the hour, including users without your app, get the SMS. Everyone else skips it.

**This step depends on push delivery tracking.** A push reaches `DELIVERED` only when your app reports it, through a Courier mobile SDK or the payload's `trackingUrl`. Without that, every push stays at `SENT` and every user also gets the SMS.

To keep reminders in business hours, compute `reminder_at` in the user's daytime rather than adding a send window. A send window holds delivery until it opens, so a push held overnight would still be undelivered when the one-hour check runs.

**6. Send the last-day email**

Add a **delay** set to **Until** with **Dynamic Interval** on `data.trial_ends_at`.

Check the subscription again, since the user may have upgraded after the reminder. Add a second **fetch data** node that calls the same endpoint, and set its merge strategy to **Overwrite**. The default Soft Merge would keep the status the first fetch stored. Then add a branch on `data.subscription_status` is equal `active` that exits, as before.

On the default path, add an **Email** send node that tells the user what they keep if they upgrade today.

To learn which version converts better, turn this node into an [experiment](https://www.courier.com/docs/journeys/experiments.md) and compare opens and clicks per variant.

**7. Set a cancelation token and publish**

In the Journey's settings, set the **cancelation token** to `trial-{{recipient}}`. Each user's run carries its own token, so you can stop one user's messages without touching anyone else's.

Select **Publish**, then copy the Journey ID from the editor. New runs use the published version, and runs already in flight finish on the version they started with.

## Trigger it from your app

**1. Invoke the Journey when the trial starts**

Call [`POST /journeys/{templateId}/invoke`](https://www.courier.com/docs/api-reference/journeys/invoke-a-journey) from the code that starts the trial, with the user and the trigger data. Replace `trial-lifecycle` with the Journey ID you copied, or with the Journey's alias if you set one.

Compute `reminder_at` and `trial_ends_at` from the user's real trial as ISO 8601 strings. The samples use four and seven days from now. In production, set the reminder to a daytime hour in the user's time zone. Keep `reminder_at` within [one delay's limit](https://www.courier.com/docs/journeys/overview.md#delays) of the invoke, and `trial_ends_at` within that limit of `reminder_at`.

```javascript Node.js theme={null}
const reminderAt = new Date(Date.now() + 4 * 86_400_000).toISOString();
const trialEndsAt = new Date(Date.now() + 7 * 86_400_000).toISOString();

const journeysInvokeResponse = await client.journeys.invoke('trial-lifecycle', {
  user_id: 'user_123',
  data: {
    plan: 'pro',
    reminder_at: reminderAt,
    trial_ends_at: trialEndsAt,
  },
  'Idempotency-Key': 'trial-started-trial_456',
});
```

```python Python theme={null}
from datetime import datetime, timedelta, timezone

reminder_at = (datetime.now(timezone.utc) + timedelta(days=4)).isoformat()
trial_ends_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()

journeys_invoke_response = client.journeys.invoke(
    template_id="trial-lifecycle",
    user_id="user_123",
    data={
        "plan": "pro",
        "reminder_at": reminder_at,
        "trial_ends_at": trial_ends_at,
    },
    idempotency_key="trial-started-trial_456",
)
```

```bash cURL theme={null}
IN_4D=$(( $(date +%s) + 4 * 86400 )); IN_7D=$(( $(date +%s) + 7 * 86400 ))
REMINDER_AT=$(date -u -d "@$IN_4D" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$IN_4D" +%Y-%m-%dT%H:%M:%SZ)
TRIAL_ENDS_AT=$(date -u -d "@$IN_7D" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$IN_7D" +%Y-%m-%dT%H:%M:%SZ)

curl -X POST https://api.courier.com/journeys/trial-lifecycle/invoke \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: trial-started-trial_456" \
  -d "{
    \"user_id\": \"user_123\",
    \"data\": {
      \"plan\": \"pro\",
      \"reminder_at\": \"$REMINDER_AT\",
      \"trial_ends_at\": \"$TRIAL_ENDS_AT\"
    }
  }"
```

```ruby Ruby theme={null}
require "time"

reminder_at = (Time.now.utc + 4 * 86_400).iso8601
trial_ends_at = (Time.now.utc + 7 * 86_400).iso8601

journeys_invoke_response = courier.journeys.invoke(
  "trial-lifecycle",
  user_id: "user_123",
  data: {
    plan: "pro",
    reminder_at: reminder_at,
    trial_ends_at: trial_ends_at
  },
  idempotency_key: "trial-started-trial_456"
)
```

```go Go theme={null}
reminderAt := time.Now().UTC().AddDate(0, 0, 4).Format(time.RFC3339)
trialEndsAt := time.Now().UTC().AddDate(0, 0, 7).Format(time.RFC3339)

journeysInvokeResponse, err := client.Journeys.Invoke(
	context.TODO(),
	"trial-lifecycle",
	courier.JourneyInvokeParams{
		JourneysInvokeRequest: courier.JourneysInvokeRequestParam{
			UserID: courier.String("user_123"),
			Data: map[string]any{
				"plan":          "pro",
				"reminder_at":   reminderAt,
				"trial_ends_at": trialEndsAt,
			},
		},
		IdempotencyKey: courier.String("trial-started-trial_456"),
	},
)
```

```java Java theme={null}
String reminderAt = java.time.Instant.now().plus(java.time.Duration.ofDays(4)).toString();
String trialEndsAt = java.time.Instant.now().plus(java.time.Duration.ofDays(7)).toString();

JourneyInvokeParams params = JourneyInvokeParams.builder()
    .templateId("trial-lifecycle")
    .journeysInvokeRequest(JourneysInvokeRequest.builder()
        .userId("user_123")
        .data(JourneysInvokeRequest.Data.builder()
            .putAdditionalProperty("plan", JsonValue.from("pro"))
            .putAdditionalProperty("reminder_at", JsonValue.from(reminderAt))
            .putAdditionalProperty("trial_ends_at", JsonValue.from(trialEndsAt))
            .build())
        .build())
    .idempotencyKey("trial-started-trial_456")
    .build();
JourneysInvokeResponse journeysInvokeResponse = client.journeys().invoke(params);
```

```php PHP theme={null}
$reminderAt = gmdate('Y-m-d\TH:i:s\Z', strtotime('+4 days'));
$trialEndsAt = gmdate('Y-m-d\TH:i:s\Z', strtotime('+7 days'));

$journeysInvokeResponse = $client->journeys->invoke(
  'trial-lifecycle',
  userID: 'user_123',
  data: [
    'plan' => 'pro',
    'reminder_at' => $reminderAt,
    'trial_ends_at' => $trialEndsAt,
  ],
  idempotencyKey: 'trial-started-trial_456',
);
```

```csharp C# theme={null}
var reminderAt = DateTime.UtcNow.AddDays(4).ToString("o");
var trialEndsAt = DateTime.UtcNow.AddDays(7).ToString("o");

JourneyInvokeParams parameters = new()
{
    TemplateID = "trial-lifecycle",
    UserID = "user_123",
    Data = new Dictionary<string, JsonElement>()
    {
        { "plan", JsonSerializer.SerializeToElement("pro") },
        { "reminder_at", JsonSerializer.SerializeToElement(reminderAt) },
        { "trial_ends_at", JsonSerializer.SerializeToElement(trialEndsAt) },
    },
    IdempotencyKey = "trial-started-trial_456",
};

var journeysInvokeResponse = await client.Journeys.Invoke(parameters);
```

```bash CLI theme={null}
IN_4D=$(( $(date +%s) + 4 * 86400 )); IN_7D=$(( $(date +%s) + 7 * 86400 ))
REMINDER_AT=$(date -u -d "@$IN_4D" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$IN_4D" +%Y-%m-%dT%H:%M:%SZ)
TRIAL_ENDS_AT=$(date -u -d "@$IN_7D" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$IN_7D" +%Y-%m-%dT%H:%M:%SZ)

courier journeys invoke \
  --api-key "$COURIER_API_KEY" \
  --template-id trial-lifecycle \
  --user-id user_123 \
  --idempotency-key trial-started-trial_456 \
  --data "{\"plan\":\"pro\",\"reminder_at\":\"$REMINDER_AT\",\"trial_ends_at\":\"$TRIAL_ENDS_AT\"}"
```

```text MCP theme={null}
With Courier MCP, start the trial-lifecycle journey for user_123 on the pro plan, with the trial ending a week from today and the reminder three days before that.
```

The idempotency key makes a retried request return the same run instead of starting a second sequence. Build it from the trial's ID, not the user's, so a user who starts a new trial later gets a new run. Courier keeps a key for 25 hours by default. See [Idempotency](https://www.courier.com/docs/reference/api-overview.md#idempotency) to hold it longer. The response carries a `runId`. Store it to inspect or cancel this exact run later.

**2. Stop the messages when they upgrade**

In your upgrade handler, call [`POST /journeys/cancel`](https://www.courier.com/docs/api-reference/journeys/cancel-journey-runs) with the user's token. Pending steps never fire, and anything already delivered stays delivered.

```javascript Node.js theme={null}
const cancelJourneyResponse = await client.journeys.cancel({ cancelation_token: 'trial-user_123' });
```

```python Python theme={null}
cancel_journey_response = client.journeys.cancel(
    cancelation_token="trial-user_123",
)
```

```bash cURL theme={null}
curl -X POST https://api.courier.com/journeys/cancel \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "cancelation_token": "trial-user_123"
  }'
```

```ruby Ruby theme={null}
cancel_journey_response = courier.journeys.cancel(cancel_journey_request: {cancelation_token: "trial-user_123"})
```

```go Go theme={null}
cancelJourneyResponse, err := client.Journeys.Cancel(context.TODO(), courier.JourneyCancelParams{
	CancelJourneyRequest: courier.CancelJourneyRequestUnionParam{
		OfByCancelationToken: &courier.CancelJourneyRequestByCancelationTokenParam{
			CancelationToken: "trial-user_123",
		},
	},
})
```

```java Java theme={null}
CancelJourneyRequest.ByCancelationToken params = CancelJourneyRequest.ByCancelationToken.builder()
    .cancelationToken("trial-user_123")
    .build();
CancelJourneyResponse cancelJourneyResponse = client.journeys().cancel(params);
```

```php PHP theme={null}
// cancel() requires both a token and a run ID, and the API rejects a request with both.
// The raw service sends only the token, through the same client.
$cancelJourneyResponse = $client->journeys->raw->cancel(
  params: ['cancelationToken' => 'trial-user_123']
)->parse();
```

```csharp C# theme={null}
JourneyCancelParams parameters = new()
{
    CancelJourneyRequest = new ByCancelationToken("trial-user_123")
};

var cancelJourneyResponse = await client.Journeys.Cancel(parameters);
```

```bash CLI theme={null}
courier journeys cancel \
  --api-key "$COURIER_API_KEY" \
  --cancelation-token trial-user_123
```

```text MCP theme={null}
With Courier MCP, cancel the journey runs with the cancelation token trial-user_123.
```

If this call is missed, a subscription check runs before the push, the SMS, and the last-day email, so a user who upgraded gets none of them.

## Verify

**1. Run it on a short clock**

Duplicate the Journey for testing, set the copy's delay after the push node to 2 minutes, and change the copy's cancelation token to `trial-test-{{recipient}}` so a test cancel can't stop a real run. Publish the copy's templates and the copy, then invoke the copy's Journey ID, not the original's, for a test user with `reminder_at` a few minutes from now and `trial_ends_at` a few minutes after that. The whole lifecycle plays out in about ten minutes instead of two weeks.

**2. Follow the run**

Open the Journey's **Logs** tab and select the run. Each node's outcome is drawn on the graph. Click a node to see its input data, the profile it read, the conditions it evaluated, and its output. Over the API, [`GET /journeys/runs/{run_id}/steps`](https://www.courier.com/docs/api-reference/journeys/list-steps-for-a-journey-run) returns the same steps.

**3. Check each path**

Run it for a test user on a device that reports delivery and confirm the SMS node is skipped. Run it for a user with no device tokens and confirm the SMS goes out. Then have your test endpoint return `subscription_status: active` and confirm the run exits at the branch.

**4. Confirm a cancel stops the run**

Invoke it once more, then cancel with the token `trial-test-<user ID>` while the run waits at a delay. Open the run in the **Logs** tab and confirm it reads **Canceled**. A cancel by token answers the same way whether or not it matched a run, so the run's status is the proof.

## Measure what's working

* **Per message**: [Analytics](https://www.courier.com/docs/monitor/analytics.md) shows delivery, open, and click rates for each Journey.
* **Per path**: in the run logs, compare how many runs exit at either upgrade branch with how many reach the last-day email.
* **Per variant**: an [experiment](https://www.courier.com/docs/journeys/experiments.md) on any send node compares versions of one message.
* **In your own systems**: an [outbound webhook](https://www.courier.com/docs/guides/track-delivery.md) forwards delivery events so you can join them to conversion data.

## Fix common problems

> [accordion] No run appears after invoking
>
> Only a published Journey has a version to run. Publish each send node's template, then publish the Journey, and invoke again with its Journey ID.

> [accordion] The branch reads stale data
>
> A run's data is a snapshot taken at invoke, so later edits to the user's profile don't reach it. Read anything that changes during a wait with a fetch data node right before the branch.

> [accordion] A run ended at the fetch node
>
> The fetch got a non-2xx response, a redirect, or no answer within 10 seconds. The step's error message in the run logs names the cause. Return `200` for every user the Journey can reach.

> [accordion] A send node was skipped
>
> A node whose conditions aren't met is skipped, and the run logs show which condition failed.

> [accordion] Cancel by token didn't stop a run
>
> A token that references a variable missing at run start leaves the run untagged. That run is still cancelable by `run_id`.

> [accordion] Every user got both the push and the SMS
>
> The push never reached `DELIVERED`, so the SMS condition passed. Check the push in [Logs](https://www.courier.com/docs/monitor/logs.md). A push stuck at `SENT` means your app isn't reporting delivery. See [push tracking](https://www.courier.com/docs/integrations/push/overview.md#tracking).

> [accordion] A user got the sequence twice
>
> Each invoke without an idempotency key starts a new run. Key the request on the trial, such as `trial-started-trial_456`, so a retry returns the existing run and a later trial still starts its own.

Every node type, and what each one accepts, is in the [nodes reference](https://www.courier.com/docs/journeys/nodes.md).
