Send an Invoice PDF on WhatsApp from C# and .NET

November 2026

Why your customers keep asking for this

If you write billing or ERP software in C# or VB.NET, sooner or later a customer asks: "Can it send the bill on WhatsApp?" Their own customers read WhatsApp all day, and a PDF there gets opened faster than any email.

There are three ways people do it, and only one holds up:

  • A wa.me link opens a chat with the text filled in. A person still has to press send, and it cannot attach the PDF. Fine for ten bills a day, not for three hundred.
  • Automating WhatsApp Web with a scraper or an unofficial library works until the number gets banned, usually in the middle of the month-end rush.
  • The WhatsApp Business Platform through a provider: an approved message template, sent by an API call, with the PDF attached and a delivery report back. This is the way that lasts, and it is what this guide builds.

In short: clean the phone number, give each send its own Idempotency-Key saved with the invoice, post the PDF as multipart/form-data, retry timeouts with the same key, and let signed webhooks tell you when the bill was delivered and read.

The examples use the JS1 Talkback Send API, but the same pattern (keys, idempotent sends, signed webhooks) fits any good provider.

Before you write code: the template

Every message your software starts must use a template that Meta has approved. For an invoice you want a utility template with a document header (the PDF) and a short body, for example:

Dear {{1}}, your invoice {{2}} is attached. Thank you for your business.

{{1}} and {{2}} are filled from your code, in order. Keep it plain: a utility template that reads like an advert can be re-classed as marketing.

Step 1: clean the phone number

Your customer master holds numbers typed by many people over many years: 98765 43210, +91-98765-43210, 098765 43210. The API wants the full number with the country code, digits only, for example 919876543210. A bare 10-digit number is refused, so fix it before you send:

// India-first: a 10-digit number gets 91 in front. Returns null when it cannot be a valid number.
static string? ToWhatsAppNumber(string? raw)
{
    if (string.IsNullOrWhiteSpace(raw)) return null;
    var d = new string(raw.Where(char.IsDigit).ToArray());
    if (d.StartsWith("00")) d = d[2..];               // 0091 98765 43210
    if (d.Length == 13 && d.StartsWith("910")) d = "91" + d[3..];   // +91 098765 43210
    if (d.Length == 11 && d[0] == '0') d = d[1..];    // 098765 43210
    if (d.Length == 10) d = "91" + d;                 // 98765 43210
    return d.Length is >= 11 and <= 15 && d[0] != '0' ? d : null;
}

Show the bad ones to the user instead of skipping them silently: a list of "parties with no usable mobile" is worth a lot to a billing clerk.

Clean the values too. Each one must be non-empty, at most 1,000 characters, on one line, with no more than 4 spaces in a row. Party names copied from old systems often break the last rule:

using System.Text.RegularExpressions;

static string Clean(string? s, string ifEmpty = "-") =>
    string.IsNullOrWhiteSpace(s) ? ifEmpty : Regex.Replace(s.Trim(), @"\s+", " ");

The sender in step 3 calls Clean, so put it in the same class (or a shared static helper class).

Step 2: one Idempotency-Key per send, saved first

This is the part most integrations get wrong. You post a 300 KB PDF, the connection drops, and you do not know whether it went. Send again and the customer may get the bill twice. Do not send again and they may get nothing.

The fix is a key that names this send, for example INV-1043-send-1. If you post again with the same key, the API returns the first answer and the customer still gets one message. A deliberate resend ("please send that bill again") gets a new key, INV-1043-send-2. A repeat of an accepted key returns the stored answer without looking at the body, so if you changed the PDF, use a new key.

Save the key before the first try, so even a crash between "post" and "save the answer" retries with the same key. One table is enough:

