WhatsApp Cloud API pricing is per delivered message, priced by the message's category and the recipient's country, with a few free cases. To know what you'll spend, estimate before a campaign from Meta's rate card, record what Meta says it charged from the pricing object on status webhooks, and reconcile both against Meta's pricing_analytics endpoint each month. This post covers the rules that matter in code, with a small tested estimator and a charge report.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown. The rates in the tests are made up; use Meta's current rate card for real ones.
The rules, from Meta's pricing page
From Meta's pricing page:
- Only delivered messages cost money. "Businesses are only charged when a message is delivered."
- Category sets the price. Marketing, utility and authentication are template categories; service messages are ordinary replies inside the customer service window. Some markets also have an authentication-international rate.
- Country sets the price too. Rates are per market, by the recipient's country calling code, with an "Other" rate for countries not listed.
- Service messages have a monthly free tier. Meta "provides every business phone number with a free tier of 1,000 service messages per month", and unused messages don't roll over.
- Utility messages sent inside an open customer service window are charged from October 1, 2026; they were free before.
- Free entry point windows are free. When a customer starts the chat from a Click to WhatsApp ad, the page says "Meta does not charge for marketing, utility, authentication or service messages" while the window is open.
- Utility and authentication have volume tiers, so the rate can fall as monthly volume grows. Meta says tiers "will be determined solely by Meta."
Rates are published in your billing currency. For India, Meta also asks eligible businesses to move to INR billing by December 31, 2026.
Estimate before you send
A campaign estimate needs three things: the recipients' markets, the template's category, and the rate card. The market comes from the calling-code prefix, and the longest match has to win, because "1" is the US and Canada while "1876" is Jamaica:
// Estimate and account for WhatsApp message charges. Rates come from Meta's
// rate card for your billing currency; the numbers in the tests are made up.
export type Category = "marketing" | "utility" | "authentication" | "service";
export type RateCard = Record<string, Partial<Record<Category, number>>>; // market -> category -> price
export type Markets = Record<string, string>; // calling-code prefix -> market
// Longest matching calling-code prefix wins: "1" is the US, "1876" Jamaica.
export const marketOf = (phone: string, markets: Markets): string => {
const digits = phone.replace(/\D/g, "");
for (let len = Math.min(4, digits.length); len > 0; len--) {
const market = markets[digits.slice(0, len)];
if (market) return market;
}
return "Other";
};
const priceOf = (rates: RateCard, market: string, category: Category) => {
const price = rates[market]?.[category] ?? rates.Other?.[category];
if (price === undefined) throw new Error(`No ${category} rate for ${market}`);
return price;
};
// Before sending: what a template campaign will cost if every message is delivered.
export const estimateCampaign = (recipients: string[], category: Category, rates: RateCard, markets: Markets) => {
const byMarket: Record<string, { messages: number; cost: number }> = {};
for (const phone of recipients) {
const market = marketOf(phone, markets);
const row = (byMarket[market] ??= { messages: 0, cost: 0 });
row.messages++;
row.cost += priceOf(rates, market, category);
}
const total = Object.values(byMarket).reduce((sum, r) => sum + r.cost, 0);
return { byMarket, total };
};
// After sending: the `pricing` object on status webhooks says what was charged.
export type StatusEvent = {
id: string;
recipient_id: string;
pricing?: { type: string; category: string };
};
export const chargesFromStatuses = (events: StatusEvent[], rates: RateCard, markets: Markets) => {
const seen = new Set<string>();
const lines: { id: string; market: string; category: string; type: string; cost: number }[] = [];
for (const event of events) {
// Pricing rides on more than one status for the same message; count it once.
if (!event.pricing || seen.has(event.id)) continue;
seen.add(event.id);
const market = marketOf(event.recipient_id, markets);
const { type, category } = event.pricing;
const cost = type === "regular" ? priceOf(rates, market, category as Category) : 0;
lines.push({ id: event.id, market, category, type, cost });
}
return lines;
};
export const summarise = (lines: ReturnType<typeof chargesFromStatuses>) => {
const rows: Record<string, { messages: number; charged: number; free: number; cost: number }> = {};
for (const line of lines) {
const row = (rows[`${line.market} ยท ${line.category}`] ??= { messages: 0, charged: 0, free: 0, cost: 0 });
row.messages++;
if (line.cost > 0) row.charged++;
else row.free++;
row.cost += line.cost;
}
return rows;
};
Two choices in estimateCampaign are deliberate:
- A missing rate throws. An estimator that silently treats an unknown market or category as zero tells you a campaign is cheaper than it is.
- It assumes every message is delivered. That's the upper bound; undelivered messages cost nothing, so the real bill can only be lower.
The estimate ignores volume tiers. If you send enough utility or authentication messages to reach a lower tier, treat the list rate as the ceiling.
Record what Meta says it charged
After sending, the status webhooks tell you how each message was priced. Meta's status webhook reference includes a pricing object on the sent status and on one of delivered or read:
"pricing": {
"billable": true,
"pricing_model": "PMP",
"type": "regular",
"category": "marketing"
}
-
typeis the field to trust:regularmeans charged; values such asfree_customer_serviceandfree_entry_pointmean it wasn't. -
categoryincludes more than the four you price, such asmarketing_lite,authentication-international,referral_conversionand group categories. Map each one to a rate explicitly; the estimator throws on any it doesn't know. -
billableis being deprecated. Don't build on it.
chargesFromStatuses above counts each message once, because the pricing object arrives on more than one status for the same message. It treats regular as charged at the market's rate and anything else as free.
Reconcile with Meta's numbers
Your report is an estimate built from list rates. Meta's own figures come from the WABA's pricing_analytics field:
curl "https://graph.facebook.com/<VERSION>/<WABA_ID>?fields=pricing_analytics.start(<UNIX_START>).end(<UNIX_END>).granularity(DAILY).dimensions(PRICING_CATEGORY,COUNTRY)" \
-H "Authorization: Bearer $TOKEN"
It returns volume and an approximate cost per category, type, country or phone number, in the account's currency. Compare it with your own report each month:
- A big gap in one market usually means a missing or outdated rate in your rate card.
- Service messages are where your count and Meta's are most likely to disagree, because the 1,000-message free tier applies per number per month. Count them, but use Meta's cost figure for the bill.
- Tiers make Meta's cost lower than list rates once volume grows.
The older conversation_analytics field stops being available on November 1, 2026, so build on pricing_analytics.
Checklist
- Charge only delivered messages, by category and by the recipient's market.
- Match markets by the longest calling-code prefix.
- Fail loudly on a missing rate; never default to free.
- Estimate campaigns at list rates, as an upper bound.
- Record the
pricingobject once per message, and trusttype, notbillable. - Map every category value, including
marketing_lite,authentication-internationaland the group categories. - Reconcile monthly with
pricing_analytics, and use Meta's cost for billing. - Keep the rate card in your billing currency, and update it when Meta publishes a new one.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: message statuses arrive out of order and rate limits in practice.
Top comments (0)