Enviar mensagens
Existe um endpoint de envio: POST /messages. O que muda entre um texto e um
template é o campo type no corpo — não a URL.
type | Para quê |
|---|---|
text | Texto livre. Só dentro da janela de 24 horas |
template | Template aprovado. Abre conversa a qualquer momento |
test | Igual a template, com o hello_world por padrão e erros traduzidos |
Todos aceitam os mesmos campos base:
| Campo | Obrigatório | O que faz |
|---|---|---|
type | sim | text, template ou test. |
to | sim | Número do destinatário em formato internacional, só dígitos. |
from | não | phone_number_id de origem. Omitido, usa o número ativo mais antigo. |
contextMessageId | não | wamid de uma mensagem recebida — sua mensagem aparece citando ela. |
sendAt | não | Instante ISO 8601 para enviar depois. Ver Agendar. |
delaySeconds | não | Mesma coisa, em segundos a partir de agora. |
A resposta 201 traz o WAMID, o identificador que casa a mensagem com os eventos de
status que chegam depois por webhook.
{
"data": {
"messages": [{ "id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF" }],
"from": "106540352242922",
"to": "5541987111527"
}
}
Só templates funcionam fora das 24 horas. Veja A janela de 24 horas.
Texto
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Seu pedido #1042 saiu para entrega.",
"previewUrl": true
}'
Até 4096 caracteres. previewUrl: true gera o cartão de pré-visualização do primeiro
link — a Meta precisa conseguir acessar a URL publicamente para montar o cartão.
Template
A única forma de escrever fora da janela de 24 horas.
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "template",
"to": "5541987111527",
"name": "confirmacao_pedido",
"language": "pt_BR",
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "Maria" },
{ "type": "text", "text": "1042" }
]}
]
}'
A ordem dos parameters segue a numeração das variáveis ({{1}}, {{2}}). Template sem
variáveis dispensa components. Detalhes em Templates.
Teste
Para responder "meu número está conectado e envia?" sem montar nada:
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "type": "test", "to": "5541987111527" }'
O padrão é o hello_world, que sai sem variável.
hello_world só sai de número de testeDe um número de produção a Meta recusa com (#131058). É regra de origem do número —
token e payload não resolvem. Nesse caso, informe name, language e components de um
template aprovado seu; a API traduz esse erro (type: "test_number_only") dizendo
exatamente isso.
Agendar
Os mesmos envios aceitam sendAt (instante absoluto) ou delaySeconds (relativo). Os
dois existem porque os dois casos existem: "sexta às 9h" é uma data, "cinco minutos depois
do carrinho abandonado" é um atraso — e converter um no outro é onde entram os erros de
fuso horário.
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Seu carrinho ainda está separado 🙂",
"delaySeconds": 7200
}'
A resposta é 202, não 201 — aceitamos o pedido, mas nada foi criado na Meta ainda:
{
"data": {
"id": "clz3k1p9x0000",
"status": "pending",
"from": "106540352242922",
"to": "5541987111527",
"sendAt": "2026-08-15T21:00:00.000Z",
"messageId": null
}
}
Listar, consultar e cancelar:
curl https://api.joinotify.com/messages/scheduled?status=pending \
-H 'Authorization: Bearer sk_live_xxx'
curl -X DELETE https://api.joinotify.com/messages/scheduled/clz3k1p9x0000 \
-H 'Authorization: Bearer sk_live_xxx'
O cancelamento vale mesmo faltando segundos: quem entrega relê o registro antes de sair.
Uma mensagem já enviada responde 404 — não há o que cancelar.
Três coisas, e nenhuma delas no momento do agendamento: o direito de uso (uma
assinatura que caiu no meio do caminho impede o envio), o número (você pode ter
desconectado a conta) e o cancelamento. Um bloqueio por cobrança não é retentado:
a mensagem falha com o motivo em failureReason e fica no histórico.
Repetir sem duplicar
A conexão caiu depois que a Meta aceitou a mensagem, e a sua integração nunca viu a resposta. Repetir a chamada, sem mais nada, é uma segunda mensagem no WhatsApp da mesma pessoa — um código enviado duas vezes, uma promoção entregue em dobro.
O header Idempotency-Key resolve isso. Gere um valor por operação — o número do pedido
e o evento, por exemplo — e mande o mesmo valor em todas as tentativas:
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: pedido-1042-confirmacao' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Pedido 1042 confirmado ✅"
}'
| Situação | Resposta |
|---|---|
| Primeira chamada | A resposta normal |
| Mesma chave, mesmo corpo | A primeira resposta, com Idempotent-Replayed: true — nada é enviado |
| Mesma chave, corpo diferente | 422 idempotency_key_reused |
| Mesma chave, primeira ainda rodando | 409 idempotency_in_progress — espere alguns segundos e repita |
O que vale saber antes de confiar nisso:
- A chave tem de 8 a 128 caracteres: letras, dígitos,
_,-,.e:. - Ela vale por 24 horas e é da conta, não da API key: duas integrações da mesma conta que usem o mesmo valor se encontram.
- Toda resposta final fica guardada, inclusive os erros. Um
502porque a Meta não respondeu pode ter sido entregue mesmo assim, e a repetição devolve o mesmo502em vez de arriscar mandar de novo. Para tentar de novo de verdade, use uma chave nova — sabendo que pode duplicar. - O corpo é comparado como chegou: serialize o JSON sempre do mesmo jeito, ou a mesma mensagem com os campos em outra ordem vira "outro corpo".
- Vale também para agendamentos: repetir um
sendAtcom a mesma chave não cria um segundo agendamento.
Os outros tipos de mensagem
Imagem, vídeo, áudio, documento, figurinha, localização, contato, reação, botões, lista,
CTA e carrossel não têm atalho de alto nível — e não precisam ter. O espelho
POST /v1/{phone_number_id}/messages aceita o payload da Cloud API inteiro, com qualquer
tipo que a Meta suporte, usando o seu sk_live_:
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "image",
"image": { "link": "https://exemplo.com/catalogo.jpg", "caption": "Catálogo de julho" }
}'
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Podemos entregar amanhã entre 9h e 12h?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "confirma", "title": "Pode sim" } },
{ "type": "reply", "reply": { "id": "remarcar", "title": "Quero remarcar" } }
]}
}
}'
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF"
}'
O formato de cada tipo é o da documentação da Meta, sem tradução nossa no meio — é a vantagem do espelho: o que vale lá vale aqui.
Quando o mesmo arquivo vai para muita gente, suba a mídia uma vez com
POST /v1/{phone_number_id}/media e reutilize o id. Com link, a Meta baixa o arquivo
do seu servidor a cada envio. Veja Mídia.
Quando o cliente toca num botão, chega um webhook messages com type: "interactive":
{
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirma", "title": "Pode sim" }
}
}
Trate o id, não o title. O título é texto de interface e pode mudar; o id é o
contrato.
Acompanhando a entrega
Nenhum envio confirma a entrega — o 201 diz apenas que o WhatsApp aceitou a mensagem. O
que acontece depois chega por webhook messages, no array statuses:
status | Significado |
|---|---|
sent | Saiu da infraestrutura da Meta |
delivered | Chegou ao aparelho |
read | Foi aberta (se o cliente não desativou a confirmação) |
failed | Não foi entregue — o motivo vem em errors[] |
Guarde o WAMID do 201 e case com o id do status. Veja Webhooks.
Se você não quiser manter estado do seu lado — ou estiver depurando um envio isolado — consulte o último status conhecido de um WAMID:
curl https://api.joinotify.com/messages/status/wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF \
-H 'Authorization: Bearer sk_live_xxx'
{
"data": {
"messageId": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF",
"status": "failed",
"recipientId": "5541987111527",
"errors": [{ "code": 131047, "title": "Re-engagement message" }],
"updatedAt": "2026-07-29T01:51:43.030Z"
}
}
A resposta vem do mesmo espelho que alimenta Eventos recebidos — não custa
chamada na Meta nem cota de envio. status: null significa que nenhum status chegou ainda
(costuma levar segundos) ou que os webhooks da conta não estão sendo entregues.
Um texto livre fora da janela de 24 horas é aceito (você recebe 201 e um WAMID) e
reprovado alguns segundos depois, com o código 131047 no status failed. Se a sua
integração trata o 201 como sucesso final, ela vai jurar que enviou uma mensagem que
nunca chegou. Ver Janela de 24 horas.