LunaDocumentaçãoEntrar

Templates

Template é uma mensagem pré-aprovada pela Meta. É o que permite iniciar conversa fora da janela de 24 horas. Dentro da janela, texto livre basta.

Quando você precisa de um template

  • O contato nunca escreveu para o seu número, ou a última mensagem dele tem mais de 24 horas: só um template sai. Texto livre é recusado com 409.
  • Enviar um template reabre a conversa. Depois que ele é entregue, a mensagem livre volta a ser aceita.
  • Para saber se a janela está aberta antes de tentar, leia GET /v1/conversations. Veja Enviar mensagens.

Crie um template

POST /v1/templates cria o template na conta de WhatsApp Business do cliente e o submete à Meta para aprovação. Escopo templates:write. O wabaId é o identificador da conta na Meta, e vem de GET /v1/business-accounts (escopo numbers:read):

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())
Resposta 201
{
  "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"
}
CampoRegra
nameMinúsculas, números e sublinhado: ^[a-z0-9_]{1,512}$. Não muda depois.
languageNa grafia da Meta, com sublinhado e não hífen: pt_BR, pt_PT, en_US, es_MX, fr. Não muda depois.
categoryMARKETING ou UTILITY. Ausente vale UTILITY. Para código de verificação existe uma rota própria (veja abaixo).
bodyTextAté 1024 caracteres. Aceita variáveis na forma {{nome_da_variavel}}, em minúsculas e sublinhado.
examplesUm valor de amostra por variável do corpo, obrigatório quando há variável. A Meta recusa parâmetro sem exemplo, e a recusa custa uma unidade do orçamento de gestão da conta.
header, footerTexto de até 60 caracteres. O rodapé não aceita variável. Para cabeçalho de imagem, vídeo ou documento, use headerMedia.
buttonsAté 10 no total, somando os tipos QUICK_REPLY, URL, PHONE_NUMBER e COPY_CODE. Se houver respostas rápidas junto de outros botões, os dois conjuntos precisam ficar agrupados.
  • A categoria que volta na resposta é a que a Meta atribuiu, e pode não ser a que você pediu, porque ela reclassifica na criação. A Luna guarda o que a Meta respondeu. É a categoria da criação: a Luna não a atualiza depois. Para a categoria corrente de um template antigo, consulte o painel da Meta.
  • Cada idioma é um template separado para a Meta. Dez templates em nove idiomas são noventa templates, e o teto é de 250 por conta de WhatsApp Business. O teto é da Meta e é o mesmo para todos os clientes.
  • Acima de três botões, apenas dois aparecem na mensagem entregue. A Meta aceita o template com mais e entrega dois, então decida o arranjo antes de desenhar o fluxo.

Acompanhe o estado

GET /v1/templates/{id} devolve o estado atual do template na Meta. Escopo templates:read:

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())
  • O status é texto livre, e não um conjunto fechado, de propósito: a Meta acrescenta valor novo sem aviso. Os comuns são APPROVED, PENDING, REJECTED, PAUSED e DISABLED.
  • A Luna não manda evento de webhook quando o estado de um template muda. Os quatro tipos de evento são de mensagem, de acumulado e de importação de histórico. Consulte o template, e não espere um evento.

GET /v1/templates lista os templates do cliente, do mais recente ao mais antigo. O status que volta na lista é o último que a Luna observou, e não uma leitura ao vivo na Meta. Use ?status= para filtrar. A comparação é literal e sensível a maiúsculas.

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())

Aprovado não é liberado

Um template APPROVED não está necessariamente liberado para sair. A Meta aplica um ritmo próprio de liberação sobre os primeiros envios de um template recém-aprovado. Ela chama isso de template pacing, e o define como um processo que dá tempo aos usuários do WhatsApp de reagirem à mensagem: se as primeiras entregas forem mal recebidas, o template é pausado antes de alcançar muita gente.

A Meta não publica prazo nem mecânica, e a Luna não inventa nenhum. Não existe campo nesta API dizendo quando o ritmo termina, e não existe estado dizendo que ele está em curso, porque essa informação não é emitida por quem a controla. Procurar um aqui é procurar o que não pode existir.
  • Escalone o primeiro disparo em vez de programá-lo para uma data.
  • Comece por um volume pequeno e cresça conforme as entregas confirmarem.
  • Acompanhe o estado do template: ele vai para PAUSED se a reação for negativa. Você pode filtrar a lista com ?status=PAUSED.

Cabeçalho de mídia

