Skip to Content
Public APIAuthentication Template

Send Authentication Template

Send a WhatsApp authentication template — typically a one-time password (OTP) flow with a copy-code or one-tap autofill button. Authentication templates have their own Meta-approved structure (no header, fixed body, special button) and a separate (lower) per-message price than utility / marketing.

These are the right pick for: login OTPs, signup verification, transaction confirmation codes, password resets.

Endpoint

POST https://api.msgai.in/api/v1/public/message

Headers

HeaderValue
AuthorizationBasic YOUR_API_KEY
Content-Typeapplication/json

The value is your raw sk_live_… key — Msgai accepts it as-is, you do not need to base64-encode it. The Basic prefix is required. See Authentication.

Before your first send

The endpoint silently returns errors that look generic if any of these are missing — verify all four:

  1. WABA configured. Connect your WhatsApp Business Account in Settings → WhatsApp Setup. Without it the API returns 400 "WhatsApp Business Account not configured".
  2. Template APPROVED by Meta and synced into your workspace. Check Templates — the row must show APPROVED, not PENDING / REJECTED. Newly created templates can take a few minutes to a few hours for Meta review.
  3. Plan supports Public API. Growth, Advanced, or Enterprise — not Starter. See Plan availability.
  4. Wallet has balance. Authentication-category sends are billed per message. Top up at Billing → Wallet if you see 402 "Insufficient balance".

Request body

{ "fullPhoneNumber": "+919028883545", "callbackData": "auth_attempt_42", "type": "Template", "template": { "name": "itk_auth_one_tap", "bodyValues": ["483920"] } }

languageCode, template_category, and buttonValues are all filled in for you:

  • languageCode and template_category are read from your stored template by name.
  • buttonValues["0"] is auto-filled from bodyValues[0] for authentication templates — the OTP is always the same in both, so there’s no reason to repeat it. You only need to send buttonValues if (rarely) you want the copy / one-tap value to differ from the message body.
FieldRequiredNotes
phoneNumberyes (one of)Local digits — pair with countryCode (e.g. "+91")
fullPhoneNumberyes (one of)Full E.164 form, e.g. "+919028883545" or "919028883545". Use this or phoneNumber+countryCode — not both
typeyesMust be "Template" (case-insensitive)
template.nameyesThe authentication template code name (lowercase, no spaces)
template.languageCodeoptionalResolved from your stored template by name when omitted. Pass it only if you need to override — must match the language the template was approved in (en, en_US, hi, pt_BR, etc.)
template.bodyValuesyesSingle-element array — the OTP code as a string. Max 15 chars
template.buttonValues["0"]optionalAuto-filled from bodyValues[0] for authentication templates. Send it only if the button value must differ from the body OTP (rare)
template.template_categoryoptionalResolved from your stored template by name when omitted. Pass "authentication" to lock the send — if Meta has silently re-categorised the template you’ll get an explicit error instead of a surprise marketing-priced send
callbackDataoptionalUp to 512 chars, echoed back in the delivery webhook so you can correlate sends to auth attempts

The structure of buttonValues is {"<button_index>": ["value", ...]}. For an authentication template the OTP button is always at index 0, and the value array has one entry — the OTP code itself.

You don’t construct the Meta envelope. The backend reads your stored template metadata, automatically picks sub_type: "url" (one-tap) vs "copy_code" based on how the template was approved, and builds the full Cloud-API payload for you. You only supply the values.

Examples

cURL

curl -X POST https://api.msgai.in/api/v1/public/message \ -H "Authorization: Basic YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fullPhoneNumber": "+919028883545", "callbackData": "login_attempt_42", "type": "Template", "template": { "name": "itk_auth_one_tap", "bodyValues": ["483920"] } }'

Node.js / TypeScript

