Home / Blog / Strategy
Strategy · 8 min read · Published 2026-10-13 · OTP Route Engineering

Bulk OTP Verification at Scale — Concurrency, Budgets, Refunds

How to run high-volume OTP verification for QA fleets and test accounts, with bounded concurrency, client-side idempotency, wallet budgeting and refunds.

TL;DR — Run bulk OTP verification by treating each activation as a tracked job, capping concurrency, budgeting the wallet before each batch, using webhooks over polling and relying on automatic refunds for activations that receive no SMS.

Running OTP verification at scale means treating every activation as a short-lived, billable job rather than a one-off API call. In practice that comes down to five habits: cap concurrency, keep a job record for every activation, budget the wallet before each batch, receive codes by webhook instead of polling, and let automatic refunds absorb activations that never receive an SMS. Getting these right lets a test fleet grow without double-spending, stalling on rate limits or losing track of codes.

This guide is written for engineers who already have a working single-activation integration (if not, start with the OTP API integration guide) and now need it to hold up under real volume.

What "at scale" looks like for verification workloads

High-volume verification usually comes from one of three places, and each has a different shape.

Workload Typical pattern Main risk at volume
QA test fleets Bursts of parallel sign-ups during a test run Rate limiting and contention for numbers in one country
Test account provisioning Scheduled batches that create seeded accounts for staging Budget overruns and orphaned activations
Operational verification Steady background flow from internal tools Silent failures that nobody notices for hours

The common thread is that verification becomes a pipeline stage. A pipeline stage needs the same properties as any other distributed job: bounded resource use, traceability and safe retries. If you are building this into a release pipeline, the CI/CD solution page covers environment setup; the patterns below apply regardless of where the jobs originate.

Model each activation as a job

The single most useful design decision is to keep your own record of every activation you start. OTP Route tracks the activation state on its side, but your system needs to know which test, account or ticket that activation belongs to.

A minimal job record contains:

  • A job key you generate, such as signup-test-4812-attempt-1, unique per logical attempt.
  • The service and country requested.
  • The activation ID returned by POST /v1/activations, once known.
  • A local state that mirrors the activation lifecycle (pending, received, completed, expired, cancelled) plus your own pre-states such as queued and requesting.
  • Timestamps for requested, received and finished, so you can measure time to code.

Write the job record as requesting before you call the API, then update it with the activation ID when the response returns. If a worker crashes between the two, you have a visible record of an attempt with an unknown outcome instead of an invisible one.

Why orphaned activations are not a financial problem

Suppose a request times out and you never learn the activation ID. Because the number was never entered into the target service, no SMS arrives, the activation reaches expired at the end of its window (typically 20 minutes), and its price is refunded to the wallet automatically. The cost of an orphan is bounded to a temporary hold on funds and an allocated number, not a loss. You can confirm this after the fact in the usage logs in the developer console. That property is what makes aggressive timeouts safe.

Concurrency control

Unbounded parallelism is the most common cause of trouble at volume. Too many simultaneous POST /v1/activations calls will trigger HTTP 429 responses, and too many simultaneous activations in one country can exhaust available inventory, producing HTTP 409.

Use a fixed-size worker pool or semaphore, and treat the limit as a tunable parameter rather than a constant. The example below uses Python's thread pool with a shared session and a backoff helper.

import os
import random
import time
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API = "https://otproute.com/api/v1"
MAX_CONCURRENCY = 8  # tune for your workload; start small

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {os.environ['OTPROUTE_API_KEY']}"})


class InsufficientBalance(Exception):
    pass


def call(method, path, **kwargs):
    """Make a request, backing off on HTTP 429."""
    for attempt in range(6):
        resp = session.request(method, f"{API}{path}", timeout=(5, 30), **kwargs)
        if resp.status_code != 429:
            return resp
        retry_after = resp.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
        time.sleep(delay + random.uniform(0, 0.5))  # jitter avoids synchronized retries
    return resp


def start_activation(job):
    resp = call("POST", "/activations",
                json={"service": job["service"], "country": job["country"]})
    if resp.status_code == 402:
        raise InsufficientBalance(job["key"])
    if resp.status_code == 409:
        return job["key"], "no_numbers_available", None
    resp.raise_for_status()
    return job["key"], "pending", resp.json()


def run_batch(jobs):
    results = []
    with ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as pool:
        futures = [pool.submit(start_activation, j) for j in jobs]
        for f in as_completed(futures):
            try:
                results.append(f.result())
            except InsufficientBalance:
                # Stop scheduling new work; in-flight activations continue.
                for other in futures:
                    other.cancel()
                break
    return results

A few rules make this robust:

  • Back off with jitter on 429. Honor a Retry-After header if one is present; otherwise use exponential backoff. Jitter prevents all workers from retrying at the same instant.
  • Treat 409 as a routing signal, not an error. No numbers are available for that combination right now. Move the job to a fallback country or requeue it later. Country routing for OTP covers fallback design.
  • Treat 402 as a circuit breaker. Insufficient balance will not fix itself by retrying. Stop scheduling, alert a human or an automated top-up process, and resume afterward.

Idempotency on your side

At volume, duplicate work shows up in three places: duplicate activation requests, duplicate webhook deliveries and duplicate consumption of a code. Each needs a guard.