CREATE TABLE InvoiceMessage (
    IdempotencyKey varchar(64)  NOT NULL PRIMARY KEY,  -- INV-1043-send-1
    InvoiceNo      varchar(30)  NOT NULL,
    MessageId      varchar(50)  NULL,                  -- from the 202 answer
    Status         varchar(20)  NOT NULL,              -- queued, retry, submitted, sent, delivered, read, failed, refused
    StatusRank     tinyint      NOT NULL,              -- 0 queued/retry, 1 submitted, 2 sent, 3 delivered, 4 read, 9 failed/refused
    FailureCode    varchar(40)  NULL,
    Tries          int          NOT NULL DEFAULT 0,
    NextTryAt      datetime2    NULL,
    UpdatedAt      datetime2    NOT NULL
);

The key is kept to 64 characters because we also send it as the message's reference (more on why in step 5).

Step 3: send the PDF (.NET 8 and later)

One HttpClient for the whole program (or IHttpClientFactory in ASP.NET Core), never one per send. The JSON goes in a form field called message and the PDF in a file field called document. The customer sees the file's own name, so call it INV-1043.pdf, not tmp4F2A.pdf.

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

public enum SendOutcome { Accepted, RetryLater, Refused }
public sealed record SendResult(SendOutcome Outcome, string? MessageId = null, string? Code = null, string? Message = null);

public sealed class InvoiceSender(string apiKey)
{
    static readonly HttpClient Http = new()
    {
        BaseAddress = new Uri("https://talkback.js1software.net/"),
        Timeout = TimeSpan.FromSeconds(30)
    };

    public async Task<SendResult> SendPdfAsync(string to, string partyName, string invoiceNo,
                                               string pdfPath, string idempotencyKey)
    {
        using var form = new MultipartFormDataContent();
        form.Add(new StringContent(JsonSerializer.Serialize(new
        {
            to,
            template = "invoice_pdf",
            @params = new[] { Clean(partyName), Clean(invoiceNo) },
            reference = idempotencyKey
        })), "message");

        var pdf = new ByteArrayContent(await File.ReadAllBytesAsync(pdfPath));
        pdf.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
        form.Add(pdf, "document", Path.GetFileName(pdfPath));

        using var req = new HttpRequestMessage(HttpMethod.Post, "v1/messages") { Content = form };
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        req.Headers.Add("Idempotency-Key", idempotencyKey);

        HttpResponseMessage res;
        try { res = await Http.SendAsync(req); }
        catch (HttpRequestException) { return new(SendOutcome.RetryLater); }   // no answer
        catch (TaskCanceledException) { return new(SendOutcome.RetryLater); }  // timed out

        using (res)
        {
            var body = await res.Content.ReadAsStringAsync();
            var status = (int)res.StatusCode;
            if (status == 202)
                return new(SendOutcome.Accepted, MessageId: ReadString(body, "id"));
            if (status == 429 || status >= 500)
                return new(SendOutcome.RetryLater, Code: ReadString(body, "code"));
            if (status == 401)
                return new(SendOutcome.Refused, Code: "UNAUTHORIZED", Message: "The API key is wrong or withdrawn.");
            return new(SendOutcome.Refused, Code: ReadString(body, "code"), Message: ReadString(body, "message"));
        }
    }

    static string? ReadString(string body, string name)
    {
        try { using var doc = JsonDocument.Parse(body); return doc.RootElement.GetProperty(name).GetString(); }
        catch { return null; }   // empty body (401) or not JSON (a proxy's error page)
    }

    // Clean() from step 1 lives here too.
}

The three outcomes are all your code needs to know:

Answer What it means What to do
202 Taken Save MessageId, status submitted
429, 5xx, timeout, no answer Not known, or not now Retry later with the same key
Any other 4xx Refused, nothing charged Status refused; show code and message to the user

A refusal is not something to retry blindly. TEMPLATE_NOT_APPROVED, BAD_PARAMS or INSUFFICIENT_BALANCE will be refused again until a person fixes the cause. The full list is in the API guide.

Step 4: a small retry queue

Do not retry inside the button click. Mark the row retry, set NextTryAt, and let a background job (a timer in a Windows service, a Quartz or Hangfire job, or a hosted service) pick it up:

