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:
- Typed client registrado com
AddHttpClient, que concentra URL, header e timeout. - Endpoint de minimal API que recebe o webhook, valida e coloca o evento em um
Channel. - 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.
Continue lendo
Teste a API de WhatsApp da D-API
Trial de 3 dias com acesso completo. Sem cartão, sem fidelidade.