How to Send OTPs with an AI Agent
AI agents that handle login or account-recovery flows need to deliver one-time codes. This post covers the right architecture — what the agent does, what stays on your server, and how to implement it correctly across SMS, email, and WhatsApp.
An AI agent handling a login or account-recovery flow needs to deliver a one-time code to a user. Senddio handles the delivery. Your application server handles everything else: generating the code, storing the state, enforcing expiry, and deciding whether the code the user submits is valid.
This boundary is not a suggestion. The moment you let the messaging API own any part of the OTP security logic — or let the agent see the plaintext code — you have created an attack surface that is difficult to audit and harder to fix.
This post explains the architecture, shows the full implementation in Node.js, and covers multi-channel delivery, rate limiting, and error handling.
The fundamental rule: the agent never sees the code
In a correctly built OTP flow, the AI agent acts as a transport layer between the user and your server. It collects the user's contact information, passes it to your server, and later forwards the user's submitted code back to your server for verification. It does not:
- Generate the code
- Call Senddio directly with the code in the message body
- Decide whether the submitted code is valid
- Store the code anywhere in its context
The reason is simple: anything the agent handles is potentially in its context window, in your tracing infrastructure, in LLM provider logs, and in any conversation history you store. A code that passes through these systems is not a secret.
The correct flow
- User provides their phone number or email in the agent interface.
- Agent calls your
POST /auth/otp/sendendpoint with the contact info. The agent does not call Senddio directly. - Your server generates a cryptographically secure random code, stores a keyed digest (not the plaintext) with an expiry, and calls Senddio with a
sdo_live_key — a server-side key, not an agent key. - Senddio delivers the message. Your server returns a session token to the agent (not the code).
- User submits the code back through the agent interface.
- Agent calls your
POST /auth/otp/verifyendpoint with the session token and the submitted code. - Your server looks up the session, compares the submitted code against the stored digest, checks expiry, marks the code as used, and returns a session or access token.
The agent handles steps 2, 5, and 6. Your server handles steps 3, 4, and 7. Senddio handles step 4's delivery. Each layer owns exactly what it should.
Server-side implementation
Generating and storing the OTP
import { randomBytes, createHmac } from "crypto";
import { redis } from "./redis";
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
const OTP_SECRET = process.env.OTP_HMAC_SECRET;
export async function generateOtp(contact) {
// Cryptographically random 6-digit code
const code = String(parseInt(randomBytes(3).toString("hex"), 16) % 900000 + 100000);
// Store digest, not plaintext
const digest = createHmac("sha256", OTP_SECRET).update(code).digest("hex");
const sessionId = randomBytes(16).toString("hex");
await redis.set(
"otp:" + sessionId,
JSON.stringify({ digest, contact, used: false }),
"EX",
OTP_EXPIRY_SECONDS
);
return { sessionId, code };
}
Sending via Senddio
export async function sendOtp(contact, code, channel = "sms") {
const payload = buildPayload(contact, code, channel);
const res = await fetch("https://api.senddio.com/v1/send", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.SENDDIO_SERVER_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error("Senddio rejected the send request: " + err.message);
}
return res.json(); // { id: "sdo_msg_...", status: "queued" }
}
function buildPayload(contact, code, channel) {
if (channel === "sms") {
return {
channel: "sms",
from: process.env.SMS_FROM_NUMBER,
to: contact,
body: "Your verification code is " + code + ". It expires in 10 minutes. Do not share it."
};
}
if (channel === "email") {
return {
channel: "email",
from: "noreply@yourdomain.com",
to: contact,
subject: "Your verification code",
html: "<p>Your code is <strong>" + code + "</strong>. It expires in 10 minutes.</p>",
text: "Your code is " + code + ". It expires in 10 minutes.",
messageType: "transactional"
};
}
if (channel === "whatsapp") {
return {
channel: "whatsapp",
from: process.env.WHATSAPP_FROM_NUMBER,
to: contact,
body: "Your verification code is " + code + ". It expires in 10 minutes. Do not share it."
};
}
throw new Error("Unsupported OTP channel: " + channel);
}
The API endpoint the agent calls
// POST /auth/otp/send
app.post("/auth/otp/send", rateLimitMiddleware, async (req, res) => {
const { contact, channel = "sms" } = req.body;
const { sessionId, code } = await generateOtp(contact);
await sendOtp(contact, code, channel);
// Return only the session ID — never the code
res.json({ sessionId, expiresIn: 600 });
});
// POST /auth/otp/verify
app.post("/auth/otp/verify", async (req, res) => {
const { sessionId, code } = req.body;
const raw = await redis.get("otp:" + sessionId);
if (!raw) {
return res.status(400).json({ error: "Invalid or expired session" });
}
const session = JSON.parse(raw);
if (session.used) {
return res.status(400).json({ error: "Code already used" });
}
const digest = createHmac("sha256", OTP_SECRET).update(code).digest("hex");
if (digest !== session.digest) {
return res.status(400).json({ error: "Invalid code" });
}
// Mark as used — prevents replay
await redis.set("otp:" + sessionId, JSON.stringify({ ...session, used: true }), "KEEPTTL");
// Issue your application session or access token here
const token = await createSessionToken(session.contact);
res.json({ token });
});
What the agent implementation looks like
// Claude tool handler in your application
async function handleAgentToolCall(toolName, toolInput) {
if (toolName === "send_otp") {
const res = await fetch("https://yourapi.com/auth/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contact: toolInput.contact,
channel: toolInput.channel
})
});
const data = await res.json();
// Return session ID to agent — it passes this to verify_otp later
return { sessionId: data.sessionId, message: "Code sent." };
}
if (toolName === "verify_otp") {
const res = await fetch("https://yourapi.com/auth/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: toolInput.sessionId,
code: toolInput.code
})
});
if (!res.ok) {
const err = await res.json();
return { success: false, error: err.error };
}
const data = await res.json();
return { success: true, token: data.token };
}
}
Notice: the agent holds the session ID and passes the user's submitted code through to verification, but never sees the plaintext code that Senddio delivered.
Choosing the channel
| Channel | Best for | Considerations |
|---|---|---|
| SMS | Default — works on any phone, no app required | Higher cost per message; SIM-swap risk on high-value accounts |
| Lower-stakes verification; fallback when SMS unavailable | Slower delivery; user needs email access during the session | |
| Markets where WhatsApp is the primary channel (LATAM, MENA, India) | Body-only sends require an active 24-hour conversation window; business-initiated OTPs require an approved authentication template |
The Senddio /v1/send endpoint accepts all three with the same structure — the only differences are the channel field and the address fields (to is E.164 for SMS and WhatsApp, an email address for email). Your OTP logic and verification logic do not change by channel.
A common pattern is to let the user choose during the registration step and store their preferred OTP channel. When the agent calls /auth/otp/send, the server looks up the preference and delivers to the right channel without the agent needing to decide.
Rate limiting and abuse controls
Your /auth/otp/send endpoint needs its own rate limiting. Senddio enforces plan-level limits per organisation, but those are not a substitute for per-user controls. A plan limit protects your infrastructure; per-user controls protect your users from being spammed.
A reasonable starting policy:
- At most one OTP request per phone number or email per 60 seconds
- At most 5 OTP requests per phone number per hour
- At most 10 OTP requests per IP per hour
- Exponential backoff after 3 failed verification attempts against the same session
const rateLimitMiddleware = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 1,
keyGenerator: (req) => req.body.contact,
message: { error: "Too many OTP requests. Try again in 60 seconds." }
});
Also enforce a maximum of 3 verification attempts per session before invalidating the code. Otherwise a short numeric code is trivially brute-forceable.
Delivery monitoring
Configure a webhook in the Senddio dashboard to receive message.delivered and message.failed events. When an OTP delivery fails — SMS carrier rejection, email bounce — your server can proactively offer the user a retry on a different channel:
// Webhook handler
app.post("/webhooks/senddio", verifySignature, async (req, res) => {
const { event, data } = req.body;
if (event === "message.failed" || event === "message.bounced") {
// Look up the OTP session by message ID; ignore unrelated messages.
await markOtpDeliveryFailed(data.messageId);
}
res.status(200).end();
});
Tag your OTP sends with tag: "otp" so you can filter them in the dashboard and in webhook handlers without matching on message content.
What not to do
- Do not put the code in the agent tool call. If your
send_otptool accepts the code as a parameter, the code is in the agent's context. The correct interface issend_otp(contact, channel)— the code generation and send happen inside your server, not in the agent layer. - Do not use agent keys for OTP sends. Agent keys are intended for messages where attribution matters. OTP sends should use a standard server-side
sdo_live_key with no agent attribution. - Do not trust Senddio delivery as proof of receipt. A
message.deliveredwebhook means the carrier accepted the message, not that the user received it. OTP verification is the proof. Do not let a delivery event substitute for the user submitting the correct code.
Summary
- The agent never sees the OTP code. It only handles session IDs, contact info, and the user's submitted code.
- Code generation, storage (as a digest), and delivery all happen server-side using a
sdo_live_key. - Senddio supports OTP delivery via SMS, email, and WhatsApp — the send structure is identical, only the channel and address fields change.
- Tag OTP sends with
tag: "otp"to filter them in logs and webhooks. - Enforce per-contact and per-IP rate limits on your OTP endpoint separately from Senddio's plan limits.
- Handle
message.failedwebhooks to offer channel fallback when delivery fails.