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/messageHeaders
| Header | Value |
|---|---|
Authorization | Basic YOUR_API_KEY |
Content-Type | application/json |
The value is your raw
sk_live_…key — Msgai accepts it as-is, you do not need to base64-encode it. TheBasicprefix 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:
- WABA configured. Connect your WhatsApp Business Account in Settings → WhatsApp Setup. Without it the API returns
400 "WhatsApp Business Account not configured". - Template
APPROVEDby Meta and synced into your workspace. Check Templates — the row must showAPPROVED, notPENDING/REJECTED. Newly created templates can take a few minutes to a few hours for Meta review. - Plan supports Public API. Growth, Advanced, or Enterprise — not Starter. See Plan availability.
- 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:
languageCodeandtemplate_categoryare read from your stored template byname.buttonValues["0"]is auto-filled frombodyValues[0]for authentication templates — the OTP is always the same in both, so there’s no reason to repeat it. You only need to sendbuttonValuesif (rarely) you want the copy / one-tap value to differ from the message body.
| Field | Required | Notes |
|---|---|---|
phoneNumber | yes (one of) | Local digits — pair with countryCode (e.g. "+91") |
fullPhoneNumber | yes (one of) | Full E.164 form, e.g. "+919028883545" or "919028883545". Use this or phoneNumber+countryCode — not both |
type | yes | Must be "Template" (case-insensitive) |
template.name | yes | The authentication template code name (lowercase, no spaces) |
template.languageCode | optional | Resolved 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.bodyValues | yes | Single-element array — the OTP code as a string. Max 15 chars |
template.buttonValues["0"] | optional | Auto-filled from bodyValues[0] for authentication templates. Send it only if the button value must differ from the body OTP (rare) |
template.template_category | optional | Resolved 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 |
callbackData | optional | Up 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.
| Status | Body message | Cause |
|---|---|---|
| 400 | phoneNumber is required | Both phoneNumber and fullPhoneNumber are empty |
| 400 | type is required | Top-level type missing or empty |
| 400 | template object is required for type Template | type is Template but the template object wasn’t sent |
| 400 | template.name is required | template.name missing or empty |
| 400 | WhatsApp Business Account not configured | Connect a WABA in Settings → WhatsApp Setup |
| 400 | Please 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 |
| 400 | Message 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 |
| 401 | Missing API key… / Invalid API key. | Missing Authorization header, wrong scheme, key revoked, or key never existed |
| 402 | Please correct the following error - Insufficient balance… | Wallet empty — top up |
| 403 | An active subscription is required to use Public APIs. | Trial expired or plan inactive |
| 403 | Public APIs are not accessible on your current plan. Please upgrade. | You’re on Starter — upgrade to Growth or higher |
| 403 | Access 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 |
| 429 | Rate limit exceeded for this resource. Limit: N requests/minute. Error Code: 429 | Per-minute API rate limit hit. Back off, retry |
| 500 | Failed to send template | Transient 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]intobuttonValues["0"]when you don’t send one. Only passbuttonValuesyourself 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_categoryis 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.