Send OTP login codes over WhatsApp: a REST API example

Send one-time passcodes over WhatsApp from your own backend with an Authentication template, a test key and idempotent retries. Node example, error handling and what to build around it.

October 16, 2026 · 5 min read

A one-time passcode (OTP) is a small job with a few sharp edges: the message must arrive fast, the same code must never be sent twice by accident, and your app has to cope when WhatsApp refuses a send. This guide shows how to send sign-in codes over WhatsApp from your own backend with a REST API, using a test key first so nothing reaches a real phone while you build.

SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API; you keep your own WhatsApp Business account.

The short version

  1. Get an Authentication template approved (Meta's library has ready-made one-time-code templates).
  2. Build with a test key, which runs every check and sends nothing.
  3. Generate and store the code on your side, then send it with one POST /messages where the code is variable 1.
  4. Send an Idempotency-Key so a retry never sends a second message, and handle WhatsApp's refusals by error type.

1. An Authentication template

Messages you start outside a 24-hour conversation window must use a template Meta has approved (how the window works). For one-time passcodes, Meta has a dedicated Authentication category. It is for one-time codes only, so marketing copy doesn't belong in it (the category rules).

Two ways to get one:

  • Use Meta's template library: it includes one-time-code templates in 16 languages. Keep Meta's wording and they are approved without the usual review.
  • Create your own on the dashboard, or ask your assistant to draft and submit it.

Authentication messages are billed by Meta at its own rate, separate from your SendFromChat plan (WhatsApp API pricing).

2. A key you can't hurt yourself with

On the dashboard's Developers page, create a test key (sfc_test_…). It checks the plan, opt-outs, the template and the 24-hour window, then stops: nothing is sent, saved or counted. Build the whole flow with it, then swap in a live key (sfc_live_…) for production.

Keep keys on your server, never in browser or mobile-app code. If you build with Claude Code, you can ask it to set up SendFromChat in your project: it saves the key straight into .env.local or your host's secrets without the key appearing in the chat (connect your assistant).

3. Send the code

Generate the code on your server and store only what you need to check it later (for example a hash and an expiry time). Then send it as variable 1 of the Authentication template. SendFromChat fills in the copy-code button that Meta requires.

// lib/send-otp.js: runs on your server only
import crypto from "node:crypto";

export async function sendLoginCode(phone, loginAttemptId) {
  const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");

  // Save a hash of `code` and an expiry for this attempt before sending.
  await saveCodeHash(loginAttemptId, hash(code), Date.now() + 10 * 60 * 1000);

  const res = await fetch("https://sendfromchat.com/api/v1/messages", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SENDFROMCHAT_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `login-${loginAttemptId}`,
    },
    body: JSON.stringify({
      to: phone, // international format, e.g. +4915112345678
      template: { name: "login_code", language: "en_US", variables: [code] },
    }),
  });

  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error?.type}: ${body.error?.message}`);
  return body; // { id: "wamid…", status: "accepted", … }
}

saveCodeHash and hash stand in for your own storage. The ten-minute expiry is an example, not a SendFromChat setting; choose what suits your app.

With a test key the response is 200 with "status": "test". With a live key it is 202 and "status": "accepted", which means Meta took the message, not that the person has seen it.

4. Don't send the same code twice

Two details matter most:

  • The idempotency key comes from the login attempt, not from the HTTP attempt. login-<attemptId> stays the same on every retry, so a network blip or a job re-run returns the first response (with an Idempotent-Replayed: true header) instead of sending a second code. Reusing a key with a different body returns idempotency_mismatch, so a "resend code" button should create a new attempt id, and a new code.
  • Rate-limit requests in your own app. Every message you send counts toward your plan's monthly allowance, so cap how often one phone number or one IP can ask for a code.

5. Handle WhatsApp's refusals

Some errors are WhatsApp's rules, not bugs. Branch on error.type:

type What it means What to do
opted_out The person replied STOP Offer another sign-in method
template_not_approved The template isn't approved yet Check its status on the dashboard or GET /templates?status=approved
warmup_limit A new number hit today's cap (50, then 100, then 200 template sends a day for the first three days) Retry later, or fall back to another method
whatsapp_rejected Meta refused the send; the message carries Meta's reason Log it and show a generic "try again"
sending_paused Trial ended or plan limit reached Alert yourself, not the user
unauthorized Key missing, wrong or revoked Check your environment variables

The full list is in the REST API reference. Always keep a fallback (email code, authenticator app) so a user is never locked out because a WhatsApp send failed.

6. Know what happened next

For delivery status, add a webhook endpoint on the Developers page and subscribe to message.status (sent, delivered, read, or failed with a reason). Each request is signed with a SendFromChat-Signature header: verify it over the raw body before trusting the payload, and skip event ids you have already handled, because deliveries can repeat. The REST API reference has the verification function, and our order notifications guide shows the same pattern end to end.

Things to keep in mind

  • Only message people who gave you their number for this purpose. A user typing their number into your sign-in form is the signal; keep a record, and read our opt-in guide.
  • Treat the code like a password. Store a hash, expire it, limit attempts, and never log the plain code.
  • Costs are two separate things: SendFromChat's flat monthly plan (from $19, 30-day free trial, no card) and Meta's per-message charge, billed to your own WhatsApp Business account at Meta's published rates with no markup from us.

A note for EU and UK customers

SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide; our GDPR guide covers consent, opt-out and records on your side. This is not legal advice.

Try it

Start the 30-day free trial (no card), connect a number, create a test key and send yourself a code. Comparing routes? See SendFromChat vs Twilio and using Meta's Cloud API directly, which also covers the OTP-only case.

Do this from your AI assistant

SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.

Start free