static readonly TimeSpan[] Backoff =
    [TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15), TimeSpan.FromHours(1)];

static DateTime? NextTry(int tries) =>
    tries >= 24 ? null                                   // about a day of trying: hand it to a person
    : DateTime.UtcNow + Backoff[Math.Min(tries, Backoff.Length - 1)];

Every retry reuses the row's own IdempotencyKey. That is what makes retrying safe: whatever happened to the earlier try, the customer gets the bill once.

Two things to know for the month-end batch of 400 invoices:

  • The API takes about 120 requests a minute. Send at most one or two a second and you will never see 429 RATE_LIMITED; if you do, wait the 60 seconds its Retry-After header asks for.
  • Check remaining in each 202 answer. When it gets low, tell someone before the batch stops with 402 INSUFFICIENT_BALANCE.

Step 5: webhooks tell you delivered and read

Do not poll for status. The API posts every change to your server: sent, delivered, read or failed, and every reply a customer writes. In ASP.NET Core:

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

app.MapPost("/talkback/webhook", async (HttpRequest request, InvoiceDb db) =>
{
    using var ms = new MemoryStream();
    await request.Body.CopyToAsync(ms);
    var raw = ms.ToArray();                       // the raw bytes: verify BEFORE parsing

    if (!Verify(raw, request.Headers["Talkback-Timestamp"], request.Headers["Talkback-Signature"], webhookSecret))
        return Results.Unauthorized();

    using var doc = JsonDocument.Parse(raw);
    var e = doc.RootElement;
    if (e.GetProperty("event").GetString() == "message.status")
        await db.MoveForwardAsync(
            e.GetProperty("reference").GetString(),   // our IdempotencyKey
            e.GetProperty("status").GetString()!,
            e.GetProperty("failureCode").GetString());
    return Results.Ok();                          // answer within 10 seconds
});
// Keep the handler to this one indexed UPDATE. Anything slow (emails, reports) goes to a background job,
// or a slow database makes Talkback retry, and after 24 hours of failures it switches the webhook off.

static bool Verify(byte[] rawBody, string? ts, string? sig, string secret)   // secret = the whole "tbwh_…" string
{
    if (!long.TryParse(ts, out var t) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300 || sig is null)
        return false;
    byte[] signed = [.. Encoding.UTF8.GetBytes(ts + "."), .. rawBody];
    var want = "v1=" + Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed)).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(want), Encoding.ASCII.GetBytes(sig));
}

Two rules keep the status honest:

  • Events can arrive twice and out of order. A read can come before delivered. So only ever move a row forward, and let failed win over everything. That is what StatusRank is for:

    -- @rank: sent 2, delivered 3, read 4, failed 9
    UPDATE InvoiceMessage
    SET Status = @status, StatusRank = @rank, FailureCode = @failureCode, UpdatedAt = SYSUTCDATETIME()
    WHERE IdempotencyKey = @reference AND StatusRank < @rank;
    

    A repeated or late event then changes nothing, with no extra bookkeeping.

  • A webhook can beat your own save. With a fast network (and always with a test key) the delivered event can reach you before your code has stored the 202 answer. That is why we match on reference (our own key, saved before the send) and not on the message id. When you store the 202, move forward the same way: always set MessageId, and set Status = 'submitted', StatusRank = 1 only when StatusRank < 1.

For customer replies (message.received) keep each eventId you have handled in a table with a unique index, and skip a repeat.

On .NET Framework 4.x or VB.NET

Plenty of billing software still runs on .NET Framework, often in VB.NET. Three changes:

  • Turn on TLS 1.2 once at startup on .NET Framework 4.5 to 4.7, or the call fails with "could not create SSL/TLS secure channel".
  • No System.Text.Json: use Newtonsoft.Json (or the System.Text.Json NuGet package).
  • No File.ReadAllBytesAsync: use File.ReadAllBytes.
Imports System.IO
Imports System.Net
Imports System.Net.Http
Imports System.Net.Http.Headers
Imports Newtonsoft.Json

