# Magic link sign-in email: template and code

Source: https://www.courier.com/recipes/magic-link-email (manifest: https://www.courier.com/recipes/magic-link-email/manifest.json)

Sign people in with an emailed link, and never deliver one that has already expired.

A magic link email signs people in without a password. When someone asks for a sign-in link, it sends one email with a single Sign in button. The link works for 15 minutes, and Courier stops trying to deliver it after that, so nobody gets a link that's already expired.

Tested 2026-10-19: passing.

## When to send a magic link email

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When someone asks for a sign-in link | Email | Your Acme sign-in link |

## Copy

### Magic link sign-in (email)

Subject: "Your Acme sign-in link", "Sign in to Acme", "Acme sign-in link (expires in 15 minutes)"

Use the button below to sign in to Acme. The link expires in 15 minutes.

If you didn't ask to sign in, you can ignore this email.

Button: Sign in ({{magic_link_url}})

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// Creates this recipe's templates in your workspace from the templates/
// folder and publishes them. Run it once per workspace:
//
//   COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
//
// It prints the template IDs that start.ts needs.
import Courier from "@trycourier/courier";
import { readFileSync, readdirSync } from "node:fs";

const client = new Courier();
const here = new URL(".", import.meta.url);

const templateIds: Record<string, string> = {};
for (const file of readdirSync(new URL("templates/", here))) {
  const body = JSON.parse(
    readFileSync(new URL(`templates/${file}`, here), "utf8")
  );
  const template = await client.notifications.create(body);
  templateIds[file.replace(/\.json$/, "")] = template.id;
}

console.log(JSON.stringify({ templateIds }));
```

## Send it when someone asks to sign in

```typescript
// Send a magic link sign-in email with the template ID setup.ts printed. The
// timeout tells Courier to stop trying after the same 15 minutes the link
// works for. The template says "15 minutes" too, so change both together.
import Courier from "@trycourier/courier";

const client = new Courier();

const EXPIRES_IN_MINUTES = 15;

export async function sendMagicLink(
  templateId: string,
  userId: string,
  magicLinkUrl: string
) {
  const { requestId } = await client.send.message({
    message: {
      to: { user_id: userId },
      template: templateId,
      data: { magic_link_url: magicLinkUrl },
      routing: { method: "single", channels: ["email"] },
      timeout: { message: EXPIRES_IN_MINUTES * 60 * 1000 },
    },
  });
  return requestId;
}
```

```python
# Send a magic link sign-in email with the template ID setup.ts printed. The
# timeout tells Courier to stop trying after the same 15 minutes the link
# works for. The template says "15 minutes" too, so change both together.
from courier import Courier

client = Courier()

EXPIRES_IN_MINUTES = 15


def send_magic_link(template_id: str, user_id: str, magic_link_url: str) -> str:
    response = client.send.message(
        message={
            "to": {"user_id": user_id},
            "template": template_id,
            "data": {"magic_link_url": magic_link_url},
            "routing": {"method": "single", "channels": ["email"]},
            "timeout": {"message": EXPIRES_IN_MINUTES * 60 * 1000},
        },
    )
    return response.request_id
```

## Templates

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

templates/magic-link.json

```json
{
  "notification": {
    "name": "Magic link sign-in",
    "tags": ["recipe", "magic-link-email"],
    "brand": null,
    "subscription": null,
    "routing": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Your Acme sign-in link"
            },
            {
              "type": "text",
              "content": "Use the button below to sign in to Acme. The link expires in 15 minutes."
            },
            {
              "type": "action",
              "content": "Sign in",
              "href": "{{magic_link_url}}"
            },
            {
              "type": "text",
              "content": "If you didn't ask to sign in, you can ignore this email."
            }
          ]
        }
      ]
    }
  },
  "state": "PUBLISHED"
}
```

## Hand it to your agent

```text
Add magic link sign-in emails to this app, sent through Courier: one email with a Sign in button, and no retries after the link expires. Follow the steps at https://www.courier.com/recipes/magic-link-email.md and use the Courier skill for SDK details.

1. Create the template in my Courier workspace by running setup.ts from that page with COURIER_API_KEY from our environment. Save the template ID it prints as COURIER_MAGIC_LINK_TEMPLATE_ID.
2. Find where this codebase creates a sign-in link and send the email there with the recipe's send code. Pass only magic_link_url. Replace the placeholder product name (Acme) in the template with ours, and make its "15 minutes" match the link's real lifetime and the send timeout.
3. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
4. When you're done, send one magic link to a test user and show me the rendered email.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| magic_link_url | URL | https://app.acme.com/auth/verify?token=3f9c1e | Your app creates the link and its token |

## FAQ

### How do I send a magic link by email?

Create a sign-in URL in your app with a token it can verify, then pass it to the template as magic_link_url. The send call delivers the email, and your app signs the person in when they open the link.

### How long should a magic link last?

Fifteen minutes is a reasonable default. Keep it as short as your users can live with, and set the message timeout and the minutes in the template to the same length, so Courier stops trying once the link no longer works.

### How do I check that a magic link email was delivered?

The send call returns a request ID. Look it up with the Messages API to see its status, its delivery history, and the rendered email.

### Can email scanners use up the link before the person clicks it?

Some email security tools open links before the person does. If your link works only once, have it open a page with a Sign in button and finish the sign-in when that button is clicked.

## Related docs

- [Send a message](https://www.courier.com/docs/send/overview)
- [Routing and fallback](https://www.courier.com/docs/send/routing)
- [Message statuses](https://www.courier.com/docs/send/statuses)

## Related recipes

- [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.
- [Password reset](https://www.courier.com/recipes/password-reset-email): Send a password reset email with a link that expires, and never deliver a stale one.
- [Email verification reminders](https://www.courier.com/recipes/email-verification-reminder-email): Get new signups to confirm their email, and stop reminding them the moment they do.
