Envio de mensagem individual pela API: WhatsApp, Telegram e bots

Envio de mensagem pela API no WhatsApp e no Telegram para um único cliente: uma requisição, texto e anexo próprios, sem disparo. Para códigos e lembretes.

Envio de mensagem individual pela API do WSENDER no WhatsApp e no Telegram
Um evento no seu sistema e, poucos segundos depois, a mensagem no celular do cliente

Tem mensagem que não pode esperar o disparo do fim do dia. O cliente pagou o pedido. Marcou um horário. Pediu um código de confirmação enquanto está ao telefone com o atendente.

Essa mensagem vai pra uma pessoa só, vale por alguns minutos e nunca se repete. Montar um disparo pra ela não faz sentido: um público de um número só, um texto pra um único caso, tudo isso por causa de uma mensagem.

Agora existe um método próprio pra isso. Uma requisição HTTP envia uma mensagem a uma pessoa específica, com texto e anexo próprios. Aqui o assunto é só o envio pontual; os disparos em massa estão no artigo sobre a API de disparos.

Como fica o envio pela API

O esquema é simples: evento no seu sistema → requisição à API → mensagem no celular do cliente.

O método inteiro é uma requisição POST /v1/message, com três informações: de qual conta enviar, pra quem e o quê. A chave é a mesma dos disparos, criada na seção "API" do painel:

bash
curl -X POST https://api.wsender.io/v1/message \
  -H "Authorization: key SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "channelId": "ID_DA_CONTA",
    "phone": "+55 11 98765-4321",
    "message": { "kind": "text", "text": "Pagamento do pedido 84421 confirmado. Esperamos você amanhã às 12h." },
    "idempotencyKey": "order-84421-paid"
  }'

A resposta traz a mensagem com identificador e status próprios:

json
{
  "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
  "mid": null,
  "status": "Queued",
  "channelId": "ID_DA_CONTA",
  "target": "Phone",
  "phone": "5511987654321",
  "dialogId": null,
  "errorReason": null,
  "sendDate": null,
  "failDate": null,
  "wait": null,
  "expiresAt": "2026-09-07T21:15:04Z",
  "idempotencyKey": "order-84421-paid"
}

O identificador da conta vem da lista de contas conectadas, GET /v1/channel, e idempotencyKey é a sua própria chave da solicitação (explicamos mais abaixo). O campo dialogId é preenchido quando o serviço encontra a conversa com essa pessoa, e com ele dá pra responder depois na mesma conversa. Se você já falou com ela antes, o campo vem preenchido na hora, nessa mesma resposta. O mid aparece depois do envio: é o identificador da mensagem dentro da conversa. O wait explica o que está segurando a mensagem na fila, e o expiresAt diz até quando uma mensagem não enviada espera a vez: depois disso, ela termina com o motivo Expired.

A resposta à requisição chega na hora, mas quer dizer só "entrou na fila". A mensagem ainda não foi entregue. Quem envia é a sua conta conectada, uma conta de verdade rodando no aplicativo do mensageiro. Isso leva segundos e, pra um número desconhecido, até uns dois minutos: os mensageiros não gostam quando alguém verifica números instantaneamente. Com bot é mais rápido, porque ele envia direto pela Bot API, sem aplicativo.

Essa diferença importa pro atendente que está na linha. Ao telefone, o certo é dizer "estou te mandando a mensagem agora". Dizer "já chegou", nesse momento, ainda não é verdade.

Para quem enviar no WhatsApp, no Telegram e nos bots

O destinatário é definido de uma destas formas, e isso decide se você precisa do número ou não:

  • Número de telefone em qualquer formato comum. É o caso mais frequente: o número já está na ficha do cliente.
  • Nome de usuário no Telegram, se a sua conta já conhece essa pessoa de uma conversa anterior.
  • Conversa, pelo identificador da API ou pelo endereço da conversa no próprio mensageiro. A sua conta já precisa conhecer essa conversa: com um endereço desconhecido, a requisição é recusada, e o serviço não tenta enviar no escuro.

