API do WhatsApp em C# (.NET): enviar mensagens e receber webhook
Tutorial de API do WhatsApp em C# e .NET: HttpClient tipado, envio de texto, imagem e lista, webhook em ASP.NET Core com fila em background e tratamento de 429.
Para usar a API do WhatsApp em C# você não precisa de SDK: um HttpClient tipado envia mensagens para https://us.api-wa.me/SUA_KEY/... com JSON, e uma minimal API do ASP.NET Core recebe o webhook no envelope da Cloud API. Neste tutorial você monta as duas pontas com .NET moderno — records, System.Text.Json, IHttpClientFactory, fila com Channel<T> e BackgroundService — usando a API não oficial da WAME, que conecta seu número por QR Code ou código de pareamento, sem aprovação na Meta.
A camada não oficial não é afiliada, endossada ou suportada pelo WhatsApp ou pela Meta. O uso é de responsabilidade de quem envia.
O que você precisa
- .NET 8 ou mais novo.
- Uma instância na WAME conectada ao seu número (veja como conectar sem QR Code se preferir código de pareamento).
- Uma URL pública para o webhook. Em desenvolvimento, um túnel como ngrok resolve.
A autenticação é a própria key na URL. Guarde-a em configuração, nunca no código:
{
"Wame": {
"BaseUrl": "https://us.api-wa.me/",
"Key": "SUA_KEY"
}
}Um cliente tipado com HttpClient
O erro clássico em .NET é criar new HttpClient() a cada envio e esgotar sockets. Com IHttpClientFactory e um cliente tipado, o ciclo de vida fica por conta do framework:
using System.Net;
using System.Net.Http.Json;
public sealed class WameOptions
{
public string BaseUrl { get; init; } = "https://us.api-wa.me/";
public string Key { get; init; } = "";
}
public sealed class WameClient(HttpClient http)
{
public Task SendTextAsync(string to, string text, CancellationToken ct = default) =>
PostAsync("message/text", new { to, text }, ct);
public Task SendImageAsync(string to, string url, string? caption = null, CancellationToken ct = default) =>
PostAsync("message/image", new { to, url, caption }, ct);
public Task SendListAsync(string to, string text, string buttonText,
IEnumerable<ListSection> sections, CancellationToken ct = default) =>
PostAsync("message/list", new { to, text, buttonText, sections }, ct);
public Task SetTypingAsync(string to, CancellationToken ct = default) =>
PostAsync("message/presence", new { to, status = "composing" }, ct);
private async Task PostAsync(string path, object body, CancellationToken ct)
{
using var resp = await http.PostAsJsonAsync(path, body, ct);
if (resp.StatusCode == HttpStatusCode.TooManyRequests)
throw new WameRateLimitException(await resp.Content.ReadAsStringAsync(ct));
resp.EnsureSuccessStatusCode();
}
}
public sealed record ListRow(string Title, string RowId, string? Description = null);
public sealed record ListSection(string Title, IReadOnlyList<ListRow> Rows);
public sealed class WameRateLimitException(string message) : Exception(message);Dois detalhes que evitam dor de cabeça:
BaseAddressprecisa terminar com a key e uma barra. Sem a barra final, oHttpClientdescarta o último segmento ao combinar commessage/text.- O
System.Text.JsoncomPostAsJsonAsyncusa camelCase por padrão, entãoRowIdvirarowIdeButtonTextvirabuttonText— exatamente o que a API espera.
O registro no Program.cs:
var builder = WebApplication.CreateBuilder(args);
var wame = builder.Configuration.GetSection("Wame").Get<WameOptions>()!;
builder.Services.AddHttpClient<WameClient>(c =>
{
c.BaseAddress = new Uri($"{wame.BaseUrl}{wame.Key}/");
c.Timeout = TimeSpan.FromSeconds(30);
});Enviar texto, imagem e lista
Com o cliente injetado, o envio fica em uma linha. O número vai sempre no formato internacional, só dígitos: código do país, DDD e número.
await wame.SendTextAsync("5511999999999", "Olá! Seu pedido foi confirmado.");
await wame.SendImageAsync("5511999999999",
"https://exemplo.com/comprovante.png", "Seu comprovante");
await wame.SendListAsync("5511999999999",
text: "Como posso ajudar?",
buttonText: "Ver opções",
sections:
[
new ListSection("Atendimento",
[
new ListRow("Segunda via de boleto", "boleto"),
new ListRow("Falar com atendente", "humano", "Horário comercial")
])
]);Uma observação sobre listas e botões: em conversa fria — contato que nunca trocou mensagem com o seu número — o WhatsApp do destinatário pode não exibir a mensagem interativa. Abra com um texto e mande o menu em seguida. O motivo está em como aquecer um número.
Todos os envios da camada não oficial já saem com comportamento humano (online, "digitando…" proporcional ao texto, envio). O SetTypingAsync acima é útil quando o seu processamento demora — uma consulta ao ERP, uma chamada de IA — e você quer sinalizar ao cliente que a resposta está vindo.
Receber o webhook no formato meta
Configure o webhook da instância apontando para a sua aplicação, no formato meta:
curl -X PUT "https://us.api-wa.me/SUA_KEY/instance" \
-H "Content-Type: application/json" \
-d '{
"allowWebhook": true,
"allowNumber": "all",
"webhookMessage": "https://seu-dominio.com/webhook/wame",
"webhookConnection": "https://seu-dominio.com/webhook/wame",
"webhookFormat": "meta"
}'Cada mensagem chega no envelope padrão da Cloud API. Records modelam só o que interessa — o resto é ignorado na desserialização:
using System.Text.Json.Serialization;
public sealed record WebhookEnvelope(string? Provider, List<Entry>? Entry);
public sealed record Entry(string? Id, List<Change>? Changes);
public sealed record Change(string? Field, ChangeValue? Value);
public sealed record ChangeValue(List<IncomingMessage>? Messages);
public sealed record IncomingMessage(
string From,
string Id,
string Type,
TextBody? Text,
Interactive? Interactive,
[property: JsonPropertyName("group_id")] string? GroupId,
[property: JsonPropertyName("from_me")] bool FromMe = false);
public sealed record TextBody(string Body);
public sealed record Interactive(
string Type,
[property: JsonPropertyName("list_reply")] ReplyId? ListReply,
[property: JsonPropertyName("button_reply")] ReplyId? ButtonReply);
public sealed record ReplyId(string Id, string? Title);Repare em três campos:
group_idsó aparece quando a mensagem veio de um grupo; aífromé quem enviou dentro dele.from_memarca o eco das mensagens enviadas pelo próprio número. Ignore, ou o bot responde a si mesmo.interactive.list_reply.idtraz orowIdque você definiu na lista.
Responder 200 na hora e processar em background
O endpoint do webhook não pode esperar o processamento. Webhook lento é reenviado, e o cliente recebe a mesma resposta duas vezes. A solução idiomática em .NET é um Channel<T> como fila em memória e um BackgroundService consumindo:
using System.Threading.Channels;
builder.Services.AddSingleton(Channel.CreateBounded<IncomingMessage>(
new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait }));
builder.Services.AddHostedService<MessageWorker>();
var app = builder.Build();
app.MapPost("/webhook/wame", async (WebhookEnvelope env, Channel<IncomingMessage> queue) =>
{
var messages = env.Entry?
.SelectMany(e => e.Changes ?? [])
.Where(c => c.Field == "messages")
.SelectMany(c => c.Value?.Messages ?? [])
.Where(m => !m.FromMe) ?? [];
foreach (var m in messages)
await queue.Writer.WriteAsync(m);
return Results.Ok();
});
app.Run();E o worker, que decide o que responder:
public sealed class MessageWorker(
Channel<IncomingMessage> queue,
WameClient wame,
ILogger<MessageWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var msg in queue.Reader.ReadAllAsync(ct))
{
try
{
await HandleAsync(msg, ct);
}
catch (WameRateLimitException ex)
{
log.LogWarning("429 ao responder {From}: {Msg}", msg.From, ex.Message);
await Task.Delay(TimeSpan.FromSeconds(30), ct);
}
catch (Exception ex)
{
log.LogError(ex, "Falha ao processar {Id}", msg.Id);
}
}
}
private Task HandleAsync(IncomingMessage msg, CancellationToken ct)
{
if (msg.GroupId is not null) return Task.CompletedTask; // este bot só atende 1:1
var escolha = msg.Interactive?.ListReply?.Id;
return escolha switch
{
"boleto" => wame.SendTextAsync(msg.From, "Vou gerar sua segunda via.", ct),
"humano" => wame.SendTextAsync(msg.From, "Já chamo alguém do time.", ct),
_ when msg.Type == "text" => wame.SendTextAsync(msg.From,
$"Recebi: {msg.Text?.Body}", ct),
_ => Task.CompletedTask
};
}
}O WameClient é registrado como transient pelo AddHttpClient, e o BackgroundService é singleton. Funciona porque o cliente tipado só segura um HttpClient vindo da factory; se o seu worker precisar de serviços scoped (um DbContext, por exemplo), injete IServiceScopeFactory e crie um escopo por mensagem.
Idempotência: o mesmo evento pode chegar duas vezes
Mesmo respondendo rápido, reentrega acontece — rede instável, deploy no meio do caminho. Guarde o id de cada mensagem processada e descarte repetições. Em produção, um SETNX no Redis com expiração de algumas horas resolve; em teste, um ConcurrentDictionary serve. O padrão completo, com assinatura e retry, está em webhook em produção.
Tratando o 429 do jeito certo
O 429 da WAME não é instabilidade: é um freio deliberado. Ele aparece quando a instância fala com números novos demais em pouco tempo ou manda o mesmo texto para números demais em poucos minutos — o desenho clássico de disparo em massa. Por isso, a política certa não é retry imediato com Polly em loop:
- Espere e espalhe. Reduza o ritmo da fila; não reenvie a mesma mensagem na hora.
- Personalize. Texto idêntico para muita gente é exatamente o que o freio procura.
- Revise o fluxo. Se um bot de atendimento está batendo em 429, algo está errado — um loop, um eco de
from_menão filtrado, uma campanha disfarçada.
A WAME não apoia spam. Esses freios existem para que atendimento, notificação e bots legítimos rodem com taxa de bloqueio muito baixa para quem usa do jeito certo. As regras estão em uso responsável da API não oficial.
Saúde do número no mesmo webhook
Como o webhook de conexão aponta para o mesmo endpoint, você também recebe o evento de saúde da instância (field: "health"), com o estado atual, o anterior e um indicador should_pause. Vale acrescentar um Change com esse campo ao modelo e, quando should_pause vier verdadeiro, parar de escrever na fila de envios ativos. Os sinais e o que fazer com cada um estão em sinais de que o número está em risco.
Um código para oficial e não oficial
Se você imagina migrar para a API oficial um dia — ou rodar as duas —, existe um atalho: o endpoint POST /{key}/message aceita exatamente o corpo da WhatsApp Cloud API. Combinado com o webhook meta, a mesma camada C# atende os dois tipos de instância. O desenho está em um código só para a API oficial e a não oficial.
Conclusão
Em .NET, integrar WhatsApp é HTTP e JSON: um HttpClient tipado registrado com IHttpClientFactory para enviar, records do System.Text.Json para ler o envelope da Cloud API, uma minimal API que responde 200 na hora e um BackgroundService com Channel<T> para processar. Somando idempotência pelo id da mensagem e respeito ao 429, você tem uma integração de produção sem SDK e sem self-host. Se preferir outra linguagem, há o tutorial em Python e o SDK JavaScript; a referência completa dos endpoints está na documentação.
Pronto para automatizar seu WhatsApp?
Crie sua conta gratuita e comece a enviar mensagens pela API em minutos.
Começar grátisPerguntas frequentes
Existe SDK oficial da WAME para C#?+
Não é preciso. A API é REST pura: a instância é identificada por uma key na própria URL (https://us.api-wa.me/SUA_KEY/...) e o corpo é JSON. Com HttpClient e System.Text.Json você cobre texto, mídia, listas, grupos e o webhook sem dependência extra.
Como recebo mensagens do WhatsApp numa aplicação ASP.NET Core?+
Configure o webhook da instância com PUT /{key}/instance apontando para um endpoint público da sua aplicação e escolha webhookFormat meta. Cada mensagem chega no envelope da Cloud API (entry, changes, value.messages). Responda 200 na hora e processe numa fila em background.
Por que responder 200 antes de processar o webhook?+
Webhook que demora a responder é reenviado. Se você chama uma IA, um banco ou outra API antes de responder, o mesmo evento pode chegar duas ou três vezes e o cliente recebe respostas duplicadas. Enfileire com Channel<T> e processe num BackgroundService.
O que significa o erro 429 da API?+
É um freio de envio: a instância falou com números novos demais em pouco tempo ou mandou o mesmo texto para números demais em poucos minutos. Não repita na hora; espere, espalhe os envios na fila e revise se o fluxo não está parecendo disparo em massa.
Posso usar o mesmo código C# com a API oficial?+
Sim, se você usar o endpoint POST /{key}/message, que aceita exatamente o corpo da WhatsApp Cloud API, e o webhook no formato meta. Assim a mesma camada de envio serve instâncias oficiais e não oficiais, mudando só a key.
Continue lendo
Agente de voz no WhatsApp: latência, interrupção (barge-in) e silêncio
Como deixar um agente de voz no WhatsApp natural: latência, streaming, detecção de fala, interrupção (barge-in), silêncio e eco, com exemplos em Node.js.
Anti-detecção na API não oficial do WhatsApp: como a WAME protege seu número
Como funciona a camada de anti-detecção da API não oficial da WAME: identidade de dispositivo, tempo humano, ritmo de envio, reconexão e monitor de saúde.
API do WhatsApp para delivery e restaurante: pedido, entrega e avaliação no mesmo chat
Como usar a API não oficial do WhatsApp no delivery: cardápio em lista, confirmação, endereço por localização, entregador em tempo real, Pix e avaliação.