async function sendLoginOtp(phone: string, otp: string) { const res = await fetch( "https://api.msgai.in/api/v1/public/message", { method: "POST", headers: { "Authorization": `Basic ${process.env.SPLASHIFY_API_KEY!}`, "Content-Type": "application/json", }, body: JSON.stringify({ fullPhoneNumber: phone, // "+919028883545" callbackData: `login:${phone}`, // echoed in webhooks type: "Template", template: { name: "itk_auth_one_tap", bodyValues: [otp], }, }), }, ); const data = await res.json(); if (!data.result) throw new Error(`${res.status}: ${data.message}`); return data.id; // Msgai message ID — correlate with delivery webhooks }

Python

import os, requests def send_login_otp(phone: str, otp: str) -> str: r = requests.post( "https://api.msgai.in/api/v1/public/message", headers={ "Authorization": f"Basic {os.environ['SPLASHIFY_API_KEY']}", "Content-Type": "application/json", }, json={ "fullPhoneNumber": phone, "callbackData": f"login:{phone}", "type": "Template", "template": { "name": "itk_auth_one_tap", "bodyValues": [otp], }, }, timeout=30, ) data = r.json() if not data.get("result"): raise RuntimeError(f"{r.status_code}: {data.get('message')}") return data["id"]

Successful response — 200 OK

{ "result": true, "message": "Message created successfully", "id": "b90f02bd-1316-49ce-b5d0-f6be14fce2b8" }

The id is a Msgai message UUID — store it to correlate with message.sent / message.delivered / message.read / message.failed webhooks (see Webhooks).

Error responses

The endpoint returns granular errors — read the message field, don’t just check the status code.

StatusBody messageCause
400phoneNumber is requiredBoth phoneNumber and fullPhoneNumber are empty
400type is requiredTop-level type missing or empty
400template object is required for type Templatetype is Template but the template object wasn’t sent
400template.name is requiredtemplate.name missing or empty
400WhatsApp Business Account not configuredConnect a WABA in Settings → WhatsApp Setup
400Please correct the following error - <Meta message>Meta rejected the send. Common causes: template not approved in that language, OTP > 15 chars, phone invalid, recipient not on WhatsApp
400Message not sent. Template category mismatch: Expected 'authentication', but received 'MARKETING'…You passed template_category: "authentication" but Meta has re-categorised the template. Re-approve a fresh auth template
401Missing API key… / Invalid API key.Missing Authorization header, wrong scheme, key revoked, or key never existed
402Please correct the following error - Insufficient balance…Wallet empty — top up
403An active subscription is required to use Public APIs.Trial expired or plan inactive
403Public APIs are not accessible on your current plan. Please upgrade.You’re on Starter — upgrade to Growth or higher
403Access denied — your IP address is not on this account's allowlist.IP allowlist enabled on your account, calling IP isn’t on it. See Allowed IPs
429Rate limit exceeded for this resource. Limit: N requests/minute. Error Code: 429Per-minute API rate limit hit. Back off, retry
500Failed to send templateTransient upstream issue. Retry with exponential backoff

Diagnosing a 502 from a calling proxy (Supabase Edge Function, Cloudflare Worker, etc.)

If the calling layer reports a generic 5xx (e.g. Supabase Auth says "Unexpected status code returned from hook: 502"), that error is from the proxy, not Msgai. The actual Msgai response is the body of the upstream call. Log it:

const res = await fetch("https://api.msgai.in/api/v1/public/message", { /* ... */ }); if (!res.ok) { const body = await res.text(); console.error("Msgai send failed", { status: res.status, body }); // body will contain { result: false, message: "..." } — that's the real error return new Response(body, { status: res.status }); }

The most common cause of opaque 502s through a proxy is using the wrong endpoint — e.g. POST /api/v25.0/{PHONE_NUMBER_ID}/messages with Bearer sk_live_…. That’s the partner API (pk_live_ keys only), not the public API. Workspace sk_live_… keys only authenticate on /api/v1/public/... with Authorization: Basic ….

Integration recipe: Supabase Auth send-sms-hook

A common login flow: Supabase Auth owns OTP generation, storage, and verification; Msgai just delivers the WhatsApp message. Wire it as a custom send-sms-hook Edge Function.

// supabase/functions/send-sms-hook/index.ts import { serve } from "https://deno.land/std/http/server.ts"; import { createHmac } from "node:crypto"; const HOOK_SECRET = Deno.env.get("SEND_SMS_HOOK_SECRET")!; const API_KEY = Deno.env.get("SPLASHIFY_API_KEY")!; const TEMPLATE = Deno.env.get("SPLASHIFY_TEMPLATE_NAME") ?? "otp"; // Optional — only set if you need to override the language stored on the template. const LANGUAGE = Deno.env.get("SPLASHIFY_TEMPLATE_LANGUAGE"); serve(async (req) => { // 1. Verify Supabase Auth's signed payload (standard webhook HMAC). // Skip this in dev only — never in production. const sig = req.headers.get("webhook-signature") ?? ""; const raw = await req.text(); const expected = createHmac("sha256", HOOK_SECRET).update(raw).digest("hex"); if (sig !== expected) { return new Response("invalid signature", { status: 401 }); } const { user, sms } = JSON.parse(raw) as { user: { id: string; phone: string }; sms: { otp: string; sms_type: string }; }; // 2. Send the WhatsApp auth template. const phone = user.phone.startsWith("+") ? user.phone : `+${user.phone}`; const splash = await fetch( "https://api.msgai.in/api/v1/public/message", { method: "POST", headers: { "Authorization": `Basic ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ fullPhoneNumber: phone, callbackData: `auth:${user.id}`, type: "Template", template: { name: TEMPLATE, ...(LANGUAGE ? { languageCode: LANGUAGE } : {}), bodyValues: [sms.otp], }, }), }, ); // 3. Surface the real error if the send fails — don't return generic 5xx. if (!splash.ok) { const body = await splash.text(); console.error("Msgai send failed", { status: splash.status, body }); return new Response(body, { status: splash.status }); } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); });

Set the secrets:

supabase secrets set \ SPLASHIFY_API_KEY=sk_live_your_rotated_key \ SPLASHIFY_TEMPLATE_NAME=otp \ SEND_SMS_HOOK_SECRET=$(openssl rand -hex 32) # SPLASHIFY_TEMPLATE_LANGUAGE is optional — set it only to override # the language stored on the template (e.g. for multi-language OTPs).

Then enable the hook in Supabase Dashboard → Authentication → Hooks → Send SMS hook with the URL of your deployed function and the same SEND_SMS_HOOK_SECRET.

You do not need SPLASHIFY_PHONE_NUMBER_ID. The Public API resolves the sending WABA from your workspace via the API key — there is no per-call phone-number-ID parameter on /api/v1/public/message.

On the client side, login is then just:

await supabase.auth.signInWithOtp({ phone: "+919028883545" }); // user types the 6 digits they read in WhatsApp: await supabase.auth.verifyOtp({ phone, token, type: "sms" });

Supabase generates, stores, and verifies the OTP; this hook only delivers it.

Pricing & best practice

  • Authentication templates are billed at the auth-conversation rate (lower than utility/marketing) when sent via Meta-approved authentication shapes.
  • The OTP is reused for the copy / one-tap button automatically. WhatsApp needs the same value in the body and the button; for authentication templates the backend copies bodyValues[0] into buttonValues["0"] when you don’t send one. Only pass buttonValues yourself if you have a deliberate reason for the two to differ.
  • Do not embed the OTP in the URL of any link button — Meta rejects auth templates with that pattern.
  • template_category is resolved from your stored template — pass "authentication" explicitly only if you want to lock the send so a silent re-categorisation by Meta surfaces as an explicit error instead of an accidental marketing-priced send.
  • Generate the OTP server-side (Supabase Auth, your own service, etc.), store its hash with a short TTL (typically 5–10 minutes), and verify on the way back. Don’t trust the client to echo it.
  • Rate-limit OTP requests per phone in your own layer (e.g. 1 per 60 seconds per number) — WhatsApp also rate-limits OTP pairs and will block repeated sends to the same recipient.