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.wamid is 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_charges row — 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 count messages, 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 / pathWhere it calls the engine
1WABA (Cloud API)WaWebhookController::applyStatus
2TwilioTwilioStatusController
3BroadcastsBroadcastsController::nodeMessageStatus
4CampaignsWaCampaignsController::nodeContactStatus
5Scheduled sendsScheduledController::updateContactStatus
6Chat (1:1)ChatController::nodeChatMessageStatus
7Unofficial-API flow / auto-reply mirrorWaInboundController

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.

ColumnMeaning
workspace_idThe customer (workspace) that owns the message.
wamidThe provider message id. UNIQUE — this is what makes charging idempotent.
to_countryDestination country, resolved from the recipient phone; half of the price key.
categoryMessage category (marketing / utility / authentication / service); the other half of the price key.
creditsThe amount charged, in money-minor (cents). This is what left the wallet.
revenue_minorYour selling price for this message, in money-minor — what the customer paid you.
cost_minorMeta's wholesale cost for this message, in money-minor — what Meta bills you. Margin = revenue_minor − cost_minor.
statusOne 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 to refunded. 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 free because 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

  1. Send — a message is dispatched (after precheck passed, for blasts).
  2. Meta / engine delivers the message to the recipient.
  3. Delivery webhook arrives at one of the 7 settle points.
  4. settleDelivered charges the wallet once and writes the message_charges ledger row.
  5. If it later fails, settleFailed reverses that exact charge and marks the row refunded.
WaDesk Documentation