If you send WhatsApp messages through Meta's Cloud API, the invoice is not a flat fee and not a price per seat. Three things decide it: the category of each message, the country of the person who receives it, and, since 1 October 2026, how many service replies each of your phone numbers sent that month. This post walks through the model as Meta documents it, then turns an open CSV of the rate card into a monthly estimate in about 30 lines of Python.
The model in six rules
1. You pay per delivered message. Since 1 July 2025 Meta charges per delivered message. The old "24 hour conversation" is gone as a billing unit. A message that fails is not charged.
2. Every message has a category. marketing, utility, authentication or service. Templates get a category when Meta reviews them, Meta can change it later, and you pay the category that applies at the moment you send. Free-form messages you send inside the 24 hour customer service window are service.
3. The recipient's country sets the rate. Meta maps the recipient's calling code to a market. Thirty-nine countries have their own row; the rest fall into a regional row such as "Rest of Western Europe" (Austria, Belgium, Denmark and others) or into "Other". A business in Germany messaging a customer in the US pays the North America rate.
4. Service replies: 1,000 free per phone number per month. Since 1 October 2026, service messages are billed again after a free tier of 1,000 per business phone number per month. The count resets at midnight in your account's timezone and unused messages do not roll over. Without a payment method, Meta delivers the free 1,000 and stops delivering service messages after that. Eligible government and non-profit organisations keep service messages free beyond the tier until 31 December 2027.
5. Utility templates inside an open window are billed again. They were free from July 2025 until 30 September 2026. Since 1 October 2026 they are charged at the utility rate and they do not use the free service tier. Inside an open window, a free-form reply is never the more expensive option.
6. Free entry points stay free. When a user messages you from a Click to WhatsApp ad on the Android or iOS app and you reply within 24 hours, that reply opens a free entry point window. Meta says it "may remain open for up to 7 days", and messages in it are not charged in any category.
Two more facts matter for budgeting. Utility and authentication get volume tiers: past a monthly threshold per market, the rate drops, counted across all accounts in your business portfolio. And Meta changes rates only on 1 January, 1 April, 1 July and 1 October, with at least a month's notice for rate card changes. So an estimate from list rates is an upper bound that holds for a whole quarter, which is what you want for a budget.
The data
Meta publishes the rate card per currency as a file that sits behind expiring CDN links. We keep it as plain CSV and JSON, one row per market, with the date the card took effect and the date we last compared it with Meta's file. The EUR file, shortened (the source column holds the URL of Meta's pricing page):
currency,effective_date,checked_on,source,market,type,iso_codes,calling_code,marketing,utility,authentication,authentication_international,service
EUR,2026-10-01,2026-10-27,https://developers.facebook.com/...,Netherlands,country,NL,31,0.1323,0.0414,0.0414,,0.0414
EUR,2026-10-01,2026-10-27,https://developers.facebook.com/...,Rest of Western Europe,region,AT BE CH DK FI IE NO PT SE,32 41 43 45 46 47 351 353 358,0.049,0.0142,0.0142,,0.0142
EUR,2026-10-01,2026-10-27,https://developers.facebook.com/...,Other,other,,,0.05,0.0064,0.0064,,0.0064
Country rows have one ISO code, region rows list their members, and Other catches everything else. Rates are EUR per delivered message, excluding VAT; an empty cell means Meta publishes no such rate for that market. On the current card the service rate equals the utility rate in every market.
The files live in github.com/tobachat/whatsapp-api-rates: data/latest/EUR.csv and a JSON twin data/latest/EUR.json for each of the 16 currencies Meta bills in, plus volume tiers, a country to market table and Meta's original files per rate card. Read them from raw.githubusercontent.com: no key, and they send Access-Control-Allow-Origin: *, so a browser can fetch them too. The data is CC BY 4.0 and citable as 10.5281/zenodo.23710440. A readable table is on the WhatsApp API pricing by country page.
A monthly estimate for one market
import csv
import io
import urllib.request
CSV_URL = "https://raw.githubusercontent.com/tobachat/whatsapp-api-rates/main/data/latest/EUR.csv"
FREE_SERVICE_PER_NUMBER = 1000 # per business phone number, per calendar month
def load_rates(url=CSV_URL):
with urllib.request.urlopen(url) as resp:
return list(csv.DictReader(io.StringIO(resp.read().decode("utf-8"))))
def market_for(rows, iso):
iso = iso.upper()
for row in rows:
if iso in row["iso_codes"].split():
return row
return next(r for r in rows if r["market"] == "Other")
def monthly_cost(row, volume):
"""volume: messages one business phone number sent to this market in a month."""
free = FREE_SERVICE_PER_NUMBER
total = 0.0
for category, sent in volume.items():
billable = max(0, sent - free) if category == "service" else sent
total += billable * float(row[category])
return round(total, 2)
if __name__ == "__main__":
rows = load_rates()
volume = {"service": 3000, "utility": 2000, "marketing": 1000}
for iso in ["NL", "DE", "FR", "GB", "BE", "US", "BR", "IN"]:
row = market_for(rows, iso)
print(f"{iso} {row['market']:<24} EUR {monthly_cost(row, volume):>8.2f}")
One number, one month: 3,000 service replies, 2,000 utility templates (order updates) and 1,000 marketing templates, all to the same country:
NL Netherlands EUR 297.90
DE Germany EUR 295.50
FR France EUR 170.40
GB United Kingdom EUR 125.40
BE Rest of Western Europe EUR 105.80
US North America EUR 31.90
BR Brazil EUR 74.20
IN India EUR 14.70
Same traffic, more than nine times the bill between the Netherlands and the US. The Netherlands tops the list because it has the highest marketing rate of all 47 markets on the EUR card (0.1323), even though Germany has the higher utility and service rate. Before 1 October 2026 the 2,000 service replies above the free tier would have cost nothing; in the Netherlands they are now EUR 82.80 of that total.
The same lookup from JavaScript
The JSON files carry the same rows as numbers, with the member countries of each region as arrays. In a browser or Node 18 and later:
const url = "https://raw.githubusercontent.com/tobachat/whatsapp-api-rates/main/data/latest/USD.json";
const card = await (await fetch(url)).json();
const rate = (iso, category) => {
const m = card.markets.find((m) => m.iso_codes.includes(iso))
?? card.markets.find((m) => m.market === "Other");
return { market: m.market, [category]: m[category], effective_date: card.effective_date };
};
console.log(rate("BR", "marketing"));
// { market: 'Brazil', marketing: 0.0625, effective_date: '2026-10-01' }
console.log(rate("ZW", "utility"));
// { market: 'Rest of Africa', utility: 0.004, effective_date: '2026-10-01' }
Swap USD for the currency your Meta account is billed in. Meta rounds each currency separately, so converting the EUR file yourself gets close but not exact.
Real audiences are mixed
You have E.164 numbers, not ISO codes, and customers in several countries. The phonenumbers package (a port of Google's libphonenumber) does the mapping. Do not map on the calling code alone: +1 is the US and Canada (North America), but also the Dominican Republic, Jamaica and Puerto Rico, which Meta puts in "Rest of Latin America". The repo's data/country_market.csv lists those extra area codes per country if you would rather do it without a library.
# pip install phonenumbers
from collections import Counter
import phonenumbers
from estimate import FREE_SERVICE_PER_NUMBER, load_rates, market_for
def estimate_mixed(rows, sent):
"""sent: (recipient_e164, category) for every message ONE business number sent in a month."""
per_market = Counter()
for number, category in sent:
iso = phonenumbers.region_code_for_number(phonenumbers.parse(number))
per_market[(market_for(rows, iso or "")["market"], category)] += 1
by_name = {r["market"]: r for r in rows}
free_left = FREE_SERVICE_PER_NUMBER
# Meta does not publish which service messages use up the free tier first.
# Spend it on the cheapest markets: that gives an upper bound.
service = sorted(
((m, n) for (m, c), n in per_market.items() if c == "service"),
key=lambda item: float(by_name[item[0]]["service"]),
)
total = 0.0
for market, n in service:
free = min(n, free_left)
free_left -= free
total += (n - free) * float(by_name[market]["service"])
for (market, category), n in per_market.items():
if category != "service":
total += n * float(by_name[market][category])
return round(total, 2)
The free tier belongs to the phone number, across all countries together, not to each country. Run the function once per business number.
What the estimate leaves out
-
Volume tiers for utility and authentication. At high volume your real rate is lower. Meta reports tier changes in an
account_updatewebhook (VOLUME_BASED_PRICING_TIER_UPDATE). The repo has the tier tables per currency indata/tiers/. - Free entry point windows. Traffic from Click to WhatsApp ads can be much cheaper than this model says.
-
authentication_international. A higher authentication rate in 18 markets that applies only to businesses Meta marks as eligible. - Meta Business Agent. Meta's own AI agent is billed per token, not per message, and stays billable inside free entry point windows.
- VAT, and whatever your provider charges on top.
Check it against what Meta actually billed
Every status webhook for a sent message carries a pricing object, on sent and on the first of delivered or read:
"pricing": {
"billable": true,
"pricing_model": "PMP",
"type": "regular",
"category": "utility"
}
type is regular when the message is charged and free_customer_service or free_entry_point when it is not. Meta plans to deprecate billable, so read type and category together. Sum those per category and market for a month, compare with the estimate and with the pricing_analytics field of the WhatsApp Business Account, and you will see quickly which assumption was off.
Rates move only on the first day of a quarter. Rerun the script after each one. The repo keeps a dated file per rate card and a changelog, and a weekly job opens a pull request when Meta's files change, so you can watch the repository and see what changed before it hits your invoice.
Tobachat builds WhatsApp customer communication software and maintains this dataset, compiled from Meta's published rate cards; it is not affiliated with Meta. This article was written with the help of AI and checked by Tobachat against Meta's pricing documentation.
Top comments (0)