Account and security
OTP by SMS with email fallback: template and code
Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
Passing, tested 2026-10-19 · SMS, Email
How it works
How the login code is sent
An OTP by SMS with email fallback sends a one-time login code by text message, or by email when the user has no phone number. The code is good for 10 minutes, and Courier stops trying to deliver it after that, so nobody receives a code that's already expired.
Messages
What it sends
When someone signs in
SMS, or email when there's no phone number.
SMS
Your Acme code is 482913. It expires in 10 minutes.
Your login code is:
482913
It expires in 10 minutes. If you didn't try to sign in, you can ignore this email.
Other subject lines: "Your Acme sign-in code"
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 signs in
Node.js
// Send a login code by SMS, or by email when there's no phone number, with// the template ID setup.ts printed. The timeout tells Courier to stop trying// after 10 minutes, the same as the code's lifetime. The template says "10// minutes" too, so change both together.import Courier from "@trycourier/courier";const client = new Courier();export async function sendLoginCode(templateId: string,userId: string,code: string) {const { requestId } = await client.send.message({message: {to: { user_id: userId },template: templateId,data: { code },routing: { method: "single", channels: ["sms", "email"] },timeout: { message: 10 * 60 * 1000 },},});return requestId;}
Python
# Send a login code by SMS, or by email when there's no phone number, with# the template ID setup.ts printed. The timeout tells Courier to stop trying# after 10 minutes, the same as the code's lifetime. The template says "10# minutes" too, so change both together.from courier import Courierclient = Courier()def send_login_code(template_id: str, user_id: str, code: str) -> str:response = client.send.message(message={"to": {"user_id": user_id},"template": template_id,"data": {"code": code},"routing": {"method": "single", "channels": ["sms", "email"]},"timeout": {"message": 10 * 60 * 1000},},)return response.request_id
Full definition: setup.ts, and the 1 template
One template with an SMS version and an email version. The send call tries SMS first and falls back to email when the user has no phone number, and the message timeout stops delivery after 10 minutes. Your app passes only the code.
Replace the product name (Acme) once, and keep the template's 10 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/login-code.json
{"notification": {"name": "Login code","tags": ["recipe", "otp-sms-email-fallback"],"brand": null,"subscription": null,"routing": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "sms","elements": [{"type": "text","content": "Your Acme code is {{code}}. It expires in 10 minutes."}]},{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your Acme login code"},{"type": "text","content": "Your login code is:"},{"type": "text","content": "{{code}}"},{"type": "text","content": "It expires in 10 minutes. If you didn't try 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 |
|---|---|---|---|
| code | string | 482913 |
FAQ
Frequently asked questions
How do I send an OTP by SMS with email as a fallback?
Put both versions in one template and send it with routing method single and channels sms, then email. Courier sends the SMS when the user has a phone number, and the email when they don't.
How long should a login code be valid?
Ten minutes is common. Set the message timeout to the same length, so Courier stops trying once the code no longer works, and say the same number of minutes in the template.
Should the code go in the email subject?
No. A subject shows in inbox lists and lock-screen previews, where anyone who sees the screen can read the code. Keep the code in the body of the email.
Recipes
Related recipes
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.