API de WhatsApp em C#: IHttpClientFactory, minimal API e BackgroundService

No ASP.NET Core, a API de WhatsApp da D-API vira um typed client registrado com IHttpClientFactory, um endpoint de minimal API para o webhook e um BackgroundService que processa os eventos fora do request. Tudo com o que já vem no .NET 8, sem pacote extra.

Como as peças se encaixam

Times .NET costumam integrar WhatsApp em sistemas de atendimento, ERPs e portais internos, quase sempre em ASP.NET Core. A API de WhatsApp da D-API não tem biblioteca NuGet: é REST, com JSON e autenticação pelo header Authorization. O desenho recomendado usa três recursos nativos:

  1. Typed client registrado com AddHttpClient, que concentra URL, header e timeout.
  2. Endpoint de minimal API que recebe o webhook, valida e coloca o evento em um Channel.
  3. BackgroundService que consome o canal e faz o trabalho demorado, inclusive responder o cliente pelo typed client.

O typed client com IHttpClientFactory

O typed client encapsula as chamadas e transforma a resposta de erro da API, no formato { "success": false, "error": "...", "statusCode": 400 }, em uma exceção com o status. O timeout fica na configuração do client, e o CancellationToken permite cancelar a chamada quando o request original é abortado.

// DApiClient.cs
public sealed class DApiClient(HttpClient http)
{
    public Task SendTextAsync(string sessionId, string to, string text, CancellationToken ct = default) =>
        PostAsync("/api/v1/messages/send/text", new { sessionId, to, text }, ct);

    public Task SendDocumentAsync(string sessionId, string to, string documentUrl, string fileName,
        CancellationToken ct = default) =>
        PostAsync("/api/v1/messages/send/document",
            new { sessionId, to, document = documentUrl, fileName }, ct);

    private async Task PostAsync(string path, object payload, CancellationToken ct)
    {
        using var res = await http.PostAsJsonAsync(path, payload, ct);
        if (res.IsSuccessStatusCode) return;

        var corpo = await res.Content.ReadAsStringAsync(ct);
        var erro = TryParse(corpo);
        throw new DApiException((int)res.StatusCode, erro?.Error ?? corpo);
    }

    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    private static DApiErro? TryParse(string json)
    {
        try { return JsonSerializer.Deserialize<DApiErro>(json, Json); }
        catch (JsonException) { return null; }
    }
}

public sealed record DApiErro(bool Success, string? Error, int StatusCode);
public sealed class DApiException(int status, string message) : Exception(message)
{
    public int Status { get; } = status;
}
// Program.cs
builder.Services.AddHttpClient<DApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.d-api.cloud");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.TryAddWithoutValidation(
        "Authorization", builder.Configuration["DApi:ApiKey"]);
});

Dois detalhes que evitam dor de cabeça. Primeiro, TryAddWithoutValidation: a chave vai pura, sem Bearer, e o método Add pode rejeitar esse formato. Segundo, quando o timeout estoura, o HttpClient lança TaskCanceledException com um TimeoutException interno. Trate esse caso separado de erro 4xx: no timeout, a mensagem pode ter saído; no 4xx, o pedido foi recusado.

Se o projeto já usa o pacote Microsoft.Extensions.Http.Resilience, é tentador encadear um AddStandardResilienceHandler no mesmo registro. Pense antes: a política padrão repete requisições em falhas transitórias, e repetir um POST de envio pode entregar a mesma mensagem duas vezes ao cliente. Para rotas de envio, prefira desligar a repetição automática de métodos não idempotentes e deixar a decisão para o BackgroundService, que sabe o contexto de cada mensagem. Para envio de PDFs, áudios e imagens, veja os campos de cada tipo em como enviar mídia pela API.

O webhook em uma minimal API

O endpoint não processa nada. Ele confere o token secreto na rota, escreve o evento em um Channel com capacidade limitada e responde. Se o canal estiver cheio, devolve 503: a D-API entende como falha temporária e tenta de novo mais tarde, com intervalo crescente, o que funciona como controle de pressão sem código extra.

public sealed record WebhookEvento(string Event, string SessionId, string Timestamp,
    string? TraceId, JsonElement Data);

builder.Services.AddSingleton(Channel.CreateBounded<WebhookEvento>(
    new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait }));
builder.Services.AddHostedService<ProcessadorWhatsApp>();

var app = builder.Build();

app.MapPost("/webhooks/whatsapp/{token}",
    (string token, WebhookEvento evento, Channel<WebhookEvento> fila, IConfiguration cfg) =>
    {
        var esperado = cfg["DApi:WebhookToken"] ?? "";
        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(esperado)))
            return Results.Unauthorized();

        return fila.Writer.TryWrite(evento)
            ? Results.Ok()
            : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    });

