Skip to content
TutorialLaravel

OTP pumping: the fraud that bills you for every code you send

D

Dinesh Wijethunga

August 13, 2026Reviewed Aug 13, 2026 8 min readIntermediate
ShareX / TwitterLinkedIn
🎓
dineshstack/laravel-whatsapp-cost-controlView on GitHub →

An unauthenticated endpoint that sends a one-time code is a payout mechanism for anybody who controls a block of premium-rate numbers. They trigger it in volume against numbers they profit from, and you pay per message. Rate limiting does not close this, because the attacker can vary everything your rate limiter keys on — IP address, phone number, timing — while the thing that actually earns them money stays fixed: the destination country. That is the control. A country allowlist that fails closed removes the economics of the attack rather than trying to out-run its volume.

The fraud is old and well documented on SMS, where it is usually called SMS pumping or artificially inflated traffic. Per-message WhatsApp billing brings the same economics to the Cloud API, with one difference worth noting up front: on SMS the money often flows through an aggregator who may eventually notice a strange pattern. On WhatsApp you are billed directly by Meta, per message, with no invoice arriving in time to warn you.

How the fraud actually pays

The attacker's revenue does not come from you. It comes from the termination fee paid to whoever operates the number range the message is delivered to. Control a range — or hold a revenue-share arrangement with an operator who does — and every message delivered into it earns a fraction of a cent.

Which produces a very specific attacker profile, and it is not the one most defences assume:

  • They do not want your data, your accounts, or your service. Nothing is breached.
  • They do not need to complete the flow. The code is never entered. The send is the entire transaction.
  • They are indifferent to which phone numbers they use, provided the numbers sit in a range that pays.
  • They are patient. Slow, steady traffic is better for them than a burst, because it survives longer.

That last point is the one that defeats most monitoring. There is no spike to alert on. The bill simply arrives larger than the month before, and the traffic looks like signups that never converted — which is a thing that happens anyway.

Why rate limiting is not the control

Rate limits are necessary. They are not sufficient, and it is worth being precise about why.

A rate limiter keys on something about the request — usually IP address, sometimes the authenticated user, occasionally the submitted phone number. Every one of those is attacker-controlled:

  • Per-IP is defeated by rotation. Residential proxy pools are cheap and large. Five requests a minute across a thousand addresses is five thousand requests a minute.
  • Per-phone-number is defeated by having more numbers. The attacker is choosing the numbers; a range holds thousands.
  • Per-user does not apply. The endpoint is unauthenticated. That is the point of it.

Worse, a rate limit is often looser than its author believes. On the platform this came from, the customer-facing send-code route declared five requests a minute and the driver-facing one had no route-level limit at all — it inherited only the generic API group, roughly sixty times looser, on an endpoint that was about to start billing per message. Both had passed review. The declared numbers were not the numbers being enforced either.

Keep the rate limits. Tighten them. Just do not mistake them for the ceiling on this particular fraud, because they bound the rate and the attacker is not in a hurry.

The control that works

The attacker needs the message delivered into a range that pays them. That range is in a country. If your product serves customers in three countries and your system refuses to send anywhere else, the attack has no revenue in it regardless of how many IPs or numbers they bring.

This is a much stronger position than rate limiting because it is not a race. It does not degrade under load, it does not need tuning, and it cannot be worn down by patience.

Fail closed, or it is not a control

The single most important property: an unset or empty allowlist must mean deny everything, never allow everything.

This sounds pedantic until you consider how the list actually gets emptied. A missing environment variable on a new server. A typo in a deploy. A config cache built before the key existed. In every one of those, the fail-open version silently converts a configuration mistake into an open payout endpoint, and nothing in your logs looks unusual because sends are succeeding.

$codes = array_values(array_filter(array_map(
    'trim',
    explode(',', (string) config('messaging.allowed_country_codes'))
)));

// A blank value must NOT mean "allow everywhere". A misconfigured
// environment falls back to the narrowest safe default, not the widest.
$this->allowedCountryCodes = $codes === [] ? ['971'] : $codes;

Fail-closed defaults are unpopular because they break things loudly during setup. That is the feature. The alternative breaks things quietly during an incident.

Put the check in the send funnel, not the controller

Every send in the system has to pass through it, which means it cannot live in a controller. Count the entry points on a mature codebase and there are always more than expected: the customer app, the driver app, an admin "resend code" button, a background job retrying a failed delivery, a console command someone wrote for testing.

A guard on four of five entry points is not a guard. Funnel every send through one method and check there:

private function send(string $operation, array $payload, ?string $category = null): array
{
    $to = $payload['to'];

    if (! $this->isAllowedDestination($to)) {
        return $this->blockedResult($operation, $payload);   // never reaches Meta
    }

    if (! $this->budget->allows($category)) {
        return $this->budgetBlockedResult($operation, $payload);
    }

    // ... timeout-bounded HTTP, audit log, cost ledger
}

The matching itself is deliberately dull — prefix comparison against normalised digits, with an explicit opt-out for the rare system that genuinely sends anywhere:

private function isAllowedDestination(string $phone): bool
{
    $digits = ltrim($phone, '+');

    if (in_array('*', $this->allowedCountryCodes, true)) {
        return true;   // explicit, never the default
    }

    foreach ($this->allowedCountryCodes as $code) {
        if (str_starts_with($digits, $code)) {
            return true;
        }
    }

    return false;
}

Make the refusal visible

A blocked send should be recorded as loudly as a failed one. Blocks are the earliest signal you will get that somebody is probing, and a control that silently discards traffic teaches you nothing about who is testing it.

The useful trick is to make the three outcomes distinguishable in one column. In the audit log, HTTP status encodes all of them:

ValueMeaning
a numberMeta answered with that status
0Meta was unreachable
nullWe refused before sending

A sudden rise in null rows for destinations outside your markets is the attack being attempted and stopped. Without that record it is invisible, which feels like safety and is actually just missing data.

Log the country and the last four digits. Do not log the whole number — you are storing the fraudster's data, but the same code path handles your customers, and the reason to keep the column narrow is that it never sees a distinction between them.

What this does not solve

Worth being straight about the limits, because a control oversold is a control someone will trust too far.

An allowlist does nothing about abuse from inside your own market. Somebody with a payout arrangement on a range in a country you legitimately serve is not blocked by any of this, and that is the case where per-number caps and velocity monitoring earn their place.

It does not help if your markets are genuinely global. A system that must send anywhere has to fall back on the weaker controls, and should expect to spend more on monitoring as a result.

And it is not a substitute for a spend cap. The allowlist bounds where money can go; it says nothing about how much. Those are separate questions and they want separate answers — a budget that hard-blocks a runaway campaign while never blocking a login is the other half, and it gets its own post.

Check your own endpoints

Three questions, in order of how much they will tell you.

First: can your send-code endpoint deliver to a country you do not sell in? Try it against a number outside your markets in a non-production environment. If the message goes, you have no allowlist.

Second: what happens with the setting removed entirely? Blank the config value and try again. A send that still succeeds means the implementation fails open, which is the failure mode that actually bites — the list is rarely wrong on purpose, it is empty by accident.

Third: how many entry points reach your sender? Grep for it and compare against where the guard lives:

grep -rn "sendTemplate\|sendOtp\|sendMessage" app/ Modules/ --include="*.php" | grep -v "Tests\|/Messaging/"

Every result is a path that must pass the check. If the guard is in a controller and that list is longer than one, it is already being bypassed.

The funnel described here — allowlist, then budget, then a timeout-bounded call, then the audit log and cost ledger — is packaged as laravel-whatsapp-cost-control, MIT licensed, for Laravel 12 and 13. The allowlist ships fail-closed: it will refuse to send anywhere until you configure the countries you actually serve, which is a deliberately annoying five minutes.

dineshstack/laravel-whatsapp-cost-control

Star on GitHub

Frequently Asked Questions

Why is my WhatsApp or SMS bill high with no matching signup growth?
Look for one-time-code sends to countries you do not operate in, with codes that were never entered. That pattern is OTP pumping: the sends themselves earn the attacker a termination-fee share, so no completed signups ever appear.
Does rate limiting stop OTP pumping?
It bounds the rate, not the fraud. Attackers rotate IPs through proxy pools and draw numbers from ranges they control, so per-IP and per-number limits are both evaded, and patient low-volume traffic never trips spike alerts.
What is the most effective control against OTP pumping?
A destination-country allowlist enforced in the single send path every message passes through. If the message cannot be delivered into a range that pays the attacker, the attack has no revenue regardless of volume.
What does fail closed mean for a country allowlist?
An empty or missing list denies every send instead of allowing every send. A deleted environment variable or stale config cache then produces loud failures during setup rather than a silently open payout endpoint during an incident.
How do I know if OTP pumping is being attempted against my endpoint?
Record refused sends, not just successful ones. A rise in blocked attempts toward countries outside your markets is the probe being stopped; if refusals are silently discarded, the attempt is invisible.
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