InícioDocumentação › API de WhatsApp em C#

Como enviar mensagem de WhatsApp com C#

Do token ao fluxo de duas vias em .NET 8: um HttpClient reutilizado como manda o figurino, serialização com System.Text.Json, envio de texto, imagem e botões, verificação de número e um webhook em Minimal API que devolve 200 antes de processar.

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.

Onde guardar o token num projeto .NET?

Em desenvolvimento, no user secretsdotnet 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ódigoSignificaO que fazer em C#
200Requisição aceitaGrave o Id junto do seu registro e siga
400Corpo inválidoLoga o JSON serializado e corrige: campo faltando, Phone fora do formato ou nome trocado por PropertyNamingPolicy camelCase
401Token ausente ou inválidoConfira se a chave Zapon:Token chegou ao IConfiguration — no Linux, o separador na variável de ambiente é __, não :
404Endpoint inexistenteConfira a rota; com BaseAddress, uma rota sem a barra inicial também engole parte do caminho
500Erro ao processarRepita 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
}
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.

Leia também

Escreva a integração hoje, conecte o número quando quiser.

14 dias grátis, sem cartão — a contagem só começa na primeira conexão. R$ 27 por mês por número, sem cobrança por mensagem.