LunaDocsSign in

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"
}
FieldRule
nameLowercase letters, digits and underscore: ^[a-z0-9_]{1,512}$. It does not change afterwards.
languageIn Meta’s spelling, with an underscore and not a hyphen: pt_BR, pt_PT, en_US, es_MX, fr. It does not change afterwards.
categoryMARKETING or UTILITY. Absent means UTILITY. For verification codes there is a separate route (see below).
bodyTextUp to 1024 characters. Accepts variables in the form {{variable_name}}, in lowercase and underscore.
examplesOne 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, footerText of up to 60 characters. The footer does not accept a variable. For an image, video or document header, use headerMedia.
buttonsUp 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 status is free text, not a closed set, on purpose: Meta adds new values without notice. The common ones are APPROVED, PENDING, REJECTED, PAUSED and DISABLED.
  • 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.

Meta publishes no deadline and no mechanics, and Luna does not make any up. No field in this API says when the pace ends, and no state says it is under way, because that information is not issued by whoever controls it. Looking for one here is looking for something that cannot exist.
  • 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 PAUSED if 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}/media returns. They are two Meta endpoints with different purposes, and a media identifier in place of the handle makes the creation get refused. The media identifier is a long decimal number, and the handle is not numeric.
  • The types accepted in the upload are application/pdf, image/jpeg, image/jpg, image/png and video/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/png whose bytes are something else is refused with 400. Above the ceiling, 413.
  • Meta does not publish how long the handle stays 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) and headerMedia are mutually exclusive: Meta accepts only one header per template.
The `headerMedia` file is the example Meta reviews, not the delivered content. Each message you send with the template supplies its own media, in the send’s header component. Whoever creates the template thinking the example image is the one sent ends up sending a message with no header.

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 bodyText as 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)
Meta removes a template by NAME, and that deletes every language version of that name. If you keep the same template in three languages, this call deletes all three at once on Meta’s side. Luna removes from your listing only what you asked for, and the other language versions keep showing up here and no longer exist there. Removal is final, and creating another with the same name restarts approval from zero. Removing twice is safe: the second call returns 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, the language, whether the security notice shows (addSecurityRecommendation), in how many minutes the code expires (codeExpirationMinutes, required) and, if you like, the buttonText and the messageSendTtlSeconds.
  • Omitting buttonText is 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.
A gap we prefer to state rather than fill with a guess: the exact shape of the button component on sending is not published yet. Meta’s documentation has two pages that disagree about it, and nobody at Helsen has run a send to settle it. It will be published when that run exists.
Each route’s full contract is in the API reference, in the Templates section.