Para um cabeçalho de imagem, vídeo ou documento, são dois passos. Primeiro suba o arquivo de exemplo em POST /v1/template-assets, em multipart/form-data, no campo file (escopo templates:write):

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())
Resposta 200
{
  "handle": "4:EXAMPLE_HANDLE_NOT_REAL:ARb0Xk:e:1790451084:0"
}

Depois use o handle em headerMedia, com o format IMAGE, VIDEO ou 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())
  • O `handle` não é o identificador de mídia que POST /v1/numbers/{id}/media devolve. São dois endpoints da Meta com propósitos diferentes, e um identificador de mídia no lugar do handle faz a criação ser recusada. O identificador de mídia é um número decimal longo, e o handle não é numérico.
  • Os tipos aceitos no upload são application/pdf, image/jpeg, image/jpg, image/png e video/mp4. Para documento a Meta aceita apenas PDF. Nem GIF nem localização são aceitos.
  • O conteúdo do arquivo é conferido contra o tipo declarado antes de qualquer ida à Meta. Um arquivo declarado como image/png cujos bytes são de outra coisa é recusado com 400. Acima do teto, 413.
  • A Meta não publica por quanto tempo o handle vale, e a Luna não guarda o valor. Use-o na criação do template logo em seguida, e suba de novo se ele deixar de servir.
  • header (texto) e headerMedia são exclusivos: a Meta aceita um só cabeçalho por template.
O arquivo do `headerMedia` é o exemplo que a Meta revisa, e não o conteúdo entregue. Cada mensagem que você enviar com o template fornece a própria mídia, no componente de cabeçalho do envio. Quem cria o template achando que a imagem do exemplo é a imagem enviada manda mensagem sem cabeçalho.

Envie o template

Com o template APPROVED, envie por POST /v1/messages com type: "template". O language.code precisa bater com o idioma aprovado, e os components preenchem as variáveis. Para variável nomeada, 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())

Com cabeçalho de mídia, a mídia vai no componente header de cada envio. O parameters aceita os valores na forma que a Cloud API define, e aqui a imagem vai por 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())

O handle usado na criação não vira o conteúdo entregue, e não há nada a reaproveitar dele no envio. Para escolher entre link e identificador de mídia, vale a recomendação de Enviar mensagens.

Editar e remover

PATCH /v1/templates/{id} altera os componentes de um template existente. A atualização é parcial: o que você não informar permanece como está. Por isso o verbo é PATCH, e não 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())
  • Nome, idioma e conta de negócio não podem ser alterados. Para um nome diferente, crie outro template.
  • Se você informar qualquer componente, informe também bodyText: a Meta recusa template sem corpo.
  • A edição volta a submeter o template à aprovação, e o estado muda. Consulte o estado depois.

DELETE /v1/templates/{id} responde 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)
A Meta remove template pelo NOME, e isso apaga todas as versões de idioma daquele nome. Se você mantém o mesmo template em três idiomas, esta chamada apaga os três de uma vez do lado da Meta. A Luna remove da sua listagem só o que você pediu, e as outras versões de idioma continuam aparecendo aqui e já não existem lá. A remoção é definitiva, e criar outro com o mesmo nome recomeça a aprovação do zero. Remover duas vezes é seguro: a segunda chamada devolve 404.

Template de autenticação

Código de verificação tem uma rota de criação própria, POST /v1/templates/authentication (escopo templates:write), e não é uma categoria a mais em POST /v1/templates. O conteúdo não é seu: o corpo é fixo pela Meta, com o texto "{{1}} is your verification code." traduzido para o idioma do template. Por isso a rota não recebe corpo, exemplos, cabeçalho, rodapé nem botões.

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())
  • Você escolhe o name, o language, se o aviso de segurança aparece (addSecurityRecommendation), em quantos minutos o código expira (codeExpirationMinutes, obrigatório) e, se quiser, o buttonText e o messageSendTtlSeconds.
  • Omitir buttonText é o recomendado: sem ele a Meta usa o rótulo padrão do idioma, que já vem traduzido.
  • No envio, o código de uso único vai duas vezes no corpo da mensagem: uma no componente de corpo e outra no componente de botão. É o formato, e não redundância a ser otimizada. A senha aceita no máximo 15 caracteres.
Uma lacuna que preferimos declarar a preencher com chute: a forma exata do componente de botão no envio ainda não está publicada. A documentação da Meta tem duas páginas em desacordo sobre ela, e ninguém na Helsen executou um envio para decidir. Ela será publicada quando houver essa execução.
O contrato completo de cada rota está na referência da API, na seção Templates.