Duplicate source Guard
A worker retries POST /v1/activations after an ambiguous failure Check the job record first; only create a new activation under a new attempt key
The same webhook event is delivered more than once Deduplicate on activation ID plus event type before acting
Two consumers read the same received code Transition the job from received to completed atomically, and only the winner uses the code

Do not blindly retry the activation request after a network timeout. Record the old attempt as unknown, start a new attempt with a new key if you still need a number, and let the unknown attempt expire and refund on its own. That trades a few minutes of held funds for a guarantee that you never lose track of an activation. The webhook capture guide shows how to implement event deduplication alongside signature verification.

Wallet budgeting

OTP Route uses a prepaid USD wallet, funded by card via Stripe or by USDT and USDC stablecoins. Prices vary by service and country, and they can change, so compute budgets from live data rather than from a spreadsheet.

A simple preflight check before each batch:

  1. Call GET /v1/prices?service=&country= for every combination in the batch.
  2. Multiply each price by the number of jobs planned for that combination.
  3. Add headroom for retries. Refunded activations return funds, but retries are charged while they are pending.
  4. Compare the total with GET /v1/balance and either proceed, shrink the batch, or top up first.
def preflight(plan, headroom=1.25):
    """plan: list of (service, country, count). Returns (required, available)."""
    required = 0.0
    for service, country, count in plan:
        price = call("GET", "/prices", params={"service": service, "country": country}).json()
        required += float(price["price"]) * count  # field names per the schema in /docs
    balance = call("GET", "/balance").json()
    return required * headroom, float(balance["balance"])

Field names in the snippet are illustrative; use the response schema in the API reference. The important part is the shape of the check: price from the catalog, multiplied by planned volume, compared against the live balance.

Separate keys, separate visibility

The developer console supports multiple API keys. Issue one per pipeline or environment, for example one for nightly regression and one for staging provisioning. Usage logs are then attributable per key, which makes it straightforward to see which workload is spending what, and you can revoke a single key without disrupting the rest.

Refunds and failure accounting

Refunds are automatic for activations that never receive an SMS, whether they expire or you cancel them. That shapes two operational habits.

Cancel early when you know the SMS will not come. If a test fails before it submits the phone number to the target service, call POST /v1/activations/{id}/cancel immediately. The number is released and the refund lands without waiting out the activation window.

Measure cost per successful verification, not cost per activation. Track these counters per service and country:

Metric How to compute it
Activations started Count of successful POST /v1/activations calls
Codes received Count of activation.sms_received events after deduplication
Expired and cancelled Count of activation.expired and activation.cancelled events
Receive rate Codes received divided by activations started
Time to code Received timestamp minus requested timestamp
Net cost per success Net wallet spend divided by codes received

These are your own numbers, measured on your own traffic. They tell you which routes deserve priority and when a route has degraded. If receive rates drop for a combination, the guide to reducing failed OTP deliveries lists the usual causes.

A reference pipeline

Putting it together, a bulk verification system usually looks like this:

  1. Scheduler creates job records with job keys and enqueues them.
  2. Preflight checks the budget for the batch against prices and balance.
  3. Workers, capped by a concurrency limit, call POST /v1/activations and store activation IDs.
  4. Test runners submit the allocated numbers to the target flow.
  5. Webhook receiver verifies X-OTPRoute-Signature, deduplicates, and writes codes to the job store.
  6. Consumers claim received codes atomically and complete the verification step.
  7. Reconciler periodically calls GET /v1/activations/{id} for jobs stuck in pending past their window, as a safety net if a webhook was missed.
  8. Reporting aggregates metrics per service, country and API key.

Each stage can fail independently without corrupting the others, which is the point.

Responsible use

Bulk verification is a legitimate engineering need for QA, test environments and operational tooling. It must not be used to mass-create accounts on third-party platforms in breach of their terms, to manipulate promotions, or for spam, fraud or any other abuse. Verify against your own applications or within the published rules of the platforms you test, and keep a clear record of what each workload is for.

Frequently asked questions

How many activations can I run in parallel?

There is no single right number. Start with a small concurrency limit, watch for HTTP 429 and HTTP 409 responses, and increase gradually. Treat the limit as configuration per workload, and consult /docs for current rate-limit guidance.

What happens to my money if an activation never receives a code?

It is refunded to your wallet automatically. When an activation expires at the end of its window, typically 20 minutes, or when you cancel it before an SMS arrives, the activation price is credited back.

Should bulk workloads use polling or webhooks?

Webhooks. Polling multiplies request volume by the number of in-flight activations and pushes you toward rate limits. Use webhooks as the primary path and a low-frequency reconciler that polls only stuck activations as a safety net.

How do I stop a batch from overspending?

Run a preflight that multiplies live prices from GET /v1/prices by planned volume, adds headroom for retries and compares the total with GET /v1/balance. Treat HTTP 402 during the run as a signal to stop scheduling new jobs.

Can I separate costs by team or pipeline?

Yes. Create a separate API key in the developer console for each pipeline or environment. Usage logs are recorded per key, so spend and errors can be attributed without extra tagging.

Route your first OTP in minutes

Pay as you go · no monthly fee · automatic refunds on unused activations.

Topics: bulk otp verification · otp at scale · high volume sms verification · otp api concurrency · test account provisioning · otp wallet budgeting · otp refunds