Send sign-in and verification codes on WhatsApp with an authentication template: create it, send the code once, and handle every error.
An authentication template is a WhatsApp template in the AUTHENTICATION category. It sends a one-time code, such as a sign-in or verification code, with a copy code button the customer taps to copy it.
Meta writes the message, in the template's language. You do not write any text:
You supply one thing at send time: the code.
The public API cannot create templates. Create it once in the dashboard:
login_code, and pick the languageAn authentication template cannot have a header, its own body or footer text, named placeholders, or any button other than the one copy code button. Once created it is not edited in the dashboard, because Meta writes its text. One-tap and zero-tap code buttons are not supported.
The expiry also tells WhatsApp when to stop trying to deliver the code, capped at 15 minutes. With no expiry, Meta's default of 10 minutes applies.
The message templates guide covers review times and the Templates page.
GET /api/v2/templates lists authentication templates like any other, with "category": "AUTHENTICATION". There is no category filter on the endpoint, so filter the list yourself:
curl "https://v2.ghala.io/api/v2/templates?sendable=true&limit=100" \
-H "Authorization: Bearer $ACCESS_TOKEN"
An approved authentication template, once Ghala has synced it from Meta:
{
"id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
"name": "login_code",
"language": "en_US",
"category": "AUTHENTICATION",
"status": "APPROVED",
"sendable": true,
"unsendable_reason": null,
"components": [
{
"type": "BODY",
"text": "*{{1}}* is your verification code. For your security, do not share this code.",
"add_security_recommendation": true
},
{
"type": "FOOTER",
"text": "This code expires in 10 minutes.",
"code_expiration_minutes": 10
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"otp_type": "COPY_CODE",
"text": "Copy code",
"url": "https://www.whatsapp.com/otp/code/?otp_type=COPY_CODE&code=otp{{1}}"
}
]
}
],
"quality_score": "GREEN",
"approved_at": "2026-10-01T09:12:00Z",
"status_changed_at": "2026-10-01T09:12:00Z"
}
Before its first sync, the same template can show the shape it was created with: a body with no text and a button of "type": "OTP". Branch on category, not on the component shape.
POST /api/v2/messages with type: "template". Put the code once, as the body's one text parameter:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: login-7f3a9c-attempt-1" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "login_code",
"template_language": "en_US",
"template_components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "482913" }] }
]
}'
import os, requests
resp = requests.post(
"https://v2.ghala.io/api/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}",
"Idempotency-Key": "login-7f3a9c-attempt-1",
},
json={
"to": "255712345678",
"type": "template",
"template_name": "login_code",
"template_language": "en_US",
"template_components": [
{"type": "body", "parameters": [{"type": "text", "text": "482913"}]}
],
},
)
print(resp.json())
const resp = await fetch("https://v2.ghala.io/api/v2/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "login-7f3a9c-attempt-1",
},
body: JSON.stringify({
to: "255712345678",
type: "template",
template_name: "login_code",
template_language: "en_US",
template_components: [
{ type: "body", parameters: [{ type: "text", text: "482913" }] },
],
}),
});
console.log(await resp.json());
Meta needs the code twice, in the body and in the copy code button. Ghala fills the button from the body, so what goes to Meta is:
[
{ "type": "body", "parameters": [{ "type": "text", "text": "482913" }] },
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "482913" }]
}
]
You may send the button component yourself, as type button, sub_type url, index "0". It must then carry the same code as the body.
A code template is for one person at one moment. Send it from your server when the customer asks for a code, never from a campaign.
Ghala checks the code before anything reaches WhatsApp:
"text": "482913", not "text": 482913. A string keeps a leading zero; a number is refused.https://, http:// or www., and nothing that reads as a domain, such as example.com.Each refusal is 422 invalid_one_time_code, and its message says which rule failed.
A send returns 200 with the recorded message. The code is not in it:
{
"id": "01M3W01AMD5SAJNXR9MTB4QHV0",
"direction": "OUTBOUND",
"message_type": "template",
"content": "Verification code sent",
"status": "SENT",
"source": "HUMAN",
"wa_message_id": "wamid.bff38cd60dea4478a405dd4a81dcc36b",
"media_url": null,
"media_mime_type": null,
"media_filename": null,
"media_duration_ms": null,
"interactive": null,
"referral": null,
"sent_at": "2026-10-01T15:06:28.362866Z",
"delivered_at": null,
"read_at": null,
"played_at": null,
"failed_at": null,
"failure_reason": null,
"template_name": "login_code",
"redacted": true,
"created_at": "2026-10-01T15:06:28.365433Z"
}
redacted: true marks a message whose code was not kept. Delivery is asynchronous: the message.status event reports delivered, read, or failed.
Ghala sends the code to WhatsApp and keeps no copy of it:
message.sent event, logs, error reports, and an error message returned to you, it reads [code withheld].Keep the code on your side if you need to check what the customer types back. Ghala cannot tell you which code it sent.
Send an Idempotency-Key on every code send. A timeout must not become a second code on the customer's phone.
Idempotency-Replayed: true. Nothing is sent again.422 idempotency_key_reused. A new code is a new request, so give it a new key.Derive the key from the attempt, for example the sign-in attempt id, so a retry after a crash reuses it.
Every error has the same body, a stable code and a message in plain words:
{
"code": "invalid_one_time_code",
"message": "the copy code button's code must match the body's code"
}
| Status | code |
When | Retry? |
|---|---|---|---|
400 |
invalid_template |
The template is not in that language (the message names the ones that exist), or it is in a state WhatsApp will not send: rejected, paused, disabled, archived, and others | No |
400 |
ambiguous_number |
The token is held by more than one connected number and X-Phone-Number-Id did not say which; the ids are in phone_number_ids |
No, add the header |
401 |
not_authenticated |
Missing, unknown, or rotated-away token | No |
402 |
plan_feature_locked |
The plan does not include API access | No |
402 |
plan_limit_reached |
The number is new to the workspace, and adding it would pass the plan's contact limit | No |
403 |
number_not_for_token |
X-Phone-Number-Id names a number this token does not hold |
No |
403 |
account_suspended |
The workspace is suspended | No |
409 |
idempotency_in_progress |
A request with this key is still running | Yes, after a short pause |
422 |
invalid_one_time_code |
The code breaks a rule above, the button's code differs from the body's, or a component other than the body and the button was sent | No |
422 |
template_not_found |
The template is not on the number's WhatsApp account, or WhatsApp could not be asked | No; sync or check the name |
422 |
idempotency_key_reused |
This key was used for a different request, including another code | No, use a new key |
422 |
validation_error |
The body is malformed, for example a missing to; detail lists the fields |
No |
429 |
rate_limited |
WhatsApp is rate-limiting this number | Yes, with backoff and the same key |
502 |
send_failed |
WhatsApp refused the message, or the number is not connected; the reason is in message |
Depends on the reason |
500 |
internal_error |
Something failed on Ghala's side | Yes, with the same key |
A template is the one message type allowed outside the 24-hour window, so a code send never gets 409 outside_messaging_window, even to a customer who has never written to you.
A template Ghala has not synced yet is looked up on WhatsApp before the send, so its code is recognised and withheld on the first send. If WhatsApp does not have it, or cannot be asked, the send is refused with template_not_found rather than forwarded.
Errors and Retries has the full catalogue and a retry loop.
Once Meta approves the template, open it on the Templates page and click Send test. Pick a contact, such as your own number, type a code, and send. It goes out exactly as an API send does, and is recorded without the code.
GET /api/v2/templatestemplate_language it was approved inIdempotency-Key per attempt, and a new key for a new code422 invalid_one_time_code and template_not_found handled as fixes, 429 and 5xx retried with the same key