How the Billing Engine Works (charge-on-delivery)
WaDesk charges for messaging on a charge-on-delivery model: money moves only when Meta (or the underlying engine) confirms a message was actually delivered. This page is the technical reference for the engine that does it. It is written for a platform admin or developer.
One place, one truth. All message money moves through a single service: app/Services/MessageBillingService.php. There is no other code path that debits or refunds a wallet for a message. If you want to understand the money, you only have to read one class.
The three core methods
settleDelivered(workspaceId, wamid, toPhone, category, provider, source)
- Called on the DELIVERED (or READ) webhook for a message.
- Charges the wallet exactly once per message id (
wamid), at the price for the destination country × category (e.g. India marketing, US utility). - Idempotent:
message_charges.wamidis a UNIQUE column, so a webhook that fires two, three or five times still charges once. Duplicate delivery events are a no-op. - Records a
message_chargesrow — the single source of P&L (revenue and cost) for that message. - Deducts money from the wallet, and never takes the balance below zero.
settleFailed(workspaceId, wamid)
- Called when Meta reports the message failed / undelivered.
- Reverses the exact charge that was taken for that
wamid, so customers never pay for messages that did not arrive. - Idempotent: reversing an already-refunded (or never-charged) message is safe and does nothing extra.
precheck(workspace, count)
- A LAUNCH gate, run before a campaign, broadcast or scheduled blast goes out.
- If a reseller wallet cannot cover the estimated cost of
countmessages, the send is BLOCKED before a single message leaves — the user is told to "top up to send". - This closes the "empty wallet still sends free" leak. Because charge-on-delivery only charges after a message ships, without this gate a customer with an empty wallet could otherwise fire an entire blast that gets recorded as free (see free cases below).
The per-client switch
MessageBillingService::payPerMessageFor(workspace) decides whether a given workspace is billed per message:
- TRUE for reseller workspaces (flagged by
workspaces.bill_to_platform_credit): always charge the wallet on delivery; the plan's message-limit is ignored. - FALSE for meta_direct workspaces: normal plan quota applies; there is no wallet charge for messages.
This single boolean is what makes the same delivery webhook behave as "resell and charge" for one customer and "just count against the plan" for another.
The 7 settle points
Every place a delivery status can arrive calls the same engine. These are the seven wired settle points:
| # | Channel / path | Where it calls the engine |
|---|---|---|
| 1 | WABA (Cloud API) | WaWebhookController::applyStatus |
| 2 | Twilio | TwilioStatusController |
| 3 | Broadcasts | BroadcastsController::nodeMessageStatus |
| 4 | Campaigns | WaCampaignsController::nodeContactStatus |
| 5 | Scheduled sends | ScheduledController::updateContactStatus |
| 6 | Chat (1:1) | ChatController::nodeChatMessageStatus |
| 7 | Unofficial-API flow / auto-reply mirror | WaInboundController |
Each of these receives a delivery/failure event, resolves the wamid, and calls settleDelivered or settleFailed. Because they all funnel into MessageBillingService, pricing, idempotency and refunds behave identically no matter which channel produced the message.
The ledger: message_charges
This table is the P&L record. One row per message id.
| Column | Meaning |
|---|---|
workspace_id | The customer (workspace) that owns the message. |
wamid | The provider message id. UNIQUE — this is what makes charging idempotent. |
to_country | Destination country, resolved from the recipient phone; half of the price key. |
category | Message category (marketing / utility / authentication / service); the other half of the price key. |
credits | The amount charged, in money-minor (cents). This is what left the wallet. |
revenue_minor | Your selling price for this message, in money-minor — what the customer paid you. |
cost_minor | Meta's wholesale cost for this message, in money-minor — what Meta bills you. Margin = revenue_minor − cost_minor. |
status | One of free (rate 0 or within-quota), charged (wallet debited), or refunded (delivery failed, charge reversed). |
Refunds, idempotency & "free" cases
- Refund-on-failed. A failed delivery triggers
settleFailed, which reverses the precise charge and flips the row torefunded. Customers are never charged for messages that did not arrive. - Idempotency. Both charge and refund are keyed on the unique
wamid. Repeated webhooks — common with Meta — never double-charge or double-refund. - Free case: rate 0. Service messages and free-entry-point / within-24h-window replies price at 0 and are recorded as
free. - Free case: within plan quota (meta_direct). These workspaces have
payPerMessageFor = FALSE; their messages count against the plan and are never wallet-charged. - Free case: empty wallet at delivery. If a reseller wallet is empty when the delivery webhook lands, the charge is recorded as
freebecause the message already went out — the engine will not push the wallet negative. This is precisely why the LAUNCH precheck matters: it stops the blast at launch, before delivery, so an empty wallet cannot ship free traffic.
End-to-end sequence
- Send — a message is dispatched (after
precheckpassed, for blasts). - Meta / engine delivers the message to the recipient.
- Delivery webhook arrives at one of the 7 settle points.
settleDeliveredcharges the wallet once and writes themessage_chargesledger row.- If it later fails,
settleFailedreverses that exact charge and marks the rowrefunded.