The core idea
You can usually estimate what a message will cost when you send it, because you know its category and its destination. The price that actually bills is assigned after delivery, on the status webhook. That's the one that matches the invoice.
One message, end to end
You send an order-shipped template to a customer in Mexico.
| Time | What happens | What you know about the cost |
|---|---|---|
| 09:14:07 | The send call returns a message ID | Your estimate |
| 09:14:08 | Meta delivers the message | Your estimate |
| 09:14:09 | A status webhook arrives with a pricing object | The charge |
For most messages this takes a couple of seconds, and the billed price matches the estimate. The webhook matters for the ones where it doesn't: a template that was reclassified, a conversation that qualified as a free entry point, a rate that changed between send and status.
Sometimes the gap is much longer, and occasionally the status never arrives at all.
When the message fails
The send call succeeding doesn't mean the message arrived. The API returns a message ID for sends that later fail: a number that isn't on WhatsApp, a user who blocked you, a quality limit. What comes back then is a failed status, with error details and no pricing object.
Meta charges per delivered message, so a failed message costs nothing. This is why cost reporting has to be built on statuses rather than sends. A send log counts the failure as a message and the invoice doesn't, so the two disagree by exactly your failure rate.
Reading the pricing object
"pricing": {
"billable": true,
"pricing_model": "PMP",
"type": "regular",
"category": "utility"
}
Three fields matter, and one of them is being deprecated. The first two are easy to conflate. category says what the message is; type says how this particular one was charged. A service message today carries category: service and type: free_customer_service, and October changes only the second.
type
This is the authoritative field. A value of regular means the message is billable, and there are also explicitly free types:
free_customer_service: a service message inside the window. Free today; this is the type that October 2026 retires.free_entry_point: a conversation that began from a qualifying entry point, such as a click-to-WhatsApp ad. It opens a 72-hour window where every message is free.
category
Which of the categories from Chapter 0.2 applies. Combined with the recipient's country, this selects the rate.
billable
A legacy boolean that Meta is deprecating. Where type is present, type wins. Don't build new logic on billable; read it only as a fallback for payloads that predate type.
Why this matters in practice
Your send log isn't your bill. Counting API calls tells you how many messages you sent, not what they cost. Any cost report has to be built from status webhooks. If you drop status webhooks, you lose billing data, not just delivery data.
The status webhook carries the evidence that determines what billed. Preserve the message's own pricing timestamp separately from the time your system processed that webhook. A callback processed after midnight must not move an earlier message onto a newer rate card.
Late statuses leave messages unpriced. A message whose status hasn't arrived has been delivered but has no price attached to it yet. Count it as unknown rather than free, and give the system that tracks spend a policy for these, not just a happy path.
If you bill through Kapso
Kapso builds that report from status webhooks already, and breaks the spend down by category, so you don't have to write it.
What it can't do is reach into your systems. If you attribute cost per customer, per campaign or per conversation in your own database, that part is still yours, and it still has to be keyed on the status webhook rather than the send.
We're also working on exposing these costs directly through the platform APIs, so more of this becomes something you query rather than something you build.