A requisição tem quatro campos pra isso, e só um deles deve ser preenchido. O serviço não tenta adivinhar pra quem você queria escrever:

text
"phone": "+55 11 98765-4321"
"username": "@lucas_souza"
"dialogId": "ID_DA_CONVERSA_NA_API"
"dialogMid": "123456789"

O último é o endereço da conversa no próprio mensageiro, o chat_id dos bots do Telegram. Ele é útil quando o seu sistema já guarda os endereços do mensageiro e não quer manter os nossos identificadores.

Nos bots, só funciona o endereçamento por conversa. O motivo não está no serviço: um inscrito do bot pode não ter número nem nome de usuário, mas a conversa sempre existe. E, de qualquer forma, o bot não pode puxar conversa primeiro: essa restrição é da plataforma.

O que dá pra enviar pela API

Texto, imagem, vídeo, áudio, documento ou cartão de contato. O texto aceita variações de frase entre colchetes: [Olá|Bom dia] vira uma das duas opções, pra que as mensagens não fiquem todas iguais.

Aqui tem uma armadilha que é melhor conhecer antes da primeira requisição: colchetes contam como spintax mesmo sem variações. O texto [Importante] seu código é 1234 chega ao cliente como Importante seu código é 1234, sem os colchetes. Se os seus modelos usam colchetes como enfeite, troque-os por outro símbolo.

O arquivo sobe uma vez, em uma requisição separada, e depois é passado pelo identificador em quantas mensagens você quiser. Uma tabela de preços enviada pra cem clientes ocupa o espaço de um arquivo só. Os limites são simples: até 10 MB por arquivo, e um arquivo que não foi usado fica guardado por um dia e depois é apagado automaticamente.

O upload é um formulário comum com o arquivo, e o campo que importa na resposta é o id:

bash
curl -X POST https://api.wsender.io/v1/file \
  -H "Authorization: key SEU_TOKEN" \
  -F "file=@tabela-precos-2026.pdf"

Se o arquivo já está em um link público, em vez do formulário, use o POST /v1/file/url com o campo url: o servidor baixa o arquivo sozinho. Depois o identificador entra na mensagem, e o tipo é definido pelo campo kind: text, image, video, audio, file ou contact. Imagem, vídeo e documento têm a legenda caption, e qualquer anexo tem o nome fileName, que o destinatário vai ver:

json
{
  "channelId": "ID_DA_CONTA",
  "phone": "+55 11 98765-4321",
  "message": {
    "kind": "file",
    "fileId": "ID_DO_ARQUIVO_ENVIADO",
    "fileName": "Tabela de preços de outubro",
    "caption": "Boa tarde! Segue a tabela de preços que combinamos. Os valores valem até o fim do mês."
  },
  "idempotencyKey": "price-request-1187"
}

O nome é opcional: sem ele, vale o nome do arquivo enviado, e a extensão é acrescentada automaticamente, não precisa informar. Imagem vai do mesmo jeito, com "kind": "image", e o cartão de contato dispensa arquivo: { "kind": "contact", "phone": "+55 11 90000-0000" }.

A única exceção: cartão de contato não pode ser enviado por bot: o serviço só envia esse cartão a partir de uma conta de mensageiro. A requisição é recusada na hora, em vez de se perder em silêncio no envio.

Como saber se a mensagem chegou

A resposta à requisição disse "aceito". A partir daí, o próprio serviço avisa como tudo terminou, com três eventos de webhook:

  • message.sent. A mensagem saiu para o mensageiro.
  • message.failed. Não deu pra enviar, e o evento traz o motivo: o destinatário bloqueou a conta, não é permitido escrever primeiro pra ele, a conta de envio foi desconectada. Ou o envio ficou alguns minutos esbarrando numa falha temporária do mensageiro e as tentativas acabaram: nesse caso, é só colocar a mensagem na fila de novo.
  • message.notfound. O destinatário não existe no mensageiro: nem esse número, nem essa conta. É um evento à parte, fora da lista de erros: não há nada pra consertar, mas vale marcar o contato na sua base.

