WhatsApp Operators DailyThe Blueticks DispatchSaturday, September 19, 2026
Productivity

How to Send WhatsApp Messages from C#/.NET on Your Own Number (2026)

One authenticated POST from .NET, no NuGet package required. Then the parts that bite C#: the new HttpClient that exhausts sockets, the encoder that ruins the path, and a retry that sends twice.

DRBy Daniel Roth · September 18, 2026 · 12 min read
How to Send WhatsApp Messages from C#/.NET on Your Own Number (2026)

Your .NET service already knows the invoice is overdue, the order shipped, the appointment is tomorrow. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.

Here is the plain version. A whatsapp api c# integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.

What is a WhatsApp API integration in C#, really?

It is an HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates and no per-message fees apply.

The distinction matters because it changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, write message templates, submit them for review, and pay per conversation. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key and an HttpClient. The trade-off is real and it runs both ways, covered honestly in the last section. The auth model and why your own number works this way is the subject of the own-number REST API guide, so this piece stays on the C# mechanics.

One thing to hold onto from the start: sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.

What do you need before your first send from .NET?

Three things: the .NET SDK (6.0 or newer), a WhatsApp number already linked to your Blueticks account, and a bt_live_ API key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures in a whatsapp api .net setup.

The pre-flight, in order:

  1. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they live in appsettings.json user-secrets, an environment variable, or Key Vault, never checked into a repo.
  2. Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
  3. Check your SDK. Everything here runs on the base class library from .NET 6 up. HttpClient and System.Text.Json ship in the runtime, so the first send needs zero NuGet packages.

Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 1234567890@g.us for a group.

How do you send your first WhatsApp message from C# with zero dependencies?

Escape the recipient with Uri.EscapeDataString, set an Authorization: Bearer header, and POST a flat JSON body of {"type":"text","text":"..."} with a single shared HttpClient. Omit sendAt and it sends immediately. This is the send whatsapp message c# baseline, and it needs nothing beyond the base class library.

using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

// ONE HttpClient for the lifetime of the app. Never `new HttpClient()` per send.
static readonly HttpClient Http = new()
{
    BaseAddress = new Uri("https://api.blueticks.co"),
    Timeout = TimeSpan.FromSeconds(20),
};

string apiKey = Environment.GetEnvironmentVariable("BLUETICKS_API_KEY")!; // bt_live_YOUR_KEY
string recipient = "+15551234567";

// EscapeDataString encodes the '+' to %2B and leaves the digits alone.
// Do NOT use HttpUtility.UrlEncode / WebUtility.UrlEncode here (see below).
string segment = Uri.EscapeDataString(recipient);            // -> %2B15551234567

