WhatsApp Cloud API sends you no invoice until it is too late

Meta does not send you a WhatsApp invoice you can act on in time. Billing is per message, priced by template category and recipient country, and the rate card changes on Meta's schedule rather than yours. The API response to a send contains a message id and no price. The delivery-status webhook carries a pricing object — and that has no amount in it either. What you get is a billable flag and a category, which you multiply against a rate card you maintain yourself. If you are not capturing that webhook, you have no per-message cost data and no way to reconstruct it, because Meta exposes no per-message billing history through the API.
This came out of a booking platform where WhatsApp carried one-time codes. The webhook receiver already existed, validated Meta's signature correctly, and threw the entire payload away. Nobody noticed, because nothing about the system looked broken — it simply could not answer what a message cost.
What Meta actually charges for
WhatsApp Business Platform moved from per-conversation to per-message billing on 1 July 2025. Every template message is priced individually along two axes:
- Category — marketing, utility, authentication or service. Marketing is by far the most expensive; service messages are free.
- Recipient country — the same template costs different amounts depending on where the handset is.
Two further wrinkles matter more than they first appear. Meta re-prices on its own schedule, so a rate you hardcoded is a rate that will silently go stale. And utility templates sent inside an open 24-hour customer service window are not charged at all — meaning identical traffic can cost different amounts depending on whether the customer happened to message you first.
None of that is visible from the sending API.
What the send response does not contain
Post a template message to the Cloud API and this comes back:
{
"messaging_product": "whatsapp",
"contacts": [ { "input": "9715XXXXXXXX", "wa_id": "9715XXXXXXXX" } ],
"messages": [ { "id": "wamid.HBgMOTcx...FQIAERgSODQ4..." } ]
}That is the whole response. It acknowledges acceptance — not delivery, and certainly not billing. At this point you do not know whether the message will arrive, whether it will be charged, or which category it will be charged under. The last one is not rhetorical: Meta can reclassify a template after approving it.
Where the price actually comes from
Subscribe to the messages webhook field and each outbound message produces a series of status callbacks. The sent status carries billing:
{
"entry": [ { "changes": [ { "field": "messages", "value": {
"statuses": [ {
"id": "wamid.HBgMOTcx...",
"status": "sent",
"recipient_id": "9715XXXXXXXX",
"pricing": {
"billable": true,
"pricing_model": "PMP",
"category": "utility",
"type": "regular"
}
} ]
} } ] } ]
}Four fields matter:
billable— whether Meta is charging for this message at all.pricing_model—PMPis per-message pricing. Anything else means the account has not moved to the current model and the arithmetic here does not apply.category— Meta's classification, not the one you submitted.type—regularfor a charged message,free_customer_servicefor one that landed inside an open service window.
Notice what is missing: no price, no currency, no running total. Meta tells you a message is chargeable and how it is classified. Turning that into money is your problem.
So cost is derived, not reported
Cost is billable AND category AND country, resolved against a rate card you own. That produces two numbers with different confidence, and collapsing them into one is the mistake to avoid:
- Estimated — computed at send time from the category you intended. Instant, occasionally wrong.
- Actual — computed when the webhook lands, from Meta's own billable flag and category. Authoritative on whether and which; still dependent on your rate card for how much.
Report both and label them. A dashboard showing a single number called "cost" is quietly wrong in the gap between sending and the webhook arriving — which, on a marketing broadcast, is exactly the window somebody is watching.
The rate card has to be effective-dated
Because Meta re-prices, a rate needs a start date and past rows must never be edited. Otherwise correcting today's price silently rewrites what last quarter cost:
Schema::create('message_rates', function (Blueprint $table) {
$table->id();
$table->string('country_code', 5); // calling code, e.g. 971
$table->string('category', 20); // MARKETING | UTILITY | AUTHENTICATION | SERVICE
$table->decimal('price_usd', 10, 6)->nullable();
$table->decimal('price_local', 10, 6);
$table->date('effective_from');
$table->timestamps();
$table->unique(['country_code', 'category', 'effective_from']);
});Resolution is the newest row whose effective_from is on or before the message date:
public function rateFor(?string $country, ?string $category, ?CarbonInterface $on = null): ?float
{
if ($country === null || $category === null) {
return null;
}
$rate = MessageRate::query()
->where('country_code', $country)
->where('category', strtoupper($category))
->where('effective_from', '<=', ($on ?? now())->toDateString())
->orderByDesc('effective_from')
->first();
return $rate?->price_local === null ? null : (float) $rate->price_local;
}One caution on populating it: reseller rate summaries are convenient and are not Meta's rate card. Take the numbers from your own account in Business Manager and treat any figure copied from a blog post — including this one — as provisional until you have checked it there.
Visibly unknown, never silently wrong
That method returns null, not zero, when the card cannot price something. The distinction is the whole design. A billable message your rate card does not cover is a real hole in the total, and a zero hides it perfectly — you get a number that looks complete and is not.
'missing_rate' => MessageLog::where('billable', true)
->whereNull('cost_actual')
->count(),Put that count on the dashboard beside the total. Anything above zero means the figure next to it is understated and somebody needs to add a rate row. A cost report that cannot tell you how much it does not know is not a cost report.
Recording the send
Open the ledger row when Meta accepts the message, keyed on the returned id:
$wamid = $response->json('messages.0.id');
if ($wamid !== null) {
MessageLog::firstOrCreate(['wamid' => $wamid], [
'direction' => 'outbound',
'template_name' => $payload['template']['name'] ?? null,
'expected_category' => $expectedCategory, // what WE think it is
'country_code' => $country,
'recipient_hash' => hash('sha256', $payload['to']),
'recipient_last4' => substr($payload['to'], -4),
'status' => 'accepted',
'cost_estimated' => $rateCard->rateFor($country, $expectedCategory),
]);
}Two decisions worth copying. A failed send has no message id and no cost, so only accepted messages get a row and there is nothing to reconcile later. And the recipient is a hash plus last four digits — enough to answer "did this customer get their code?" without a phone number existing anywhere in the reporting tables.
Reconciling from the webhook
The handler stays idempotent, keeps the lifecycle honest, and captures pricing:
// Meta redelivers, and a status can arrive for a message this system never
// sent — another tool holding the same token. A skeleton row keeps its cost
// data rather than discarding it.
$message = MessageLog::firstOrCreate(
['wamid' => $status['id']],
['direction' => 'outbound', 'status' => 'accepted']
);
$updates = [];
// Statuses arrive out of order — a read can beat the sent it follows.
// Only ever move forward through the lifecycle.
$rank = ['accepted' => 0, 'sent' => 1, 'delivered' => 2, 'read' => 3];
$state = $status['status'];
if ($state !== 'failed' && isset($rank[$state])
&& $rank[$state] > ($rank[$message->status] ?? 0)) {
$updates['status'] = $state;
}
if (isset($status['pricing'])) {
$pricing = $status['pricing'];
$updates['billable'] = $pricing['billable'] ?? null;
$updates['pricing_model'] = $pricing['pricing_model'] ?? null;
$updates['pricing_type'] = $pricing['type'] ?? null;
$updates['category'] = isset($pricing['category'])
? strtoupper($pricing['category'])
: null;
$updates['cost_actual'] = ($pricing['billable'] ?? null) === false
? 0
: $rateCard->rateFor($message->country_code, $pricing['category'] ?? null);
}
$message->update($updates);The monotonic status check is not defensive habit. Meta delivers statuses over separate HTTP calls with independent retries, so a retried sent can land after the delivered it precedes. Without the rank comparison a message that reached the customer gets demoted, and the delivery rate under-reports for reasons nobody can trace.
Three things that will make your totals wrong
Counting at send instead of at status
A failed message is never billed. Summing rows at creation time inflates the total by every failure. Sum the ledger with failures excluded and let the webhook decide what counts.
Trusting your own category
Meta can reclassify a template after approval, and utility to marketing is roughly a sixfold increase. This is why expected_category and category are separate columns rather than one overwriting the other — the disagreement is the signal:
MessageLog::whereNotNull('category')
->whereColumn('category', '!=', 'expected_category')
->selectRaw('template_name, category, count(*) as messages')
->groupBy('template_name', 'category')
->get();Meta also fires a message_template_category_update webhook when it does this. Subscribe and alert — it is the only notice you get, and the alternative is finding out from a bill.
Assuming the free window is rare
Utility messages sent inside an open 24-hour service window cost nothing, and the webhook reports it as type: free_customer_service. Treat those as a first-class outcome rather than an anomaly, or a month where costs fell because customers happened to message first will look like a reporting bug.
What the ledger then makes possible
Cost data is not the goal. It is the precondition for two controls that cannot exist without it.
The first is a spend cap that knows the difference between kinds of traffic. A budget that blocks every send at its limit will eventually block a login, which is why marketing deserves a hard stop and authentication never does — a campaign that pauses is the feature working, an undelivered one-time code is a locked-out customer.
The second is fraud. An unauthenticated endpoint that sends a message costs money per request, which makes it a payout mechanism for anyone with premium-rate numbers to point it at. Rate limiting alone does not close that, and the ceiling turns out to be a country allowlist that fails closed.
Both get their own posts. Both are only enforceable once you know what a message costs.
What it looks like when it works
One template message, watched end to end on a live account:
| Moment | State | Estimated | Actual |
|---|---|---|---|
| Send accepted | accepted | 0.057658 | pending |
| sent webhook (t+0s) | billable, PMP, regular, UTILITY | 0.057658 | 0.057658 |
| delivered webhook (t+2s) | delivered | 0.057658 | 0.057658 |
Two seconds from send to delivered, pricing confirmed on the first status. The estimate matched because the category we intended was the one Meta charged — the outcome you want, and precisely the thing you cannot assume without recording both.
Check your own account
Two questions, both answerable in a minute.
Are you receiving status webhooks at all? Send one message and look for a row with a non-null billable. If it is still null after a minute, your callback URL is not receiving them and every cost figure you hold is an estimate wearing a confident label.
Is your billing model current? Any pricing_model other than PMP means the account is on an older model.
php artisan tinker --execute='
$m = MessageLog::latest()->first();
echo ($m->billable === null
? "NO PRICING WEBHOOK RECEIVED"
: "model={$m->pricing_model} type={$m->pricing_type}").PHP_EOL;
'And if these messages are triggered by an unauthenticated endpoint — a one-time code, a password reset — the rate limit in front of it is not a performance control, it is the spend cap. Worth confirming it admits the number it claims to, because in Laravel it frequently does not.
The ledger, the effective-dated rate card and the fail-closed country allowlist described here are packaged as laravel-whatsapp-cost-control, MIT licensed, for Laravel 12 and 13. Sending is already well served by existing packages; this one covers the part that decides what the sending costs.
dineshstack/laravel-whatsapp-cost-control
Frequently Asked Questions
Why does the WhatsApp send response not include a price?
Where does WhatsApp tell me whether a message was billable?
Why is the category on my message different from the one I sent?
Do failed WhatsApp messages cost money?
Why did a utility message cost nothing?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?