A assinatura desses eventos é explícita. Informe o endereço do webhook e liste esses eventos no filtro dele: sem isso eles não chegam, mesmo que o webhook já esteja configurado pros eventos de disparo. Isso é proposital. Quem manda cem mensagens por dia ganha com o aviso de cada mensagem; quem faz disparos pra dezenas de milhares de destinatários não deveria receber esse fluxo por acidente.

O que vem no evento: identificador da mensagem, conta de envio, destinatário do jeito que você informou, status, motivo da falha e datas. E o principal: a sua própria chave, a mesma que você passou na requisição. Com ela, você encontra a solicitação no seu sistema e nem precisa guardar os nossos identificadores.

Este é o evento de falha quando o destinatário bloqueou a sua conta:

json
{
  "id": "7d1e0b58-3a44-4f2c-9c81-15b0f7a9d6e3",
  "type": "message.failed",
  "date": "2026-09-07T09:15:42Z",
  "payload": {
    "message": {
      "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
      "channelId": "ID_DA_CONTA",
      "phone": "5511987654321",
      "status": "Failed",
      "errorReason": "Blocked",
      "failDate": "2026-09-07T09:15:41Z",
      "idempotencyKey": "order-84421-paid"
    }
  }
}

O motivo indica o que fazer. Blocked e RestrictedSend: não dá pra escrever pra essa pessoa a partir dessa conta. ChannelDisconnected: a conta foi desconectada, conecte de novo e reenvie. Expired e Unavailable: o envio não aconteceu no prazo, e a mensagem pode voltar pra fila com uma chave nova.

Ao montar o tratamento dos eventos, leve em conta três coisas:

  1. O mesmo evento pode chegar mais de uma vez. Por exemplo, o seu servidor já recebeu, mas a resposta se perdeu no caminho. Confira o id do evento e não repita a ação no seu sistema.
  2. Um cancelamento feito por você não gera evento. Você mesmo cancelou a mensagem e recebeu a resposta dessa requisição: não tem por que avisar de novo sobre uma ação sua.
  3. O resultado pode ser conferido pela API. O serviço cuida sozinho das falhas temporárias de envio. Se a mensagem não for enviada no prazo, ela recebe o status Failed, e, se houver um webhook ativo, é criado um evento message.failed com o motivo. Se o receptor estiver fora do ar, o evento pode não chegar; o resultado atual continua disponível em GET /v1/message/{id}.

A entrega do webhook é repetida com intervalos cada vez maiores, mas cada evento fica guardado por no máximo um dia. Depois de um dia de erros sem nenhuma entrega bem-sucedida, o webhook é pausado e os novos eventos deixam de se acumular.

Confira o health na resposta de GET /v1/webhook, corrija o receptor e chame POST /v1/webhook/resume. Se a verificação passar, a entrega dos eventos que ainda estão guardados é retomada; os que se perderam durante a pausa e os vencidos não voltam.

Se não quiser lidar com webhooks, dá pra consultar o status da mensagem pelo identificador: GET /v1/message/{id} devolve a mesma estrutura da resposta ao envio, com o status atual: Queued, Sending, Sent, Failed ou NotFound. Mas, sendo sincero: logo depois da requisição o status vai ser "na fila", e uma consulta só não basta pra saber o resultado, vai ser preciso perguntar de novo. O método não tem um modo "espere até enviar", e isso é de propósito: deixar a sua requisição HTTP aberta por um minuto é pior do que mandar um evento. Por isso, aqui os webhooks são o caminho recomendado.

Resposta a mensagens recebidas pelo mesmo método

O método não serve só pra mensagens de saída. Quando chega uma mensagem nova do cliente, o serviço avisa com o evento message.received, que traz o texto, o remetente e a conversa. Aí você responde com a mesma requisição de envio, usando como destinatário a conversa do evento. Assim, com um único método dá pra montar uma troca nos dois sentidos: uma caixa de entrada própria, um chatbot ou uma integração com o CRM. Se não compensa construir uma caixa de entrada própria, o Dialogs, que abre a partir do painel, resolve pros atendentes: lá está a mesma conversa, e as respostas enviadas pela API aparecem com a marca "API".

