Skip to main content
AI7 min read

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.

S
Senddio Team
Engineering · September 22, 2026

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

  1. User provides their phone number or email in the agent interface.
  2. Agent calls your POST /auth/otp/send endpoint with the contact info. The agent does not call Senddio directly.
  3. 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.
  4. Senddio delivers the message. Your server returns a session token to the agent (not the code).
  5. User submits the code back through the agent interface.
  6. Agent calls your POST /auth/otp/verify endpoint with the session token and the submitted code.
  7. 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
Email Lower-stakes verification; fallback when SMS unavailable Slower delivery; user needs email access during the session
WhatsApp 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_otp tool accepts the code as a parameter, the code is in the agent's context. The correct interface is send_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.delivered webhook 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.failed webhooks to offer channel fallback when delivery fails.
Published
September 22, 2026
Share on XAll posts

Try Senddio free.

One API, six channels. 300 emails per day on the free plan — no credit card.

Read the API docsStart free