By language

WhatsApp API in C#: IHttpClientFactory, minimal API and BackgroundService

In ASP.NET Core, the D-API WhatsApp API becomes a typed client registered with IHttpClientFactory, a minimal API endpoint for the webhook and a BackgroundService that processes events outside the request. All with what already ships in .NET 8, no extra package.

By D-API engineering team6 min read

How the pieces fit together

.NET teams usually integrate WhatsApp into support systems, ERPs and internal portals, almost always on ASP.NET Core. The D-API WhatsApp API has no NuGet library: it is REST, with JSON and authentication through the Authorization header. The recommended design uses three native building blocks:

  1. A typed client registered with AddHttpClient, which holds the URL, header and timeout.
  2. A minimal API endpoint that receives the webhook, validates it and puts the event on a Channel.
  3. A BackgroundService that consumes the channel and does the slow work, including replying to the customer through the typed client.

The typed client with IHttpClientFactory

The typed client wraps the calls and turns the API error response, shaped like { "success": false, "error": "...", "statusCode": 400 }, into an exception carrying the status. The timeout lives in the client configuration, and the CancellationToken lets you cancel the call when the original request is aborted.

// 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 body = await res.Content.ReadAsStringAsync(ct);
        var error = TryParse(body);
        throw new DApiException((int)res.StatusCode, error?.Error ?? body);
    }

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

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

public sealed record DApiError(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"]);
});

Two details that save headaches. First, TryAddWithoutValidation: the key goes raw, without Bearer, and the Add method may reject that format. Second, when the timeout fires, HttpClient throws a TaskCanceledException with an inner TimeoutException. Handle that case separately from 4xx errors: on a timeout, the message may have gone out; on a 4xx, the request was rejected.

If the project already uses the Microsoft.Extensions.Http.Resilience package, it is tempting to chain an AddStandardResilienceHandler onto the same registration. Think first: the default policy retries requests on transient failures, and retrying a send POST can deliver the same message to the customer twice. For send routes, prefer turning off automatic retries for non-idempotent methods and leave the decision to the BackgroundService, which knows the context of each message. For sending PDFs, audio and images, see the fields for each type in how to send media through the API.

The webhook in a minimal API

The endpoint doesn't process anything. It checks the secret token in the route, writes the event to a bounded Channel and responds. If the channel is full, it returns 503: D-API reads that as a temporary failure and retries later with exponential backoff, which works as backpressure with no extra code.

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

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

var app = builder.Build();

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

        return queue.Writer.TryWrite(evt)
            ? Results.Ok()
            : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    });

The WhatsApp webhook carries no signature, so the constant-time token comparison is the origin check. The minimal API model binding already deserializes the envelope; the Data field stays a JsonElement because it changes with the event. Event types are listed in WhatsApp webhooks.

The BackgroundService that consumes the queue

The hosted service is a singleton, and the typed client should not be held by it for the application's whole lifetime. The right way is to open a scope per event and resolve DApiClient inside it.

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

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

            try
            {
                await dapi.SendTextAsync(evt.SessionId, jid.Split('@')[0],
                    "We got your message. An agent will reply shortly.", stoppingToken);
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Failed to reply to event {TraceId}", evt.TraceId);
            }
        }
    }
}

The TraceId in the structured log is what ties an error on your side to a specific D-API delivery. Deduplication belongs here too: store each message's id and ignore repeats, because an event can be redelivered.

Test the typed client without a network

Since DApiClient receives HttpClient through its constructor, you just hand it a fake handler in the test. That way you validate error handling without depending on the API or a connected number, and the test runs the same in CI and on every developer's machine.

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

[Fact]
public async Task Api_error_becomes_DApiException_with_status()
{
    var http = new HttpClient(new FakeHandler(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("support", "14155550123", "hi"));

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

The same pattern covers a body that is not JSON, such as a proxy error page, making sure the client doesn't break with JsonException and still returns the original message for the log.

When to swap the Channel for a real queue

Channel lives in the process's memory. With a single instance and moderate volume, it works well. With several replicas behind a load balancer, or when losing an event during a deploy is not acceptable, the way to go is a persistent queue. D-API can publish session events straight to your own RabbitMQ, which removes the HTTP endpoint and leaves consumption to MassTransit or the official RabbitMQ client. Delivery options are on the integrations page. If your product is a customer support platform, the WhatsApp API for helpdesks guide shows how to organize queues, agents and per-customer connections.

Frequently asked questions

Is there an official D-API NuGet package?
No. The official SDK is for Node.js. In .NET the integration is REST with the framework’s own HttpClient, which already brings JSON serialization, timeouts and dependency injection support.
Why not create a new HttpClient for every send?
Creating and disposing HttpClient on every call can exhaust the server’s ports under load. IHttpClientFactory reuses handlers and renews connections periodically, which also respects DNS changes.
Does it work on .NET Framework 4.8?
Sending works, because it is just HTTP with JSON. The examples on this page use .NET 8 features such as minimal APIs and primary constructors; on .NET Framework you would adapt them to controllers and traditional classes.
Why does the Authorization header throw a format error?
.NET validates the Authorization header format when you add it with the Add method. Since D-API expects the raw key, without Bearer, use TryAddWithoutValidation so the value is sent exactly as is.
Does the in-memory queue lose events?
It does if the process crashes with pending items. For low volume that is usually acceptable. With several instances or critical events, use an external queue; D-API can also deliver events straight to RabbitMQ.

Try D-API's WhatsApp API

3-day trial with full access. No credit card, no lock-in.