A resposta pode sair citando uma mensagem específica, como no próprio mensageiro quando você responde a algo dito no meio da conversa. Pra isso, a requisição leva o identificador da mensagem citada: ele aparece tanto no evento de mensagem recebida quanto no histórico da conversa que a API devolve.

É o mesmo POST /v1/message, só que o destinatário é a conversa e entra o campo replyToMid:

json
{
  "channelId": "ID_DA_CONTA",
  "dialogId": "ID_DA_CONVERSA_DO_EVENTO",
  "replyToMid": "MID_DA_MENSAGEM_DO_CLIENTE",
  "message": { "kind": "text", "text": "Temos sim, em estoque. Quer que eu separe pra você até o fim do dia?" },
  "idempotencyKey": "reply-5c2e-1"
}

Os dois identificadores chegam no evento de mensagem recebida: a conversa em payload.dialog.id, a mensagem em payload.message.mid. Dá pra citar qualquer tipo de mensagem, desde que seja da mesma conversa. Uma mensagem de outra conversa o serviço recusa: a verificação acontece no momento da requisição, e nada se perde em silêncio no envio.

A mensagem original pode ser apagada depois da sua requisição. Nesse caso, a conta de mensageiro envia a mensagem normalmente, sem citação: não faz sentido perder o texto por causa da formatação. Com bots é diferente: a mensagem termina com erro, e o motivo deixa claro o que fazer, que é reenviar sem citação.

Uma requisição repetida não cria uma segunda mensagem

Um problema típico das mensagens transacionais: a rede cai bem na hora da resposta, o seu código repete a requisição e o cliente recebe duas mensagens iguais.

Passe na requisição a sua chave única, e uma repetição com a mesma chave devolve a mesma mensagem com o status atual, sem enviar outra. É a mecânica padrão de idempotência, e aqui ela funciona no nível da própria mensagem.

Enquanto a mensagem espera na fila, dá pra cancelar com uma requisição à parte: DELETE /v1/message/{id}. Depois que a conta de envio pegou a mensagem, é tarde: volta o código 409 com o status atual no corpo, e o resultado fica por conta do próprio envio. A mensagem cancelada passa a aparecer como Failed com o motivo Canceled; não existe um status separado de "cancelada".

O que o serviço não deixa fazer

Os limites, sem rodeios, pra você não planejar o que não vai funcionar.

  • A verificação de temas proibidos também vale aqui. A mensagem é verificada no momento da requisição; se for recusada, não entra na fila, e a resposta traz o motivo. O envio direto não serve pra driblar a moderação.
  • O limite diário do plano é o mesmo dos disparos. As mensagens diretas consomem esse limite do mesmo jeito: uma mensagem enviada conta como uma mensagem do plano, e o método não tem limite próprio. Também não existe cobrança extra por passar do limite: quando ele acaba, a requisição é recusada. E o saldo já desconta o que está na fila do projeto.
  • Entre envios da mesma conta há uma pausa de alguns segundos. É uma proteção da conta contra um fluxo "em loop": uma conversa de verdade com o cliente não esbarra nela, mas uma tentativa de mandar mil mensagens por esse método esbarra.
  • O serviço não fornece números próprios. A mensagem sai da sua conta conectada ou do seu bot: a reputação do número continua sua, e as respostas dos clientes chegam pra você no aplicativo do mensageiro, como sempre.

Próximos passos

O método funciona nos planos normais e está em fase de testes: ainda não tem preço próprio, e as mensagens contam como mensagens normais do plano. No plano gratuito são 15 mensagens por dia, o suficiente pra montar a integração.

Comece com uma requisição pro seu próprio número: conecte uma conta, pegue a chave na seção "API" do painel e mande uma mensagem de teste pra você mesmo. Depois, assine os eventos e coloque a chamada no ponto do seu sistema onde o evento acontece.

A descrição completa dos métodos, com a opção de executar requisições direto no navegador, está na documentação da API, e a especificação pra gerar o cliente fica no arquivo OpenAPI.