Skip to content
TutorialLaravel

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

D

Dinesh Wijethunga

August 13, 2026Reviewed Aug 13, 2026 8 min readIntermediate
ShareX / TwitterLinkedIn
Block the campaign at the cap. Never block the one-time code
dineshstack/laravel-whatsapp-cost-controlView on GitHub →

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

CategoryAt the capApproaching the cap
MARKETINGHard block — sends refusedWarn at threshold
AUTHENTICATIONWarn, send anywayWarn
UTILITYWarn, send anywayWarn
SERVICEWarn, 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 continue

That 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

Star on GitHub

Frequently Asked Questions

Why did my app stop sending OTP codes when the marketing budget ran out?
Your spend cap treats every category as one bucket, so a broadcast that exhausted the budget also blocks authentication traffic. Give marketing its own hard-blocking budget and let functional categories warn without blocking.
Should a WhatsApp budget guard fail open or fail closed?
Open. If the guard's own evaluation throws, the send should proceed and the failure should be logged, because the guard protects money rather than security. A country allowlist in the same funnel correctly fails closed — the direction depends on which failure is worse.
Why is my budget counting more spend than Meta billed?
Two usual causes: failed messages are being summed even though they are never billed, or the guard is using send-time estimates for messages whose pricing webhook already reported them as free inside a service window.
Why does my daily WhatsApp budget reset in the middle of the morning?
The window is keyed to UTC rather than your operating timezone. Compute the window start in the business timezone and convert to UTC for the query, or the reset lands hours away from the local midnight.
What happens if I set a budget limit of zero to disable it?
Under naive comparison, zero blocks every send from the first one, because spend is always greater than or equal to zero. Treat zero and null as unconfigured and skip the budget instead.
D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Add a comment

Comments

Guest comments are held for moderation.

You might also like