O webhook de WhatsApp não carrega assinatura, por isso a comparação do token em tempo constante é a verificação de origem. O model binding da minimal API já desserializa o envelope; o campo Data fica como JsonElement porque muda conforme o evento. Os tipos de evento estão em webhook de WhatsApp.

O BackgroundService que consome a fila

O serviço hospedado é singleton, e o typed client não deve ficar preso nele durante toda a vida da aplicação. A forma correta é abrir um escopo por evento e resolver o DApiClient ali dentro.

public sealed class ProcessadorWhatsApp(
    Channel<WebhookEvento> fila,
    IServiceScopeFactory scopes,
    ILogger<ProcessadorWhatsApp> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var evento in fila.Reader.ReadAllAsync(stoppingToken))
        {
            if (evento.Event != "messages.received") continue;
            if (evento.Data.GetProperty("fromMe").GetBoolean()) continue;

            using var scope = scopes.CreateScope();
            var dapi = scope.ServiceProvider.GetRequiredService<DApiClient>();
            var jid = evento.Data.GetProperty("from").GetProperty("jid").GetString()!;

            try
            {
                await dapi.SendTextAsync(evento.SessionId, jid.Split('@')[0],
                    "Recebemos sua mensagem. Um atendente responde em instantes.", stoppingToken);
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Falha ao responder evento {TraceId}", evento.TraceId);
            }
        }
    }
}

O TraceId no log estruturado é o que liga o erro do seu lado a uma entrega específica da D-API. Deduplicação também entra aqui: guarde o id de cada mensagem e ignore repetições, porque um evento pode ser reentregue.

Testar o typed client sem rede

Como o DApiClient recebe o HttpClient pelo construtor, basta entregar a ele um handler falso no teste. Assim você valida o tratamento de erro sem depender da API nem de um número conectado, e o teste roda igual no CI e na máquina de cada desenvolvedor.

sealed class HandlerFalso(HttpStatusCode status, string corpo) : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct) =>
        Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(corpo) });
}

[Fact]
public async Task Erro_da_api_vira_DApiException_com_status()
{
    var http = new HttpClient(new HandlerFalso(HttpStatusCode.BadRequest,
        """{ "success": false, "error": "Session not connected", "statusCode": 400 }"""))
        { BaseAddress = new Uri("https://api.d-api.cloud") };

    var ex = await Assert.ThrowsAsync<DApiException>(() =>
        new DApiClient(http).SendTextAsync("suporte", "5511999999999", "oi"));

    Assert.Equal(400, ex.Status);
    Assert.Equal("Session not connected", ex.Message);
}

O mesmo padrão cobre o caso de corpo que não é JSON, como uma página de erro de proxy, garantindo que o client não quebre com JsonException e ainda devolva a mensagem original para o log.

Quando trocar o Channel por uma fila de verdade

O Channel vive na memória do processo. Com uma instância só e volume moderado, ele resolve bem. Com várias réplicas atrás de um balanceador, ou quando perder um evento no deploy não é aceitável, o caminho é uma fila persistente. A D-API pode publicar os eventos da sessão direto em um RabbitMQ seu, o que elimina o endpoint HTTP e deixa o consumo com o MassTransit ou o client oficial do RabbitMQ. As opções de entrega estão em integrações. Se o seu produto é uma plataforma de atendimento, o guia de API de WhatsApp para helpdesk mostra como organizar filas, atendentes e conexões por cliente.

Perguntas frequentes

Existe pacote NuGet oficial da D-API?

Não. O SDK oficial é para Node.js. Em .NET a integração é REST com o HttpClient do próprio framework, que já traz serialização JSON, timeout e integração com injeção de dependência.

Por que não criar um new HttpClient em cada envio?

Criar e descartar HttpClient a cada chamada pode esgotar as portas do servidor sob carga. O IHttpClientFactory reaproveita os handlers e renova as conexões periodicamente, o que também respeita mudanças de DNS.

Funciona no .NET Framework 4.8?

O envio funciona, porque é só HTTP com JSON. Os exemplos desta página usam recursos do .NET 8, como minimal APIs e construtores primários; no .NET Framework seria preciso adaptar para controllers e classes tradicionais.

Por que o header Authorization dá erro de formato?

O .NET valida o formato do header Authorization ao adicionar pelo método Add. Como a D-API espera a chave pura, sem Bearer, use TryAddWithoutValidation para o valor ser enviado exatamente como está.

A fila em memória perde eventos?

Perde se o processo cair com itens pendentes. Para volume baixo isso costuma ser aceitável. Com várias instâncias ou eventos críticos, use uma fila externa; a D-API também entrega eventos direto no RabbitMQ.

Teste a API de WhatsApp da D-API

Trial de 3 dias com acesso completo. Sem cartão, sem fidelidade.