' Once, when the program starts:
' ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol Or SecurityProtocolType.Tls12

Private Shared ReadOnly Http As New HttpClient() With {.Timeout = TimeSpan.FromSeconds(30)}

' Returns the HTTP status: 202 = taken; 429 or 5xx = retry later with the same key; any other = refused.
' A timeout throws TaskCanceledException: treat it as "retry later with the same key".
Public Async Function SendInvoicePdfAsync(apiKey As String, toNumber As String, partyName As String,
                                          invoiceNo As String, pdfPath As String, idemKey As String) As Task(Of Integer)
    Dim msg As New Dictionary(Of String, Object) From {
        {"to", toNumber}, {"template", "invoice_pdf"},
        {"params", New String() {Clean(partyName), Clean(invoiceNo)}}, {"reference", idemKey}}

    Using form As New MultipartFormDataContent()
        form.Add(New StringContent(JsonConvert.SerializeObject(msg)), "message")
        Dim pdf As New ByteArrayContent(File.ReadAllBytes(pdfPath))
        pdf.Headers.ContentType = New MediaTypeHeaderValue("application/pdf")
        form.Add(pdf, "document", Path.GetFileName(pdfPath))

        Using req As New HttpRequestMessage(HttpMethod.Post, "https://talkback.js1software.net/v1/messages")
            req.Content = form
            req.Headers.Authorization = New AuthenticationHeaderValue("Bearer", apiKey)
            req.Headers.Add("Idempotency-Key", idemKey)
            Using res = Await Http.SendAsync(req)
                Return CInt(res.StatusCode)
            End Using
        End Using
    End Using
End Function

' Same rule as Clean() in step 1: never empty, one line, no runs of spaces.
Private Shared Function Clean(s As String) As String
    If String.IsNullOrWhiteSpace(s) Then Return "-"
    Return Text.RegularExpressions.Regex.Replace(s.Trim(), "\s+", " ")
End Function

A dictionary is used for the JSON because To is a reserved word in VB. Keep the API key in your database or settings, not in the source code.

Test it without sending anything

A test key (tb_test_…) goes through every check on the request, but nothing reaches anyone and nothing is charged. Special numbers force each path your code must handle:

to You get
910000000402 402 INSUFFICIENT_BALANCE
910000000502 422 PROVIDER_REFUSED
910000000999 202, then a failed webhook
any other valid number 202, then a delivered webhook

A test key does not check your template (its name, how many values, whether it needs a PDF). Before switching a customer to the live key, call GET /v1/readiness?template=invoice_pdf with the live key; it answers "ready": true or lists what is missing.

Common questions

Can I send the PDF as a normal message, without a template?

Not from your software. A message your business starts must use an approved template. Free text is allowed only within 24 hours after the customer has written to you, and that is a conversation, not a bill run.

What happens if the customer's number is not on WhatsApp?

The send is still taken (202), and the webhook comes back failed with NOT_REACHABLE. Show those to the user so they can phone the party or correct the number. A message that was sent and failed is still counted.

How big can the PDF be?

Up to 5 MB through Talkback. A one- or two-page invoice from a report tool is usually 50 to 300 KB; if yours is several MB, the logo image is probably too large.

Should the API key go in the desktop program?

Keep it out of the source code and out of anything you hand to the end customer's staff. Store it in your database or server settings, and give each of your customers their own key, so one can be withdrawn without touching the others.

Sending through JS1 Talkback

JS1 Talkback is the sending service behind these examples, built in India for software makers and their business customers:

  • Messages go from your customer's own WhatsApp number, and their staff read replies in a shared inbox.
  • A REST API with test keys, idempotent sends, signed webhooks and a readiness check, as above. The full reference is the API guide.
  • No monthly fee. You buy prepaid messages; see the current price per message.

Writing in PHP instead? See how to send an invoice PDF on WhatsApp from PHP and Laravel.

See the developer page and get a test key.


← Back to all posts