using var request = new HttpRequestMessage(
    HttpMethod.Post, $"/v1/scheduled-messages/{segment}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Headers.Add("Idempotency-Key", "invoice-4471-reminder");
request.Content = new StringContent(
    """{"type":"text","text":"Your invoice #4471 is due tomorrow."}""",
    Encoding.UTF8, "application/json");

using HttpResponseMessage response = await Http.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{(int)response.StatusCode} {body}");

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data.id, never at the top level. The language-agnostic first-send walkthrough shows the same call in shell, Python and Node if you want to sanity-check the request outside .NET first.

Now the C#-specific footgun, and it is a quiet one. The reflex for encoding a URL is HttpUtility.UrlEncode or WebUtility.UrlEncode. Both implement application/x-www-form-urlencoded, the form/query encoder, and both turn a space into a +. That is correct for a query string and wrong for a path segment. For a bare phone number you would never notice, because there is no space to mangle. The day a chat id carries a character those encoders treat differently, they ship a + where the server expected %20 and the send resolves to the wrong recipient. Uri.EscapeDataString implements RFC 3986 data-string escaping, which is the right rule for a path segment, and Microsoft's own docs spell out that it is the method for escaping a value destined for a URI component. Use it, not the form encoders.

Which HTTP client should you use — HttpClient, IHttpClientFactory, or RestSharp?

Use IHttpClientFactory in any app with a DI container, a static readonly HttpClient in a console tool, and reach for RestSharp only if your team already standardises on it. The one thing you must never do is new HttpClient() per request. That is the socket-exhaustion footgun, and it is the single most common way a .NET whatsapp api client falls over under load.

Here is why. A disposed HttpClient leaves its underlying socket in TIME_WAIT for a while, and a busy service that news-up a client per send burns through the machine's ephemeral ports until connections start failing with SocketException. Microsoft's HttpClient guidelines state it plainly: HttpClient is intended to be instantiated once and reused. In a hosted app the clean answer is IHttpClientFactory, which pools and rotates handlers for you:

using System.Text.Json;

// Program.cs — register a typed client once.
builder.Services.AddHttpClient("blueticks", client =>
{
    client.BaseAddress = new Uri("https://api.blueticks.co");
    client.Timeout = TimeSpan.FromSeconds(20);
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue(
            "Bearer", builder.Configuration["Blueticks:ApiKey"]);
});

// Anywhere with the factory injected:
public sealed class WhatsAppSender(IHttpClientFactory factory)
{
    public async Task<HttpResponseMessage> SendAsync(
        string recipient, string text, string idempotencyKey, CancellationToken ct)
    {
        HttpClient http = factory.CreateClient("blueticks");
        string segment = Uri.EscapeDataString(recipient);

        using var req = new HttpRequestMessage(
            HttpMethod.Post, $"/v1/scheduled-messages/{segment}");
        req.Headers.Add("Idempotency-Key", idempotencyKey);
        // Serialize the payload so a quote or newline in `text` can't break the JSON.
        string body = JsonSerializer.Serialize(new { type = "text", text });
        req.Content = new StringContent(body, Encoding.UTF8, "application/json");

        return await http.SendAsync(req, ct);
    }
}

IHttpClientFactory gives you named or typed clients, a central place to set the base address and bearer key, and a handler pool that sidesteps both socket exhaustion and the opposite failure, a long-lived client that never sees DNS changes. It is also where Polly plugs in, which the reliability section builds on. Whichever client you pick, the request shape and the response envelope are identical.

How do you read the response safely in C#?

Deserialize the {"success":true,"data":{...}} wrapper, read your fields off data, and model waMessageKey as a nullable object, not a string. Set PropertyNameCaseInsensitive on your JsonSerializerOptions so a casing mismatch never silently nulls a field. data can be absent on a 2xx that has nothing to return, so guard it.

The response you actually get back on a scheduled send looks like this, trimmed:

{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }

Model only the fields you use, with System.Text.Json:

using System.Text.Json;
using System.Text.Json.Serialization;

record Envelope(bool Success, MessageData? Data);
record MessageData(string Id, string Status, WaMessageKey? WaMessageKey);

// waMessageKey is an OBJECT, null until dispatch — never a string.
record WaMessageKey(
    bool FromMe,
    string? Remote,
    string? Id,
    [property: JsonPropertyName("_serialized")] string? Serialized);

static readonly JsonSerializerOptions JsonOpts = new()
{
    PropertyNameCaseInsensitive = true,
};

Envelope? env = JsonSerializer.Deserialize<Envelope>(body, JsonOpts);
if (env?.Data is null)
    return;                          // 2xx with no payload — do not dereference.

string messageId = env.Data.Id;      // 24-char hex queue id, your handle

Modelling waMessageKey as a string is the mistake that passes every test and breaks in production. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null and only fills in later as an object with fromMe, remote, id and _serialized fields. A string binding either throws on the object or hides the null. Type it as a nullable record, and use the 24-character hex id as your handle in the meantime. The read-messages guide covers the fuller message shape if you need to parse inbound chats too.

How do you schedule a message for later and get sendAt right in .NET?

Add a sendAt field holding an RFC 3339 timestamp with an explicit UTC offset. Build it with DateTimeOffset, never a bare DateTime, and format it with the round-trip "o" specifier. The accepted window is roughly 10 seconds to 365 days ahead; anything outside that is rejected at validation with a 400.

using System.Text.Json;

// A DateTimeOffset carries its offset, so the moment is unambiguous.
var when = new DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.FromHours(3));
string sendAt = when.ToString("o");   // 2026-10-01T09:00:00.0000000+03:00

// Serialize rather than interpolate, so text/sendAt are always valid JSON.
string json = JsonSerializer.Serialize(new { type = "text", text = "Reminder", sendAt });

The .NET-specific way this goes wrong is DateTime.Now. A bare DateTime has a Kind of Local, Utc or Unspecified, and the same code serialises to a different instant on your laptop versus a UTC container. DateTimeOffset removes the ambiguity by storing the offset alongside the time, and the round-trip "o" format specifier emits a string that already satisfies RFC 3339. A second trap: compute sendAt, let it sit in a queue for nine seconds, and dispatch arrives already inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom.

Recurring cadences and the deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide. Do not rebuild that here.

Ready to wire this into your app? Get a bt_live_ key and drop the queued BackgroundService below into your app. Your own number, your own contacts, no Meta Business verification and no per-message template fees. Point it at a live number and watch the first send land in seconds.

How do you make sends reliable — retries, idempotency, and a queued worker?

Never call the API inline in a controller. Push the send onto an in-memory Channel, drain it from a BackgroundService, retry only 429 and 5xx with Polly, and carry a stable Idempotency-Key derived from the business object so a retry never double-sends. That last part is the whole game for a whatsapp api integration c# that survives real traffic.

