Templates
A template is a message pre-approved by Meta. It is what lets you start a conversation outside the 24-hour window. Inside the window, free-form text is enough.
When you need a template
- The contact never wrote to your number, or their last message is more than 24 hours old: only a template goes out. Free-form text is refused with
409. - Sending a template reopens the conversation. Once it is delivered, free-form messages are accepted again.
- To know whether the window is open before trying, read
GET /v1/conversations. See Send messages.
Create a template
POST /v1/templates creates the template in the customer’s WhatsApp Business account and submits it to Meta for approval. templates:write scope. The wabaId is the account’s identifier at Meta, and comes from GET /v1/business-accounts (numbers:read scope):
curl -X GET "https://api.lunahia.com.br/v1/business-accounts" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/business-accounts', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/business-accounts",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())curl -X POST "https://api.lunahia.com.br/v1/templates" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"wabaId": "YOUR_WABA_ID",
"name": "confirmacao_de_pedido",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos preparando.",
"examples": {
"nome": "Ana"
},
"footer": "Obrigado por comprar com a gente.",
"buttons": [
{
"type": "QUICK_REPLY",
"text": "Falar com atendente"
}
]
}'const res = await fetch('https://api.lunahia.com.br/v1/templates', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"wabaId": "YOUR_WABA_ID",
"name": "confirmacao_de_pedido",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos preparando.",
"examples": {
"nome": "Ana"
},
"footer": "Obrigado por comprar com a gente.",
"buttons": [
{
"type": "QUICK_REPLY",
"text": "Falar com atendente"
}
]
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/templates",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"wabaId": "YOUR_WABA_ID",
"name": "confirmacao_de_pedido",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos preparando.",
"examples": {
"nome": "Ana",
},
"footer": "Obrigado por comprar com a gente.",
"buttons": [
{
"type": "QUICK_REPLY",
"text": "Falar com atendente",
},
],
},
timeout=10,
)
print(res.status_code, res.json())201 response{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"metaTemplateId": "1259544702043801",
"name": "confirmacao_de_pedido",
"language": "pt_BR",
"category": "UTILITY",
"status": "PENDING",
"createdAt": "2026-10-06T14:00:00.000Z",
"updatedAt": "2026-10-06T14:00:00.000Z"
}| Field | Rule |
|---|---|
name | Lowercase letters, digits and underscore: ^[a-z0-9_]{1,512}$. It does not change afterwards. |
language | In Meta’s spelling, with an underscore and not a hyphen: pt_BR, pt_PT, en_US, es_MX, fr. It does not change afterwards. |
category | MARKETING or UTILITY. Absent means UTILITY. For verification codes there is a separate route (see below). |
bodyText | Up to 1024 characters. Accepts variables in the form {{variable_name}}, in lowercase and underscore. |
examples | One sample value per body variable, required when there is a variable. Meta refuses a parameter with no example, and the refusal costs one unit of the account’s management budget. |
header, footer | Text of up to 60 characters. The footer does not accept a variable. For an image, video or document header, use headerMedia. |
buttons | Up to 10 in total, adding up the QUICK_REPLY, URL, PHONE_NUMBER and COPY_CODE types. If quick replies sit next to other buttons, the two sets must be grouped. |
- The category returned in the response is the one Meta assigned, and may not be the one you asked for, because it reclassifies at creation. Luna stores what Meta answered. It is the creation-time category: Luna does not update it afterwards. For the current category of an older template, check Meta’s panel.
- Each language is a separate template for Meta. Ten templates in nine languages are ninety templates, and the ceiling is 250 per WhatsApp Business account. The ceiling is Meta’s and is the same for every customer.
- Above three buttons, only two appear in the delivered message. Meta accepts the template with more and delivers two, so decide the arrangement before designing the flow.
Follow the status
GET /v1/templates/{id} returns the template’s current status at Meta. templates:read scope:
curl -X GET "https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())- The
statusis free text, not a closed set, on purpose: Meta adds new values without notice. The common ones areAPPROVED,PENDING,REJECTED,PAUSEDandDISABLED. - Luna does not send a webhook event when a template’s status changes. The four event types are about messages, backlog and history import. Check the template, and do not wait for an event.
GET /v1/templates lists the customer’s templates, newest first. The status in the list is the last one Luna observed, not a live read at Meta. Use ?status= to filter. The comparison is literal and case-sensitive.
curl -X GET "https://api.lunahia.com.br/v1/templates?status=APPROVED" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/templates?status=APPROVED', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/templates?status=APPROVED",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())Approved is not cleared
An APPROVED template is not necessarily cleared to go out. Meta applies a pace of its own to the first sends of a newly approved template. It calls this template pacing, and defines it as a process that gives WhatsApp users time to react to the message: if the first deliveries are poorly received, the template is paused before reaching many people.
- Ramp up the first send instead of scheduling it for a date.
- Start with a small volume and grow as deliveries confirm.
- Watch the template’s status: it goes to
PAUSEDif the reaction is negative. You can filter the list with?status=PAUSED.
Media header
For an image, video or document header, there are two steps. First upload the example file at POST /v1/template-assets, as multipart/form-data, in the file field (templates:write scope):
curl -X POST "https://api.lunahia.com.br/v1/template-assets" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-F "file=@./exemplo.png;type=image/png"import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append(
'file',
new Blob([await readFile('./exemplo.png')], { type: 'image/png' }),
'exemplo.png',
);
const res = await fetch('https://api.lunahia.com.br/v1/template-assets', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
body: form,
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/template-assets",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
files={"file": ("exemplo.png", open("exemplo.png", "rb"), "image/png")},
timeout=10,
)
print(res.status_code, res.json())200 response{
"handle": "4:EXAMPLE_HANDLE_NOT_REAL:ARb0Xk:e:1790451084:0"
}Then use the handle in headerMedia, with the format IMAGE, VIDEO or DOCUMENT:
curl -X POST "https://api.lunahia.com.br/v1/templates" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"wabaId": "YOUR_WABA_ID",
"name": "pedido_a_caminho",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, o seu pedido saiu para entrega.",
"examples": {
"nome": "Ana"
},
"headerMedia": {
"format": "IMAGE",
"handle": "YOUR_HANDLE_FROM_TEMPLATE_ASSETS"
}
}'const res = await fetch('https://api.lunahia.com.br/v1/templates', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"wabaId": "YOUR_WABA_ID",
"name": "pedido_a_caminho",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, o seu pedido saiu para entrega.",
"examples": {
"nome": "Ana"
},
"headerMedia": {
"format": "IMAGE",
"handle": "YOUR_HANDLE_FROM_TEMPLATE_ASSETS"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/templates",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"wabaId": "YOUR_WABA_ID",
"name": "pedido_a_caminho",
"language": "pt_BR",
"category": "UTILITY",
"bodyText": "Olá {{nome}}, o seu pedido saiu para entrega.",
"examples": {
"nome": "Ana",
},
"headerMedia": {
"format": "IMAGE",
"handle": "YOUR_HANDLE_FROM_TEMPLATE_ASSETS",
},
},
timeout=10,
)
print(res.status_code, res.json())- The `handle` is not the media identifier that
POST /v1/numbers/{id}/mediareturns. They are two Meta endpoints with different purposes, and a media identifier in place of thehandlemakes the creation get refused. The media identifier is a long decimal number, and thehandleis not numeric. - The types accepted in the upload are
application/pdf,image/jpeg,image/jpg,image/pngandvideo/mp4. For a document Meta accepts only PDF. Neither GIF nor location is accepted. - The file’s content is checked against the declared type before any call to Meta. A file declared as
image/pngwhose bytes are something else is refused with400. Above the ceiling,413. - Meta does not publish how long the
handlestays valid, and Luna does not store the value. Use it in the template creation right after, and upload again if it stops working. header(text) andheaderMediaare mutually exclusive: Meta accepts only one header per template.
Send the template
With the template APPROVED, send it through POST /v1/messages with type: "template". The language.code must match the approved language, and the components fill in the variables. For a named variable, use parameter_name:
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}'const res = await fetch('https://api.lunahia.com.br/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR",
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana",
},
],
},
],
},
},
timeout=10,
)
print(res.status_code, res.json())With a media header, the media goes in the header component of each send. parameters accepts the values in the form the Cloud API defines, and here the image goes by link:
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "pedido_a_caminho",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "header",
"parameters": [
{
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg"
}
}
]
},
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}'const res = await fetch('https://api.lunahia.com.br/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "pedido_a_caminho",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "header",
"parameters": [
{
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg"
}
}
]
},
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "pedido_a_caminho",
"language": {
"code": "pt_BR",
},
"components": [
{
"type": "header",
"parameters": [
{
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg",
},
},
],
},
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana",
},
],
},
],
},
},
timeout=10,
)
print(res.status_code, res.json())The handle used at creation does not become the delivered content, and there is nothing to reuse from it in the send. To choose between link and a media identifier, the recommendation in Send messages applies.
Edit and remove
PATCH /v1/templates/{id} changes the components of an existing template. The update is partial: whatever you do not send stays as it is. That is why the verb is PATCH, not PUT.
curl -X PATCH "https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos separando os itens.",
"examples": {
"nome": "Ana"
}
}'const res = await fetch('https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos separando os itens.",
"examples": {
"nome": "Ana"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.patch(
"https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"bodyText": "Olá {{nome}}, recebemos o seu pedido e já estamos separando os itens.",
"examples": {
"nome": "Ana",
},
},
timeout=10,
)
print(res.status_code, res.json())- Name, language and business account cannot be changed. For a different name, create another template.
- If you send any component, send
bodyTextas well: Meta refuses a template with no body. - The edit submits the template for approval again, and the status changes. Check the status afterwards.
DELETE /v1/templates/{id} answers 204.
curl -X DELETE "https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status);import os
import requests
res = requests.delete(
"https://api.lunahia.com.br/v1/templates/YOUR_TEMPLATE_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code)404.Authentication template
A verification code has a creation route of its own, POST /v1/templates/authentication (templates:write scope), and is not one more category in POST /v1/templates. The content is not yours: the body is fixed by Meta, with the text "{{1}} is your verification code." translated into the template’s language. That is why the route takes no body, examples, header, footer or buttons.
curl -X POST "https://api.lunahia.com.br/v1/templates/authentication" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"wabaId": "YOUR_WABA_ID",
"name": "codigo_de_verificacao",
"language": "pt_BR",
"addSecurityRecommendation": true,
"codeExpirationMinutes": 10
}'const res = await fetch('https://api.lunahia.com.br/v1/templates/authentication', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"wabaId": "YOUR_WABA_ID",
"name": "codigo_de_verificacao",
"language": "pt_BR",
"addSecurityRecommendation": true,
"codeExpirationMinutes": 10
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/templates/authentication",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"wabaId": "YOUR_WABA_ID",
"name": "codigo_de_verificacao",
"language": "pt_BR",
"addSecurityRecommendation": True,
"codeExpirationMinutes": 10,
},
timeout=10,
)
print(res.status_code, res.json())- You choose the
name, thelanguage, whether the security notice shows (addSecurityRecommendation), in how many minutes the code expires (codeExpirationMinutes, required) and, if you like, thebuttonTextand themessageSendTtlSeconds. - Omitting
buttonTextis recommended: without it Meta uses the language’s default label, already translated. - On sending, the one-time code goes twice in the message body: once in the body component and once in the button component. That is the format, not redundancy to optimize. The password accepts at most 15 characters.