The missing .NET SDK for Meta's WhatsApp Business Cloud API — more complete than Meta's own Node.js SDK: every message type, read receipts, replies, webhook security, and typed parsing.
Send text, template, image, video, audio, document, sticker, location,
contacts, and interactive buttons/lists. Verify webhooks, parse inbound
messages. No dependencies beyond System.Text.Json. Targets netstandard2.0
and net8.0.
dotnet add package WhatsAppCloud.Netusing WhatsAppCloud;
var client = new WhatsAppClient(new WhatsAppClientOptions
{
PhoneNumberId = "<phone-number-id>", // from the Meta developer console
AccessToken = "<access-token>" // system user token
});
// Free-form text (needs an open 24h customer service window)
await client.SendTextAsync("201012345678", "Hello from .NET!");
// Approved template with {{1}}, {{2}} parameters
await client.SendTemplateAsync("201012345678", "order_update", "en_US",
new[] { "Eslam", "12345" });
// Image by URL
await client.SendImageAsync("201012345678",
"https://example.com/order.jpg", caption: "Your order shipped!");
// Video, audio, document, sticker
await client.SendVideoAsync("201012345678", "https://example.com/demo.mp4");
await client.SendDocumentAsync("201012345678", "https://example.com/invoice.pdf", filename: "invoice.pdf");
// Location & contacts
await client.SendLocationAsync("201012345678", 30.0444, 31.2357, "Cairo", "Egypt");
await client.SendContactsAsync("201012345678", new[]
{
new WhatsAppContact { FormattedName = "Ahmed Hassan", FirstName = "Ahmed",
Phones = new List<ContactPhone> { new ContactPhone { Phone = "+201012345678" } } }
});
// Interactive reply buttons (up to 3)
await client.SendButtonsAsync("201012345678", "Confirm your order?",
new[]
{
new ReplyButton { Id = "confirm", Title = "Confirm" },
new ReplyButton { Id = "cancel", Title = "Cancel" }
});
// Interactive list
await client.SendListAsync("201012345678", "Pick a drink", "Open menu",
new[]
{
new ListSection
{
Title = "Drinks",
Rows = new List<ListRow>
{
new ListRow { Id = "tea", Title = "Tea" },
new ListRow { Id = "coffee", Title = "Coffee", Description = "Freshly brewed" }
}
}
});
// Reply to a specific message (works on every send method)
await client.SendTextAsync("201012345678", "Got it!", replyToMessageId: "wamid.xyz");
// Blue ticks — call when your webhook receives a message
await client.MarkAsReadAsync("wamid.xyz");Bring your own HttpClient (e.g. from IHttpClientFactory):
var client = new WhatsAppClient(options, httpClient);Media can be sent by public HTTPS URL (above), or uploaded once and reused by ID — cheaper for repeated sends (the media ID is valid for 30 days):
// Upload from disk…
var upload = await client.UploadMediaAsync("/path/to/photo.jpg", "image/jpeg");
// …or from any stream (memory, network, …)
using (var stream = File.OpenRead("/path/to/invoice.pdf"))
{
upload = await client.UploadMediaAsync(stream, "invoice.pdf", "application/pdf");
}
Console.WriteLine(upload.MediaId); // pass this to the Send*ByIdAsync methods
// Send by media ID instead of URL
await client.SendImageByIdAsync("201012345678", upload.MediaId, "Your photo!");
await client.SendVideoByIdAsync("201012345678", videoMediaId, "Watch this");
await client.SendAudioByIdAsync("201012345678", audioMediaId);
await client.SendDocumentByIdAsync("201012345678", docMediaId, filename: "invoice.pdf");
await client.SendStickerByIdAsync("201012345678", stickerMediaId);
// Look up a media object (e.g. an ID from an inbound webhook) to get a
// short-lived download URL, MIME type and size — download immediately,
// the URL expires after ~5 minutes
var info = await client.GetMediaInfoAsync(inboundMessage.MediaId);
Console.WriteLine($"{info!.MimeType} — {info.FileSizeBytes} bytes: {info.Url}");
// Free the storage when you're done with an upload
await client.DeleteMediaAsync(upload.MediaId);Size limits: images 5 MB, video/audio 16 MB, documents 100 MB, stickers 500 KB.
Verification handshake (the GET Meta sends when you save the webhook URL):
using WhatsAppCloud.Webhooks;
var challenge = WebhookSecurity.VerifyChallenge(
mode, verifyToken, challengeParam, expectedVerifyToken);
if (challenge != null) return Results.Text(challenge); // 200 with the challengeValidate inbound POSTs — never skip this on a public URL:
var signature = Request.Headers["X-Hub-Signature-256"];
var body = await new StreamReader(Request.Body).ReadToEndAsync();
if (!WebhookSecurity.IsValidSignature(appSecret, body, signature))
return Results.Unauthorized();
var payload = WebhookParser.Parse(body);
foreach (var msg in WebhookParser.GetMessages(payload))
Console.WriteLine($"{msg.From}: {msg.Text}");
foreach (var status in WebhookParser.GetStatuses(payload))
Console.WriteLine($"{status.MessageId}: {status.Status}");IncomingMessage carries MediaId for inbound media messages (feed it to
GetMediaInfoAsync for a download URL), and ReplyId/ReplyTitle for
interactive button/list replies (Text holds the tapped title).
WhatsAppClient (construct with WhatsAppClientOptions; optional own HttpClient):
| Method | Description |
|---|---|
SendTextAsync(to, body, previewUrl?, replyToMessageId?, ct?) |
Free-form text (needs an open 24h window) |
SendTemplateAsync(to, templateName, languageCode, bodyParameters?, replyToMessageId?, ct?) |
Approved template with {{1}}, {{2}}… parameters |
SendImageAsync / SendVideoAsync |
Media by public HTTPS URL, optional caption |
SendAudioAsync / SendStickerAsync |
Audio / sticker (WEBP) by public HTTPS URL |
SendDocumentAsync |
Document by URL, optional filename + caption |
SendImageByIdAsync / SendVideoByIdAsync / SendAudioByIdAsync / SendDocumentByIdAsync / SendStickerByIdAsync |
Media by uploaded media ID |
UploadMediaAsync(filePath, mimeType, ct?) / UploadMediaAsync(stream, fileName, mimeType, ct?) |
Upload media → reusable MediaUploadResult.MediaId (30 days) |
GetMediaInfoAsync(mediaId, ct?) |
Metadata + short-lived download URL for a media ID |
DeleteMediaAsync(mediaId, ct?) |
Delete an uploaded media object |
SendLocationAsync(to, latitude, longitude, name?, address?, …) |
Location pin |
SendContactsAsync(to, contacts, …) |
One or more contact cards |
SendButtonsAsync(to, bodyText, buttons, footerText?, …) |
Up to 3 interactive reply buttons |
SendListAsync(to, bodyText, buttonText, sections, footerText?, …) |
Interactive list (≤10 sections × 10 rows) |
MarkAsReadAsync(messageId, ct?) |
Blue ticks for an inbound message |
ParseSendResponse(json) |
Parse a raw API response (throws WhatsAppApiException on errors) |
Webhooks (WhatsAppCloud.Webhooks): WebhookSecurity.VerifyChallenge(…) answers
Meta's verification handshake; WebhookSecurity.IsValidSignature(appSecret, body, header)
validates X-Hub-Signature-256 (constant-time); WebhookParser.Parse(…) /
GetMessages(…) / GetStatuses(…) flatten the payload into IncomingMessage /
MessageStatus lists.
Errors: API errors throw WhatsAppApiException with Meta's Code and ErrorType;
SendMessageResult.Successful / MediaUploadResult.Successful are false when no ID came back.
- Default Graph API version is
v26.0; override withWhatsAppClientOptions.ApiVersion. - API errors throw
WhatsAppApiExceptionwith Meta'scodeandtype. SendMessageResult.MessageIdis thewamid.…you can correlate with webhook statuses.
You can — this SDK just removes the boilerplate: correct payload shapes, template components, auth headers, error mapping, webhook signature validation (constant-time), and payload parsing. PRs welcome.
MIT