A tidy server rack with neatly managed cabling, a reliable always-on background worker draining WhatsApp send jobs one at a time

using System.Threading.Channels;
using Microsoft.Extensions.Hosting;

record SendJob(string Recipient, string Text, string IdempotencyKey);

// A bounded queue so a burst of orders can't blow up memory.
public sealed class SendQueue
{
    private readonly Channel<SendJob> _channel =
        Channel.CreateBounded<SendJob>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait,
        });

    public ValueTask EnqueueAsync(SendJob job, CancellationToken ct)
        => _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<SendJob> ReadAllAsync(CancellationToken ct)
        => _channel.Reader.ReadAllAsync(ct);
}

public sealed class SendWorker(SendQueue queue, WhatsAppSender sender, ILogger<SendWorker> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (SendJob job in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                using HttpResponseMessage res = await sender.SendAsync(
                    job.Recipient, job.Text, job.IdempotencyKey, stoppingToken);

                if ((int)res.StatusCode is 429 or >= 500)
                    throw new HttpRequestException($"retryable: {(int)res.StatusCode}");
            }
            catch (Exception ex)
            {
                log.LogWarning(ex, "send failed for {Key}", job.IdempotencyKey);
            }
        }
    }
}

Attach the retry policy where the client is registered. Microsoft.Extensions.Http.Resilience wraps Polly and adds a standard handler in one line:

builder.Services.AddHttpClient("blueticks", /* ...as above... */)
    .AddStandardResilienceHandler();   // retries transient 5xx/408/429 with backoff + jitter

Two rules make the retry safe. First, retry only 429, 5xx and transient IO, never a 400; a 400 is a bad body and will fail identically forever. Second, the Idempotency-Key is a field on the job, computed once from the order, ticket or reminder (order-4471-shipped), so all attempts carry the identical value. Compute it inside the worker from DateTime.Now and every retry becomes a fresh message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same message id, so detect a replay by the id, not the status. A different body under the same key returns 409, which means your key derivation has a bug.

One production limit to design around: the free plan allows 5 requests per 6-hour slot, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. Event-triggered sends fired from a webhook rather than a queue belong to the event automation guide; this section is only the outbound hand-off, retry and de-dupe.

When should you use this vs the WhatsApp Business (Cloud) API instead?

Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade is honest and it runs both ways.

The .NET-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients, whatsapp-web.js and Baileys, are Node projects. Running your own from C# means supervising a second runtime, a Node process and a browser profile, next to your .NET app forever: a session store, a reconnect supervisor, and a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier.

Where a BSP genuinely wins is not close. Approved marketing templates to 50,000 people, the OBA badge, ten agents on one inbox: that is what the Cloud API and its partners are built for, and the read on when the Business API is worth it makes that case in full. The verification question specifically is settled in the no-Meta-verification guide, so it is one line here: the own-number path skips Business verification because it is your own linked device.

One honest warning, because a queue makes it easy to get wrong. Fan 500 jobs onto your channel with no throttle and you fire 500 sends as fast as the worker drains. That burst is the pattern most likely to get a number flagged, whatever sent it, and WhatsApp's rules on automated and bulk messaging apply either way. Throttle the worker, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.

A person at a desk glancing at a phone lying face-down beside a closed notebook, a WhatsApp message delivered in a .NET workflow

FAQ

Can I send a WhatsApp message from C# without any NuGet packages?

Yes. The first-send example needs nothing beyond the base class library, because HttpClient and System.Text.Json ship in the .NET runtime from .NET 6 onward. Packages like Polly, Microsoft.Extensions.Http.Resilience or RestSharp add ergonomics for retries and pooling, not capability.

Why does new HttpClient() per send break under load?

A disposed client leaves its socket in TIME_WAIT, and a busy service news-ing up a client per request exhausts the machine's ephemeral ports until sends fail with SocketException. Microsoft's guidance is to instantiate HttpClient once and reuse it, or inject IHttpClientFactory, which pools and rotates handlers for you.

How do I stop a .NET retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header derived once from the business object (order-4471-shipped) and carried on the queued job so every retry reuses it. A replay returns the original response and the same message id instead of sending again. Compute the key from DateTime.Now inside the retry and each attempt becomes a fresh, duplicate message.

Why does my sendAt schedule the message at the wrong time?

Almost always a bare DateTime. DateTime.Now carries a Kind that serialises differently on a laptop versus a UTC container. Build the timestamp with DateTimeOffset, which stores the offset alongside the time, and format it with the round-trip "o" specifier so the string already satisfies RFC 3339.

Will sending from C# get my WhatsApp number banned?

Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a loop firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, spread batches over hours, and stop when someone asks. Consent is the sender's responsibility.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.