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.
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.
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 Courierclient = Courier()EXPIRES_IN_MINUTES = 15def 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.
Data
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
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.
Recipes
Related recipes
Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
Send a password reset email with a link that expires, and never deliver a stale one.
Get new signups to confirm their email, and stop reminding them the moment they do.
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.