Block the campaign at the cap. Never block the one-time code

A spend cap that blocks every WhatsApp send when the budget runs out is a cap that will eventually block a login. The category that ran the budget dry is almost never the category that gets hurt: a marketing broadcast overshoots, the cap trips, and the next one-time code — costing a fraction of a cent — is refused. The customer standing at that moment sees an app that will not let them in, over a budget decision they were never part of. The fix is not a bigger budget. It is admitting that the four WhatsApp categories are not the same kind of traffic, and that only one of them deserves a hard stop.
This post is about that asymmetry as a deliberate design decision — including the part that looks inconsistent until you see why: the budget guard and the fraud guard sitting in the same send funnel with opposite failure modes, both correct.
Four categories, two kinds of traffic
Meta prices WhatsApp messages in four categories, and the pricing webhook tells you which one each send was charged under. From a budget's point of view they collapse into two groups:
- Discretionary — MARKETING. Somebody chose to run this campaign. Stopping it mid-flight costs reach, not function. It is also the expensive category, routinely several times the price of the others, which is why it is the one that empties budgets.
- Functional — AUTHENTICATION, UTILITY, SERVICE. Nobody chose these individually; the product emits them because a customer did something. A one-time code, a booking confirmation, a reply inside a service window. Each is cheap, and each not-sent is a user-visible failure.
A single cap treats those identically, which produces the worst trade available: it saves fractions of a cent on functional messages while the campaign that actually spent the money has already gone out. The arithmetic is lopsided in the extreme — blocking a full day of OTP traffic usually saves less than a hundredth of what one modest broadcast costs.
The policy, stated plainly
| Category | At the cap | Approaching the cap |
|---|---|---|
| MARKETING | Hard block — sends refused | Warn at threshold |
| AUTHENTICATION | Warn, send anyway | Warn |
| UTILITY | Warn, send anyway | Warn |
| SERVICE | Warn, send anyway (it is free regardless) | Warn |
Two refinements that earn their keep in practice:
An ALL budget exists for visibility and never blocks anything — not even marketing. If a total-spend cap could refuse a marketing send, then the answer to "why was this campaign stopped?" depends on two budgets instead of one, and the person operating the dashboard has to simulate the guard in their head. One category blocks, one budget per decision, and the refusal is always explainable in a sentence.
And the block message should say what to do, not just what happened. "Marketing spend cap reached — raise the budget to resume sends" turns a support escalation into a settings change.
Counting spend honestly
The guard is only as good as the number it compares against the cap, and three details decide whether that number is honest.
Use the reconciled cost when you have it, the estimate until then. Cost arrives in two stages — an estimate at send time, and Meta's authoritative billable-and-category verdict when the status webhook lands. The spend query prefers the second:
$spent = (float) MessageLog::query()
->where('direction', 'outbound')
->where('created_at', '>=', $windowStart)
->whereNot('status', 'failed') // failures are never billed
->where(DB::raw('COALESCE(category, expected_category)'), $budgetCategory)
->sum(DB::raw('COALESCE(cost_actual, cost_estimated)'));Meta's category outranks yours. That first COALESCE is not decoration. Meta can reclassify a template after approval — utility to marketing is the common direction, at roughly six times the price. A guard that groups spend by the category you intended lets a reclassified template drain the marketing budget while being counted against utility, where nothing blocks. The webhook's verdict fills the category column; until it arrives, the send-time guess stands in.
Failures do not count. A message that failed was never billed. Summing it anyway makes the guard trip early, and on the marketing side an early block looks exactly like the feature working — nobody investigates a cap that fired.
Pin the window to the operating timezone
A daily budget resets at midnight. The only question is whose midnight, and the default answer — the application timezone, which on a stock deployment is UTC — is wrong in a way nobody notices until it fires.
For a product operating on Gulf time, a "daily" window keyed to UTC resets at 04:00 local. A cap that trips during the evening peak stays tripped through the next morning's peak too, then resets mid-morning. The window and the business day disagree by four hours, and every incident report about it reads as confusing until someone draws the timeline.
private function windowStart(string $period): CarbonInterface
{
$tz = (string) config('messaging.timezone', 'Asia/Dubai');
return $period === 'daily'
? now($tz)->startOfDay()->utc()
: now($tz)->startOfMonth()->utc();
}Compute the boundary in the operating timezone, convert to UTC, query in UTC. Storage stays uniform; the reset lands where the business thinks it does.
The zero that blocks everyone
One more counting rule, learned the painful way on a different budget system in the same codebase: a limit of zero means "not configured", never "block everything".
$budgets = Budget::query()
->where('is_active', true)
->whereNotNull('limit_amount')
->where('limit_amount', '>', 0) // 0 = unconfigured, not "deny all"
->get();The trap is mechanical: an unset config value casts to zero, and a guard comparing spent >= limit against zero blocks every send from the first one. On the marketing side that is an outage with a clear symptom. The subtle version is a seeded budget row someone zeroes "to disable it" — which, under naive comparison, does the opposite of disabling.
Two guards, opposite failure modes, same funnel
Here is the part that looks inconsistent. In the same send funnel:
- The country allowlist fails closed — an empty list denies every send.
- The budget guard fails open — if its evaluation throws (table missing mid-deploy, database hiccup), the send proceeds and the failure is logged.
public function check(?string $category): array
{
try {
return $this->evaluate($category !== null ? strtoupper($category) : null);
} catch (Throwable $e) {
// The guard protects money, not security. An OTP must not
// die of a budget query.
Log::error('Budget guard failed, allowing send', ['message' => $e->getMessage()]);
return ['allowed' => true, 'blocking_budget' => null, 'warnings' => []];
}
}The asymmetry follows from what each guard protects. The allowlist guards against an adversary: failing open converts a config mistake into a payout endpoint, so it must not. The budget guards against overspend: failing closed converts a database hiccup into locked-out customers, so it must not. "Fail closed" is not a universal virtue — it is a question you answer per control, by asking which failure is worse. Write the answer as a comment on the catch block, because the next reviewer will flag whichever direction you chose as the inconsistent one.
Warn long before you block
A block with no warning phase teaches the operator that budgets are landmines. Each budget carries an alert threshold — 80 per cent by default — and crossing it logs a warning with the numbers in it while sends continue:
marketing monthly budget at 85% (424.15/500.00) — sends continue
authentication monthly budget exhausted (12.4/10.00) — sends continueThat second line is the asymmetry doing its quiet work: a functional category over its cap is a visible fact and an unstopped flow. The message says so explicitly, because a warning that reads like a block generates the same panic a block would.
Pin it with the test that matters
One test carries this whole design, and it is the one to write first: exhaust every budget, then prove an OTP still sends.
public function test_an_otp_is_never_blocked_by_any_budget(): void
{
$this->setBudget('MARKETING', 1.0);
$this->setBudget('AUTHENTICATION', 1.0);
$this->setBudget('ALL', 1.0);
$this->spend('AUTHENTICATION', 50.0);
$this->spend('MARKETING', 50.0);
$result = $this->sender->sendOtp('9715XXXXXXXX', '123456');
$this->assertTrue($result['success']);
}Its mirror — a marketing send refused at the cap, with the refusal written to the audit log — pins the other half. Between them they encode the policy in a place a refactor cannot quietly reverse it.
Check your own guard
Three questions. Does your spend cap distinguish categories, or does one bucket govern everything — and if one bucket, what happens to a login the day a campaign empties it? Does a zeroed or missing limit block traffic or admit it? And if the guard's own query throws, which way does it fail — and is that the direction you would choose on purpose?
The guard described here ships in laravel-whatsapp-cost-control (MIT, Laravel 12 and 13), wired into the same funnel as the allowlist and the cost ledger: caps per category per period, timezone-pinned windows, warn-then-block on marketing only, and the OTP test above in its suite. The defaults encode the asymmetry so that the first budget someone configures cannot accidentally become the one that locks customers out.
dineshstack/laravel-whatsapp-cost-control
Frequently Asked Questions
Why did my app stop sending OTP codes when the marketing budget ran out?
Should a WhatsApp budget guard fail open or fail closed?
Why is my budget counting more spend than Meta billed?
Why does my daily WhatsApp budget reset in the middle of the morning?
What happens if I set a budget limit of zero to disable it?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?



