Mensagens
Identidade do contato, envio idempotente, estados do envio, leitura e janela de atendimento.
Identidade do contato
A Meta identifica cada pessoa pelo BSUID (business-scoped user id), que o hub expõe como userId. O BSUID vale dentro do portfólio de negócios do seu cliente final (o mesmo usuário tem outro BSUID em outro portfólio). Formato: código do país, ponto e até 128 alfanuméricos, por exemplo BR.13491208655302741918.
userIdé a chave primária do contato. Guarde e procure pelouserId; o telefone (waId, só dígitos) pode faltar, porque a Meta omite o telefone de quem usa nome de usuário.parentUserIdsó vem quando a conta do cliente tem parent BSUID;usernamesó quando o contato tem nome de usuário.- Identidade nos eventos:
{ userId, parentUserId, waId, username, name }, comnullno que a Meta não mandou. - Troca de identidade chega como
whatsapp.identity.updated(o contato trocou de número ou de BSUID). Usedata.previous.userIdpara achar o contato e gravedata.current. O hub já move a janela de atendimento para a identidade nova.
Todas as rotas desta seção estão sob /v1/connections/:connectionId. Conexão de outro app responde 404 CONNECTION_NOT_FOUND; enviar exige conexão active (senão 409 CONNECTION_NOT_ACTIVE).
As recusas destas rotas (mensagens, mídia, templates) seguem o formato { statusCode, error, code, message, ...detalhes }. Decida pelo code.
Enviar
POST /v1/connections/:connectionId/messages com o header Idempotency-Key (1 a 128 caracteres entre A-Z a-z 0-9 . _ : -, obrigatório; sem ele, 400 IDEMPOTENCY_KEY_REQUIRED). Responde 202 { "message": ... }: o envio entra numa fila e o resultado chega por evento.
Corpo estrito (campo extra é rejeitado), um formato por type:
type |
Campo | Conteúdo |
|---|---|---|
text |
text |
{ body, previewUrl? }, body até 4096 |
image, video |
image / video |
{ mediaId, caption? } (legenda até 1024) |
document |
document |
{ mediaId, caption?, filename? } |
audio |
audio |
{ mediaId, voice? }; voice: true (mensagem de voz) só com audio/ogg |
sticker |
sticker |
{ mediaId } |
location |
location |
{ latitude, longitude, name?, address? } |
contacts |
contacts |
lista de 1 a 257 contatos no formato da Meta em camelCase (name.formattedName, phones[], emails[], urls[], addresses[], org, birthday) |
interactive |
interactive |
objeto plano com kind, header?, body, footer? (até 60) e os campos do kind, sem o aninhamento da Meta (action, parameters): kind: "buttons" com buttons: [{ id, title }] (1 a 3, título até 20 e único; body até 1024; header de texto { type: "text", text } ou de mídia do hub { type: "image" | "video" | "document", mediaId, filename? }), kind: "list" com button (texto do botão, até 20) e sections: [{ title?, rows: [{ id, title, description? }] }] (até 10 seções e 10 linhas no total; body até 4096; só header de texto) ou kind: "cta_url" com displayText (até 20) e url https (body até 1024; só header de texto) |
reaction |
reaction |
{ wamid, emoji }; emoji: "" remove a reação. Reação não aceita replyTo |
template |
template |
{ templateId, parameters? } (id do hub) ou { name, language, parameters? } (ver Parâmetros de template) |
Todo tipo leva to: { userId?, phone? } (pelo menos um) e, fora reaction, replyTo? com o wamid da mensagem citada. Com os dois, o telefone vale. mediaId é sempre o id de uma mídia que você subiu pelo hub (Mídia), da mesma conexão.
{
"to": { "userId": "BR.13491208655302741918" },
"type": "text",
"text": { "body": "Seu pedido saiu para entrega." }
}
Idempotência. A Idempotency-Key é única por app. Repetir o pedido com a mesma chave e o mesmo corpo devolve 200 com o estado atual da mensagem, sem novo envio, mesmo que a conexão tenha caído depois. A mesma chave com corpo diferente responde 409 IDEMPOTENCY_KEY_REUSED. O telefone é normalizado antes da comparação (+5543999990000 e 5543999990000 são o mesmo pedido). A chave vale enquanto a mensagem existe no hub, ou seja, 30 dias; depois disso a mesma chave cria um envio novo.
Rota de marketing. Template MARKETING de uma conta que aceitou os termos do Marketing Messages API (connection.updated com meta.accountEvent: "MM_LITE_TERMS_SIGNED") sai pela rota marketing_messages da Meta; o resto sai por messages. A rota usada aparece em message.route. Sem os termos, o marketing sai pelo Cloud API comum até a Meta encerrar esse caminho (contas criadas a partir de 13/04/2027 não podem; todas param em 13/10/2027).
Recusas na hora do pedido (nada é enviado):
| Status | code |
Quando |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED |
header ausente ou fora do formato |
| 409 | IDEMPOTENCY_KEY_REUSED |
chave já usada com outro corpo |
| 404 | CONNECTION_NOT_FOUND |
conexão inexistente ou de outro app |
| 409 | CONNECTION_NOT_ACTIVE |
conexão não está active ou está sem credencial |
| 404 | MEDIA_NOT_FOUND |
mídia inexistente, de outra conexão ou ainda não pronta |
| 410 | MEDIA_EXPIRED |
a mídia venceu na Meta (29 dias depois do upload): suba de novo |
| 400 | MEDIA_KIND_MISMATCH |
a mídia não é da família que o tipo exige (por exemplo um PDF em image, ou voice fora de audio/ogg), inclusive no header de interativo e de template |
| 404 | TEMPLATE_NOT_FOUND |
template fora do cache da conta da conexão |
| 422 | TEMPLATE_NOT_APPROVED |
template não está APPROVED |
| 400 | TEMPLATE_PARAMETERS_MISMATCH |
parâmetros não batem com o template (traz component, nunca os valores) |
| 400 | TEMPLATE_COMPONENT_UNSUPPORTED |
o template tem um componente que o hub não preenche (traz component) |
| 400 | RECIPIENT_PHONE_REQUIRED |
template AUTHENTICATION sem to.phone (a Meta não aceita BSUID nele) |
| 422 | OUTSIDE_SERVICE_WINDOW |
mensagem livre (não template) para um contato com a janela de atendimento vencida |
| 400 | (validação) | corpo fora do schema |
Estados do envio
message (no 202, no 200 idempotente e nas leituras):
{
"id": "...",
"connectionId": "...",
"type": "text",
"state": "accepted",
"route": "messages",
"wamid": "wamid.HBgM...",
"pacing": null,
"errorCode": null,
"metaErrorCode": null,
"deliveryState": "delivered",
"deliveryStateAt": "2026-10-23T15:00:05.000Z",
"createdAt": "2026-10-23T15:00:00.000Z",
"updatedAt": "2026-10-23T15:00:05.000Z"
}
Nenhuma resposta nem evento traz o conteúdo ou o destinatário.
state |
Significado | O que fazer |
|---|---|---|
pending |
Na fila do hub (inclusive esperando a vazão do número ou um retry por limite da Meta) | Esperar |
sending |
O hub começou a submissão à Meta | Esperar |
accepted |
A Meta aceitou (wamid preenchido). A entrega segue em deliveryState |
Acompanhar whatsapp.message.status |
failed |
Recusado, pelo hub ou pela Meta, com errorCode (e metaErrorCode quando veio da Meta) |
Corrigir a causa antes de enviar de novo |
uncertain |
O hub não sabe se a Meta recebeu (timeout, queda de rede, 5xx, 131000/131016/133004) |
Não reenvie às cegas. Espere: se a Meta mandar um status com a referência do envio, a mensagem vira accepted sozinha. Se nada chegar, a equipe Matra encerra como abandoned |
abandoned |
Entrega desconhecida, encerrada pela equipe Matra | Reenviar é decisão humana (pode duplicar). Não é recusa da Meta |
pacing vem no accepted quando a Meta informa o ritmo: accepted, held_for_quality_assessment (a Meta segura a mensagem para avaliar qualidade) ou paused (entrega pausada).
deliveryState é o melhor status de entrega visto: sent < delivered < read < played. failed só aparece se nada melhor foi visto, e delivered/read/played substituem um failed anterior (entrega provada vale mais). failed em deliveryState não muda o state (a Meta aceitou a submissão e falhou depois).
uncertain e a conciliação. O hub manda o id da mensagem para a Meta em biz_opaque_callback_data, e o status da Meta o devolve. Quando o status chega para um envio uncertain (ou abandoned), a mensagem vira accepted e sai um whatsapp.message.status source: "hub" com meta: { "reconciled": true }. Se o envio já estava abandoned, o evento traz também "overridesAdminResolution": true: a evidência da Meta vale sobre a decisão da equipe Matra, e quem já reenviou pode ter uma duplicata. Um accepted com overridesAdminResolution: true vale sobre qualquer abandoned do mesmo messageId, qualquer que seja o createdAt dos dois: se a conciliação chega no instante em que o admin encerra o envio, o abandoned pode sair depois dele. Em testes com o app real, o campo voltou nos status de texto, interativo, reação, imagem, documento e template.
Status de um envio feito por outro workspace (o dono anterior de um número que mudou de workspace) não chega ao seu: o hub reconhece o biz_opaque_callback_data de outra conexão e, sem correspondência pelo wamid na sua, descarta o item (nenhum whatsapp.message.status, e a referência do outro envio não é guardada). O evento source: "hub" de uma transição sai logo depois dela; se essa emissão falhar, uma varredura emite o evento em até 1 h, com timestamp igual à hora da última alteração gravada do envio (em geral a da transição; um status da Meta que chegou no meio a adianta).
errorCode de envio recusado (trate valores desconhecidos como erro genérico):
errorCode |
Causa |
|---|---|
outside_service_window |
Meta 131047: fora da janela de 24 h, só template |
user_opted_out |
Meta 131050: o contato não quer receber marketing; não repita |
bsuid_not_supported |
Meta 131062: o tipo não aceita BSUID; envie por telefone |
marketing_disabled_on_cloud_api |
Meta 131063 |
template_not_found_at_meta |
Meta 132001; o hub ressincroniza os templates da conta |
template_paused |
Meta 132015 |
credential_invalid |
Meta 190/0: o cliente precisa reconectar |
permission_denied |
Meta 10/200/131005 |
payment_method_required |
Meta 131042: problema no método de pagamento da conta. Passado o nível gratuito, sem método válido a Meta não entrega mensagens de serviço; o cliente acerta o pagamento no Gerenciador do WhatsApp antes de enviar de novo |
rate_limited |
8 recusas seguidas por limite da Meta |
connection_inactive, credential_unavailable, template_not_approved, media_expired |
A situação mudou entre o pedido e o envio |
meta:<código> |
Outra recusa da Meta, com o código dela (meta:unknown sem código) |
resolved_by_admin |
Em abandoned |
Ler envios
| Método e rota | O que faz | Respostas |
|---|---|---|
GET …/messages |
Lista (paginada); filtros idempotencyKey e state |
200, 404 |
GET …/messages/:messageId |
Busca um envio | 200, 404 MESSAGE_NOT_FOUND |
Parâmetros de template
parameters tem header?, body? e buttons?:
header:{ type: "text", value },{ type: "image" | "video" | "document", mediaId, filename? }(mídia do hub) ou{ type: "location", latitude, longitude, name?, address? }. Tem de ser do formato do header do template.body: lista de{ name?, value }. Template posicional: osnvalores em ordem (ou todos comnamede1an). Template nomeado: todo valor comname, o mesmo conjunto do template, em qualquer ordem.buttons: lista de{ index, type, value }comtypequick_reply(payload),url(sufixo da URL dinâmica; o hub faz o encode) oucopy_code(cupom até 20). Em templateAUTHENTICATION, o código vai no botão de índice dele comtype: "url".
O hub recusa com TEMPLATE_COMPONENT_UNSUPPORTED o que não preenche: carrossel, oferta por tempo limitado, botões de Flow, catálogo e MPM, botão OTP fora do preset copy code, URL com variável fora do fim, e parâmetro currency ou date_time. A detecção de currency/date_time é pelo template em cache; se escapar, a Meta recusa no envio (failed com meta:<código>).
Confirmação de leitura
POST …/read-receipts com { wamid, typingIndicator? }: marca a mensagem recebida (e as anteriores) como lida; com typingIndicator: true mostra também "digitando". Síncrono, no limite de envio.
| Status | code |
Quando |
|---|---|---|
| 200 | { "success": true } |
|
| 400 | INVALID_MESSAGE_ID |
a Meta não reconhece o wamid (131009) |
| 422 | META_READ_REJECTED |
outra recusa da Meta, com metaErrorCode |
| 502 | META_UNAVAILABLE |
resposta incerta; repetir é seguro |
Janela de atendimento
GET …/windows?userId=<BSUID> ou ?phone=<dígitos> → { "window": { "open": true, "expiresAt": "...", "freeEntryPoint": null } }, ou { "window": null } sem registro. freeEntryPoint é { "expiresAt": "..." } enquanto vale uma janela de ponto de entrada gratuito (ver Custos); ela só muda o preço, não libera mensagem livre fora das 24 h. A janela abre ou estende por 24 h a cada mensagem recebida do contato (data.window do whatsapp.message.received traz o expiresAt); eco, histórico, mensagem de sistema e de grupo não contam.
Lacuna conhecida: a Meta também reabre a janela quando o contato liga para o número, e o hub não vê ligações. Para quem usa Calling, um 422 OUTSIDE_SERVICE_WINDOW pode recusar um envio que a Meta aceitaria. Sem registro de janela o hub não bloqueia: o envio segue e a Meta decide.