# App download link SMS: template and code

Source: https://www.courier.com/recipes/app-download-link-sms (manifest: https://www.courier.com/recipes/app-download-link-sms/manifest.json)

Move web signups onto your mobile app with a text, then an email, and skip anyone who already installed.

An app download link SMS gets web signups onto your mobile app. Two days after signup it texts a link to your app, and on day 5 it emails App Store and Google Play buttons. People without a phone number get only the email, and installing the app stops the rest.

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 an app download link

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 2 days after signup | SMS |  |
| 2 | 5 days after signup | Email | Get the Acme app |

Want different timing? Change the 2-day and 3-day waits in the Journeys editor. The second wait counts from the first, so the email goes 3 days after the text.

## Copy

### App download link (SMS)

Acme: get our app for iPhone and Android here: https://acme.app/get Reply STOP to opt out.

### App download link (email)

Subject: "Get the Acme app", "Get Acme on your phone"

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

Acme also comes as a mobile app. Download it and sign in with the account you already have.

Button: Download on the App Store (https://apps.apple.com/app/acme/id1234567890)

Button: Get it on Google Play (https://play.google.com/store/apps/details?id=com.acme.app)

## The journey

```json
{
  "name": "App download link",
  "cancelation_token": "app-{{recipient}}",
  "nodes": [
    {
      "type": "trigger",
      "trigger_type": "api-invoke"
    },
    {
      "type": "delay",
      "mode": "duration",
      "duration": "P2D"
    },
    {
      "type": "send",
      "channel": "sms",
      "message": {
        "template": "@app-link-sms"
      },
      "conditions": ["profile.phone_number", "exists"]
    },
    {
      "type": "delay",
      "mode": "duration",
      "duration": "P3D"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@app-link-email"
      }
    },
    {
      "type": "exit"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.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 }));
```

## Start it when someone signs up

```typescript
// Start the sequence when someone signs up on the web, with the journey ID setup.ts printed.
// The templates read the first name from the user's Courier profile, so
// there's no data to pass.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function startAppDownloadLinks(journeyId: string, userId: string) {
  const { runId } = await client.journeys.invoke(journeyId, {
    user_id: userId,
  });
  return runId;
}
```

```python
# Start the sequence when someone signs up on the web, with the journey ID setup.ts printed.
# The templates read the first name from the user's Courier profile, so
# there's no data to pass.
from courier import Courier

client = Courier()


def start_app_download_links(journey_id: str, user_id: str) -> str:
    response = client.journeys.invoke(template_id=journey_id, user_id=user_id)
    return response.run_id
```

## Stop it when they install the app

```typescript
// Stop the sequence when the app is installed, or when the account is deleted.
// It cancels every run waiting for the user, and does nothing if the sequence
// already finished.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function stopAppDownloadLinks(userId: string) {
  await client.journeys.cancel({ cancelation_token: `app-${userId}` });
}
```

```python
# Stop the sequence when the app is installed, or when the account is deleted.
# It cancels every run waiting for the user, and does nothing if the sequence
# already finished.
from courier import Courier

client = Courier()


def stop_app_download_links(user_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"app-{user_id}")
```

## Templates

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

templates/app-link-sms.json

```json
{
  "channel": "sms",
  "notification": {
    "name": "App download link (SMS)",
    "tags": ["recipe", "app-download-link-sms"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "sms",
          "elements": [
            {
              "type": "text",
              "content": "Acme: get our app for iPhone and Android here: https://acme.app/get Reply STOP to opt out."
            }
          ]
        }
      ]
    }
  }
}
```

templates/app-link-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "App download link (email)",
    "tags": ["recipe", "app-download-link-sms"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Get the Acme app"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}},"
            },
            {
              "type": "text",
              "content": "Acme also comes as a mobile app. Download it and sign in with the account you already have."
            },
            {
              "type": "action",
              "content": "Download on the App Store",
              "href": "https://apps.apple.com/app/acme/id1234567890"
            },
            {
              "type": "action",
              "content": "Get it on Google Play",
              "href": "https://play.google.com/store/apps/details?id=com.acme.app"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
Add an app download link sequence to this app, sent through Courier: an SMS with the download link 2 days after a web signup, then an email with App Store and Google Play buttons on day 5, both skipped once the user installs the app. Follow the steps at https://www.courier.com/recipes/app-download-link-sms.md and use the Courier skill for SDK details.

1. Create the journey and its templates in my Courier workspace by replacing Acme and the download links in the template files 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_APP_LINK_JOURNEY_ID.
2. Find where this codebase creates a new web user and start the journey there. Pass only the user ID, and make sure first_name and phone_number are written to their Courier profile at signup.
3. Find where a user first signs in on the mobile app and cancel the journey there with the token app-<user_id>. Cancel it the same way when an account is deleted.
4. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
5. When you're done, start one run for a test user and show me the run's steps.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| profile.first_name | string | Dana | From the profile. Write it when the user signs up. The emails say "there" when it's missing |

## FAQ

### When should I send an app download link after signup?

Give the web product a couple of days first, then text. This recipe texts on day 2 and emails on day 5, and you can change either wait in the Journeys editor.

### How do I skip people who already installed the app?

When the app first signs in, cancel the run with the token app-<user_id>. Nothing is stored on the profile, and the SMS and email still to come are dropped.

### What happens if the user has no phone number?

The SMS is skipped and the email still goes. The SMS step checks the profile for a phone number first.

### Can one link work for both iPhone and Android?

Yes. Use a link from your deep link or attribution provider that routes by device as the SMS link. The email has a separate button for each store.

## Related docs

- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)
- [Send your first SMS](https://www.courier.com/docs/guides/send-your-first-sms)

## Related recipes

- [Welcome email sequence](https://www.courier.com/recipes/welcome-email-sequence): Welcome every new user, then nudge only the ones who haven't finished setup.
- [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.
- [One-time passcode](https://www.courier.com/recipes/otp-sms-email-fallback): Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
