Integração de WhatsApp em C# costuma nascer dentro de algo maior: um ERP em ASP.NET, um serviço de cobrança, um job que já roda de madrugada. A API do zapon é REST com JSON, então tudo o que você precisa já está na BCL — HttpClient para a chamada e System.Text.Json para o corpo. Não há pacote do zapon no NuGet e não faz falta.
Esta página vai do token ao fluxo de duas vias: envio de texto, imagem e botões, verificação de número, receptor de webhook e tratamento de erro por código HTTP. Os exemplos usam .NET 8 com nullable habilitado e seguem duas regras que a plataforma cobra caro de quem ignora: um HttpClient por aplicação e async do começo ao fim.
O que você precisa antes da primeira linha
O que preciso para chamar a API do zapon em C#?
.NET 6 ou superior (os exemplos usam .NET 8), uma conta no zapon e um número conectado. Da conexão sai o token, que vai no header de toda chamada — um token por número. Nenhum pacote NuGet é obrigatório: HttpClient e System.Text.Json fazem a integração inteira. Para o webhook você precisa de uma URL pública que aceite POST.
- SDK. .NET 6, 7 ou 8. O código também compila em .NET Framework 4.8 com pequenas adaptações, mas ali o cuidado com o
HttpClient estático é ainda mais importante. - Conexão e token. No painel, crie a conexão e leia o QR Code com o WhatsApp do número (Aparelhos conectados → Conectar aparelho). Copie o
token: ele autentica as chamadas daquele número e só daquele. - Base da API.
https://api.zapon.dev, sempre por HTTPS.
Onde guardar o token num projeto .NET?
Em desenvolvimento, no user secrets — dotnet user-secrets set "Zapon:Token" "..." —, que fica fora da pasta do projeto e nunca vai para o Git. Em produção, na variável de ambiente Zapon__Token ou no cofre da sua nuvem. O que não pode é o token no appsettings.json versionado, que é de longe o vazamento mais comum em repositório .NET:
configuração sem token no repositório
// Program.cs
var token = builder.Configuration["Zapon:Token"]
?? throw new InvalidOperationException("Zapon:Token não configurado");
// em produção, a variável de ambiente Zapon__Token (dois sublinhados) alimenta a mesma chave
A autenticação é um header só, de nome token. Não é Authorization, não é Bearer e não há OAuth. Quem tem o token envia em nome daquele número, então trate-o como a senha do banco.
Primeiro envio de texto
Como enviar uma mensagem de WhatsApp com C#?
Um POST em https://api.zapon.dev/chat/send/text com o header token, Content-Type: application/json e um corpo de dois campos: Phone, apenas dígitos com país e DDD, e Body, o texto. A resposta devolve data.Id, o identificador da mensagem no WhatsApp, que vale guardar junto do registro que originou o envio.
O cliente abaixo é a peça central. Ele recebe o HttpClient por injeção, guarda as opções de serialização numa instância estática e traduz falha de rede e status HTTP para uma exceção própria:
ZaponClient.cs — cliente tipado
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Encodings.Web;
public sealed class ZaponException : Exception
{
public int Status { get; } // 0 = nem chegou na API (rede ou timeout)
public ZaponException(int status, string detalhe)
: base($"zapon {status}: {detalhe}") => Status = status;
}
public sealed class ZaponClient(HttpClient http)
{
// Crie as opções UMA vez: instanciar JsonSerializerOptions por chamada
// invalida o cache interno de metadados e custa caro em volume.
private static readonly JsonSerializerOptions Json = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // acento legível no log
};
public async Task<JsonElement> ChamarAsync(string rota, object payload, CancellationToken ct = default)
{
HttpResponseMessage res;
try
{
res = await http.PostAsJsonAsync(rota, payload, Json, ct);
}
catch (HttpRequestException e)
{
throw new ZaponException(0, e.Message);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new ZaponException(0, "timeout"); // timeout do HttpClient, não cancelamento
}
var bruto = await res.Content.ReadAsStringAsync(ct); // leia texto: 500 pode não vir em JSON
JsonElement raiz = default;
var temJson = false;
try
{
raiz = JsonDocument.Parse(bruto).RootElement;
temJson = true;
}
catch (JsonException) { }
var ok = res.StatusCode == System.Net.HttpStatusCode.OK
&& temJson
&& raiz.TryGetProperty("success", out var s) && s.GetBoolean();
if (!ok)
{
var detalhe = temJson && raiz.TryGetProperty("error", out var e)
? e.GetString() ?? ""
: bruto[..Math.Min(200, bruto.Length)];
throw new ZaponException((int)res.StatusCode, detalhe);
}
return raiz.GetProperty("data");
}
public async Task<string> EnviarTextoAsync(string phone, string body, CancellationToken ct = default)
{
var data = await ChamarAsync("/chat/send/text", new { Phone = phone, Body = body }, ct);
return data.GetProperty("Id").GetString() ?? "";
}
}
O registro no contêiner é o que garante o reaproveitamento de conexões e o header em todas as chamadas:
Program.cs — registro e primeiro envio
builder.Services.AddHttpClient<ZaponClient>(c =>
{
c.BaseAddress = new Uri("https://api.zapon.dev");
c.DefaultRequestHeaders.Add("token", token);
c.Timeout = TimeSpan.FromSeconds(25);
});
// em qualquer serviço que receba ZaponClient por injeção:
try
{
var id = await zapon.EnviarTextoAsync(
"5511999999999",
"Pedido 4821 confirmado. Assim que o pacote sair, mandamos o rastreio por aqui.");
logger.LogInformation("mensagem enviada: {Id}", id);
}
catch (ZaponException e)
{
logger.LogError("falhou: status={Status} {Mensagem}", e.Status, e.Message);
}
Todo envio bem-sucedido volta neste envelope — code, success e o data com identificador e horário.
resposta da API
{
"code": 200,
"success": true,
"data": {
"Details": "Sent",
"Id": "90B2F8B13FAC8A9CF6B06E99C7834DC5",
"Timestamp": "2026-09-03T09:12:08-03:00"
}
}
Por que não instanciar um HttpClient por envio?
Porque using var http = new HttpClient() dentro de um método é o bug mais caro desta plataforma. Ao descartar o objeto, o socket subjacente fica em TIME_WAIT por alguns minutos, e um lote de mil mensagens esgota as portas efêmeras da máquina — a exceção que aparece é SocketException por endereço em uso, num serviço que "só manda mensagem". O caminho certo é o AddHttpClient do exemplo, que entrega um HttpClient com handler compartilhado e rotação de DNS. Se o projeto não tem contêiner de injeção, um campo static readonly HttpClient resolve.
Por que o TaskCanceledException aparece quando dá timeout?
Porque o HttpClient implementa timeout cancelando a tarefa, e não lançando uma exceção específica. O resultado é que timeout e cancelamento de verdade chegam pela mesma porta, o que confunde o log e atrapalha a decisão de repetir. O filtro when (!ct.IsCancellationRequested) do cliente separa os dois: se ninguém pediu cancelamento, foi o relógio. Vale ainda a regra geral de async em C#: nunca chame .Result nem .Wait() para "simplificar" — em ASP.NET clássico isso trava a requisição, e em qualquer aplicação consome thread do pool à toa.
Preciso do Newtonsoft.Json?
Não. O System.Text.Json cobre tudo o que esta integração pede e já está na BCL. Um detalhe importa: os campos do zapon começam com maiúscula (Phone, Body, Image), e o serializador escreve o nome exatamente como está no seu tipo — desde que você não ligue PropertyNamingPolicy = JsonNamingPolicy.CamelCase. Se o projeto configurou camelCase globalmente para as respostas da API, passe as opções próprias do cliente na chamada, como o exemplo faz; caso contrário você envia phone e recebe 400.
Imagem e botões
Como enviar uma imagem pela API do zapon em C#?
POST /chat/send/image com Phone, Image e, opcionalmente, Caption. O campo Image é o arquivo em base64 no formato data URI — data:image/jpeg;base64,.... Em C#, isso é File.ReadAllBytesAsync mais Convert.ToBase64String, sem pacote nenhum.
enviando uma imagem
var caminho = Path.Combine("etiquetas", "4821.jpg");
var bytes = await File.ReadAllBytesAsync(caminho);
var imagem = "data:image/jpeg;base64," + Convert.ToBase64String(bytes);
await zapon.ChamarAsync("/chat/send/image", new
{
Phone = "5511999999999",
Image = imagem,
Caption = "Etiqueta do pedido 4821. Cole na caixa antes de postar."
});
Uma cautela de memória: base64 cresce o arquivo em cerca de um terço, e ReadAllBytesAsync traz tudo para o heap. Em .NET, qualquer alocação acima de 85 KB vai para o LOH, que não é compactado por padrão — um lote de fotos grandes fragmenta a memória do processo e o consumo nunca volta ao patamar anterior. Redimensione antes de enviar e trate um arquivo por vez. Se a imagem está numa URL sua, baixe e converta o conteúdo: o campo espera os bytes, não o endereço.
Como mandar botões de resposta rápida?
O POST /chat/send/buttons aceita quantos botões você precisar — três ou quatro é o que se lê num toque. Cada um leva um id definido por você e um text de até 20 caracteres, porque rótulo longo é truncado. Guarde no id a chave do seu registro: é ele que volta inteiro no webhook. Além da resposta rápida, o mesmo endpoint monta botão que abre um link ("type": "cta_url"), botão que abre o discador ("type": "cta_call") e botão que copia um código ("type": "copy") — e os tipos convivem na mesma mensagem. O toque em resposta rápida volta no webhook; os outros três agem no aparelho de quem recebe.
botões com o pedido carimbado no id
// os nomes dos campos vão em minúsculo no JSON: id e text
public sealed record Botao(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
var botoes = new[]
{
new Botao("ped4821|hoje", "Pode entregar"), // 13 caracteres
new Botao("ped4821|amanha", "Prefiro amanhã"),
new Botao("ped4821|humano", "Falar com alguém")
};
await zapon.ChamarAsync("/chat/send/buttons", new
{
Phone = "5511999999999",
Body = "Pedido 4821 sai para entrega hoje. Tem alguém para receber à tarde?",
Footer = "Loja Exemplo",
Buttons = botoes
});
Repare no [property: JsonPropertyName]: em record posicional, uma anotação sem o alvo property: cai no parâmetro do construtor e é simplesmente ignorada pelo serializador. O resultado é um JSON com Id e Text em maiúscula, botão que não aparece e meia hora perdida. Quando desconfiar, serialize e imprima antes de acusar a API.
Sem o carimbo no id, a resposta chega só com o telefone e você adivinha de qual pedido ela fala. Com ele, o webhook faz Split('|') e vai direto ao registro. Para mais de três opções, o caminho é o /chat/send/list, que abre um menu.
Verificar o número antes de enviar
Como saber se um número tem WhatsApp antes de disparar?
POST /user/check recebe a lista no campo Phone e devolve IsInWhatsapp para cada número. Passar a lista por aí antes de disparar poupa chamada com cadastro antigo e telefone fixo — e uma sequência de envios para números que não existem é um dos padrões que mais pesam contra o seu número.
filtrando a lista antes do disparo
var candidatos = new[] { "5511999999999", "5511888888888", "551133334444" };
var data = await zapon.ChamarAsync("/user/check", new { Phone = candidatos });
var validos = new List<string>();
var fora = new List<string>();
foreach (var u in data.GetProperty("Users").EnumerateArray())
{
var numero = u.GetProperty("Query").GetString()!;
(u.GetProperty("IsInWhatsapp").GetBoolean() ? validos : fora).Add(numero);
}
logger.LogInformation("com WhatsApp: {Validos}", validos);
await cadastro.MarcarSemWhatsappAsync(fora); // corrija a origem, não só o disparo de hoje
Persista o resultado no cadastro em vez de reconsultar a cada campanha. E encare fora como tarefa de dados: número sem WhatsApp costuma ser o nono dígito faltando — problema que se corrige uma vez, na origem.
Receber mensagens: o webhook
Como receber em C# as respostas que chegam no WhatsApp?
Cadastre no painel, por conexão, uma URL pública e escolha os eventos. Cada evento vira um POST para essa URL com a mensagem inteira no corpo — não há segunda chamada para buscar conteúdo. Em ASP.NET Core, o endpoint deve devolver 200 imediatamente e empurrar o evento para um Channel consumido por um BackgroundService.
O receptor precisa aceitar duas formas de corpo: JSON puro e formulário codificado, com o evento dentro do campo jsonData. Ler o corpo cru e decidir você mesmo é mais seguro que um parâmetro tipado, que devolve 415 quando o Content-Type não é o esperado — e o sintoma vira "o webhook não chega".
Program.cs — webhook em Minimal API
using System.Threading.Channels;
// fila em memória: o endpoint só escreve, o worker processa
var fila = Channel.CreateBounded<string>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.DropWrite // prefira perder evento a derrubar o serviço
});
builder.Services.AddSingleton(fila);
builder.Services.AddHostedService<ProcessadorDeEventos>();
var app = builder.Build();
app.MapPost("/whatsapp/eventos", async (HttpRequest req) =>
{
string cru;
if (req.HasFormContentType)
{
var form = await req.ReadFormAsync();
cru = form["jsonData"].ToString();
}
else
{
using var leitor = new StreamReader(req.Body);
cru = await leitor.ReadToEndAsync();
}
fila.Writer.TryWrite(cru); // não bloqueia: escreve e sai
return Results.Ok(); // 200 imediato
});
app.Run();
ProcessadorDeEventos.cs — o worker
public sealed class ProcessadorDeEventos(
Channel<string> fila, ZaponClient zapon, ILogger<ProcessadorDeEventos> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var cru in fila.Reader.ReadAllAsync(ct))
{
try { await TratarAsync(cru, ct); }
catch (Exception e) { logger.LogError(e, "falha ao tratar evento"); }
}
}
private async Task TratarAsync(string cru, CancellationToken ct)
{
var ev = JsonDocument.Parse(cru).RootElement;
if (ev.GetProperty("type").GetString() != "Message") return;
var info = ev.GetProperty("event").GetProperty("Info");
if (info.GetProperty("IsFromMe").GetBoolean()) return; // o que você enviou volta
if (info.GetProperty("IsGroup").GetBoolean()) return;
var id = info.GetProperty("ID").GetString()!;
if (!await MarcarComoVistoAsync(id)) return; // reentrega acontece
var telefone = info.GetProperty("Chat").GetString()!.Split('@')[0];
var msg = ev.GetProperty("event").GetProperty("Message");
var botao = msg.TryGetProperty("buttonsResponseMessage", out var br)
? br.GetProperty("selectedButtonID").GetString() ?? ""
: "";
if (botao.Length > 0)
{
var partes = botao.Split('|', 2);
await pedidos.RegistrarEscolhaAsync(partes[0], partes[1]);
await zapon.EnviarTextoAsync(telefone, partes[1] == "hoje"
? "Combinado. A entrega sai hoje à tarde."
: "Certo, remarcamos para amanhã.", ct);
return;
}
var texto = msg.TryGetProperty("conversation", out var c) ? c.GetString() ?? "" : "";
await atendimento.EnfileirarAsync(telefone, texto.Trim()); // o resto é assunto de gente
}
}
Qual é o formato do evento de mensagem recebida?
O corpo carrega o tipo do evento e a mensagem inteira. Uma resposta escrita chega neste formato:
payload do evento Message
{
"type": "Message",
"event": {
"Info": {
"ID": "3EB0C767D26A1B5F7C83",
"Chat": "[email protected]",
"Sender": "[email protected]",
"IsFromMe": false,
"IsGroup": false,
"PushName": "Ana Souza",
"Timestamp": "2026-09-03T18:04:22-03:00"
},
"Message": { "conversation": "pode entregar sim" }
}
}
// quando ele toca num botão, muda só o bloco Message:
"Message": { "buttonsResponseMessage": {
"selectedButtonID": "ped4821|hoje",
"Response": { "SelectedDisplayText": "Pode entregar" }
} }
O bloco Message muda de forma conforme o tipo da mensagem, e é por isso que o worker usa TryGetProperty em vez de desserializar para uma classe. Se você quiser tipos fortes, deixe todas as propriedades anuláveis e não conte com nenhuma delas presente — o primeiro áudio recebido derruba um modelo rígido.
Por que responder 200 antes de processar?
Porque a entrega do evento tem janela curta, e um endpoint que consulta banco, chama outra API e só então retorna transforma cada evento numa espera. O padrão do Channel com BackgroundService existe exatamente para isso em .NET: o endpoint escreve na fila em microssegundos e devolve 200; o worker consome no ritmo dele. Resista à tentação de usar _ = Task.Run(...), que perde a tarefa no desligamento da aplicação e engole exceções sem log. Vale registrar quem enviou o POST: as entregas chegam com o cabeçalho User-Agent: zapon-webhook/1.
Tratamento de erro por código HTTP
O que fazer em cada erro da API?
400 é corpo inválido e se resolve no código, nunca na retentativa. 401 é token ausente ou de outra conexão. 404 é rota errada. O 500, junto com falha de rede, é o único caso que merece nova tentativa, com espera crescente — insistir num 400 ou num 401 apenas gasta chamada.
| Código | Significa | O que fazer em C# |
|---|
200 | Requisição aceita | Grave o Id junto do seu registro e siga |
400 | Corpo inválido | Loga o JSON serializado e corrige: campo faltando, Phone fora do formato ou nome trocado por PropertyNamingPolicy camelCase |
401 | Token ausente ou inválido | Confira se a chave Zapon:Token chegou ao IConfiguration — no Linux, o separador na variável de ambiente é __, não : |
404 | Endpoint inexistente | Confira a rota; com BaseAddress, uma rota sem a barra inicial também engole parte do caminho |
500 | Erro ao processar | Repita com espera crescente e, se persistir, veja o estado da conexão no painel |
Note que EnsureSuccessStatusCode() não serve aqui: ele lança HttpRequestException sem o corpo da resposta, e é justamente no corpo que vem a explicação do 400. Verifique o status você mesmo, como o cliente faz. Para a repetição, um método simples resolve:
repetição seletiva com espera crescente
public static async Task<JsonElement> ComRetentativaAsync(
Func<Task<JsonElement>> acao, int tentativas = 3)
{
for (var i = 1; ; i++)
{
try { return await acao(); }
catch (ZaponException e) when (e.Status == 0 || e.Status >= 500)
{
if (i >= tentativas) throw; // 400 e 401 nem entram aqui
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i - 1))); // 1s, 2s, 4s
}
}
}
O filtro when na cláusula catch é o detalhe idiomático: erro definitivo nem é capturado, sobe direto e mantém a pilha original. Se o projeto já usa uma biblioteca de resiliência como o Polly, encaixe a política no AddHttpClient — mas garanta que ela repita apenas 5xx e falha de rede, nunca 400 nem 401.
Ritmo de envio e preservação do número
O risco em C# mora no Parallel.ForEachAsync e no Task.WhenAll sobre a lista inteira: mil clientes viram mil mensagens em segundos, padrão que não se parece com nenhum uso humano. Serialize, e aceite que o lote demore:
um por vez, com intervalo
foreach (var cliente in await PendentesDeHojeAsync())
{
if (cliente.OptOut) continue; // quem pediu para sair, sai de todas as rotinas
try
{
var id = await zapon.EnviarTextoAsync(cliente.Telefone, TextoPara(cliente), ct);
await envios.RegistrarAsync(cliente.Id, id); // guardar o Id evita mandar duas vezes
}
catch (ZaponException e)
{
await envios.RegistrarFalhaAsync(cliente.Id, e.Message);
}
await Task.Delay(Random.Shared.Next(3000, 7000), ct); // 3s a 7s, com variação
}
- Fale com quem espera. Mensagem sobre um pedido que a pessoa fez é esperada; lista comprada é denúncia esperando acontecer, e denúncia é o que pesa.
- Intervalo com variação. Um
Task.Delay(2000) exato mil vezes é tão artificial quanto o disparo em rajada. - Aqueça número novo. Comece com poucos envios por dia e aumente ao longo de semanas.
- Descadastro que funciona. Guarde o
OptOut no cadastro e faça toda rotina consultá-lo antes de enviar. O pedido chega em qualquer palavra, então alguém precisa olhar a fila de exceções.
Sobre bloqueio de número, sem promessa mágica. Nenhuma API — nem a oficial da Meta — impede que o WhatsApp aja contra um número denunciado. O risco não se elimina, se administra: falar com quem espera, manter volume e ritmo compatíveis com o seu negócio, responder quem responde e parar de enviar para quem pediu para sair. Quem promete que o número nunca será bloqueado não está sendo honesto.
Sobre custo, o cálculo em C# é o mesmo de qualquer linguagem: R$ 27 por mês por número conectado, sem cobrança por mensagem, respeitada a cota de 300 mensagens por dia por número — estourou, a API devolve HTTP 429, e é essa trava que segura o seu loop de envio antes que o WhatsApp bloqueie a conexão. O teste é de 14 dias sem cartão e só começa a contar na primeira conexão — dá para escrever a integração inteira, com testes, antes de gastar o primeiro dia.
Perguntas frequentes
Funciona em .NET Framework 4.8?
Funciona. HttpClient existe desde o 4.5 e o Convert.ToBase64String é o mesmo; troque System.Text.Json por Newtonsoft.Json se preferir e mantenha um static readonly HttpClient, porque ali não há IHttpClientFactory para segurar o handler. Em ASP.NET clássico, redobre o cuidado com .Result: o contexto de sincronização daquele modelo trava a requisição por completo.
Dá para rodar em Azure Functions ou AWS Lambda?
Dá, e o receptor de webhook encaixa bem numa função com gatilho HTTP. Dois cuidados: declare o HttpClient como estático fora do método, para sobreviver entre invocações, e devolva 200 antes do trabalho pesado — numa função, isso significa gravar o evento numa fila da própria nuvem e processar em outra função, já que a execução termina quando a resposta sai.
Como envio de um Worker Service ou de um job agendado?
Do mesmo jeito: registre o ZaponClient com AddHttpClient no HostBuilder e injete-o no BackgroundService. Propague o CancellationToken do ExecuteAsync até a chamada HTTP, para que uma parada da aplicação não deixe o lote pendurado no meio.
Meu JSON está saindo com escape unicode no lugar dos acentos. É problema?
Não é: escape unicode é JSON válido e a mensagem chega correta no WhatsApp. O System.Text.Json escapa por padrão como medida de segurança para saída em HTML. Se o log ilegível incomoda, use JavaScriptEncoder.UnsafeRelaxedJsonEscaping, como o cliente desta página — o nome assusta, mas o risco é de injeção em HTML, e aqui o destino é uma API.
Como testo o webhook sem publicar o serviço?
Suba a aplicação local, exponha a porta com um túnel HTTP e cadastre a URL do túnel na conexão. Para testar só a sua regra, use WebApplicationFactory num teste de integração, ou faça um POST direto no endpoint com o payload de exemplo desta página — é o mesmo formato que chega em produção.
Um cliente atende vários números?
Não: um token é um número. Registre um HttpClient nomeado por conexão, ou passe o header por chamada em vez de fixá-lo no DefaultRequestHeaders, guardando os tokens num dicionário. Cada número conectado custa R$ 27 por mês, sem cobrança por mensagem, e traz a sua própria cota de 300 mensagens diárias.
Preciso da API oficial da Meta ou de aprovação de template?
Não. O número entra por QR Code ou código de pareamento, sem processo de aprovação e sem template a homologar. Em compensação, a reputação do número é responsabilidade de quem integra — o que torna a seção de ritmo tão importante quanto o código.