Account and security

Magic link sign-in email: template and code

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

Passing, tested 2026-10-19 · Email

How it works

When to send a magic link email

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.

Messages

What it sends

When someone asks for a sign-in link

Email.

Email

From: Acme
Your Acme sign-in link

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.

Sign in

Other subject lines: "Sign in to Acme", "Acme sign-in link (expires in 15 minutes)"

Build

Add it to your app

1. Create the template

Run setup.ts once with your API key. It creates the template in your workspace, publishes it, and prints the template ID you pass to step 2. Copy setup.ts and the templates folder from the full definition at the end of this section.

Terminal

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

2. Send it when someone asks to sign in

Node.js

// 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
Full definition: setup.ts, and the 1 template

One email template and one send call. Your app creates the magic link and passes it in as magic_link_url. The send call routes to email with a timeout that matches the link, so Courier stops trying once it no longer works.

Replace the product name (Acme) once, and keep the template's 15 minutes the same as the timeout.

setup.ts

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

templates/magic-link.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"
}

Agents

Hand it to your agent

Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.

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

Data fields

FieldTypeExampleNote
magic_link_urlURLhttps://app.acme.com/auth/verify?token=3f9c1eYour app creates the link and its token

FAQ

Frequently asked questions

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.

Build this in your workspace

Run setup.ts against a free account and the template is ready to send. Prefer no code? Rebuild it in Design Studio.