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.

Email

From: Acme
Your Acme login code

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 Courier
client = 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.

Add login codes to this app, sent through Courier: SMS first, email when there's no phone number, and no retries after 10 minutes. Follow the steps at https://www.courier.com/recipes/otp-sms-email-fallback.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_LOGIN_CODE_TEMPLATE_ID. 2. Find where this codebase generates a login or verification code and send it there with the recipe's send code. Pass only the code. Replace the placeholder product name (Acme) in the template with ours, and make its "10 minutes" match the code'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 code to a test user and show me which channel it went out on.

Data

Data fields

FieldTypeExampleNote
codestring482913

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.

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.