---
title: "WhatsApp API in C# and .NET with ASP.NET Core | D-API"
description: "Integrate the WhatsApp API in C# with IHttpClientFactory in ASP.NET Core, receive the webhook in a minimal API and process events in a BackgroundService."
url: https://d-api.cloud/en/whatsapp-api-csharp
---

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 teamUpdated Sep 24, 20266 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](https://d-api.cloud/en) 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](https://d-api.cloud/en/whatsapp-api-send-media).

## 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](https://d-api.cloud/en/whatsapp-webhook).

## 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](https://d-api.cloud/en/integrations) page. If your product is a customer support platform, the [WhatsApp API for helpdesks](https://d-api.cloud/en/whatsapp-api-for-helpdesk) 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 API3-day free trial, no credit card.[Start free trial](https://app.d-api.cloud/register?lang=en)

## Keep reading

-   [By languageWhatsApp API in Go](https://d-api.cloud/en/whatsapp-api-golang)
-   [By languageWhatsApp API in Node.js](https://d-api.cloud/en/whatsapp-api-nodejs)
-   [By languageWhatsApp API in Python](https://d-api.cloud/en/whatsapp-api-python)
-   [By languageWhatsApp API in PHP](https://d-api.cloud/en/whatsapp-api-php)

## Try D-API's WhatsApp API

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

[Start free trial](https://app.d-api.cloud/register?lang=en)[Read the docs](https://docs.d-api.cloud/)
