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.melink 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 itsRetry-Afterheader asks for. - Check
remainingin each202answer. When it gets low, tell someone before the batch stops with402 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
readcan come beforedelivered. So only ever move a row forward, and letfailedwin over everything. That is whatStatusRankis 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
deliveredevent can reach you before your code has stored the202answer. That is why we match onreference(our own key, saved before the send) and not on the message id. When you store the202, move forward the same way: always setMessageId, and setStatus = 'submitted', StatusRank = 1only whenStatusRank < 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: useFile.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