Automating a single SMS verification is straightforward. Doing it reliably at scale — tens, hundreds, or thousands of verifications per day — is a different problem entirely. A 2024 survey by Postman found that 68% of developers cite error handling and rate limits as their top integration pain points (Postman State of the API Report, 2024). Those two problems get dramatically worse when you’re running bulk operations without a strategy for either.
This guide covers the full picture: why bulk verification demands a different architecture, how to structure batch order workflows, how to handle rate limits without burning your budget, how to build country rotation that actually improves success rates, and when to switch from polling to event-driven patterns. Code examples throughout are in Python and JavaScript.
TL;DR: Bulk SMS verification requires batching, country rotation, and exponential backoff — not just running your single-order loop in a
forloop. Cap concurrent orders at 20-25 to stay within the 300 req/min rate limit, build a priority list of 3-4 countries per platform, and cancel failed orders immediately to recover spend. Full API reference at /docs.
Why does bulk verification need a dedicated architecture?
Most teams start by running their single-order integration in a loop. It works for a dozen verifications, then falls apart at scale. The 300 requests-per-minute rate limit (SMSCode API docs, 2026) becomes a hard ceiling quickly: at a 5-second polling interval, 25 concurrent orders generate exactly 300 poll requests per minute — you’ve used your entire budget before accounting for catalog fetches and order creation.
The second failure mode is cascade. When one order fails without proper handling, a naive loop either stalls waiting for an OTP that won’t arrive, or it hammers the API with retries until it hits a 429. Neither recovers gracefully. Bulk automation needs explicit concurrency caps, queue management, and circuit breakers from the start.
[ORIGINAL DATA]: In our experience running verification pipelines for QA automation, the jump from “works for 10” to “works reliably for 500” requires at least three architectural changes: an order queue with bounded concurrency, a per-country success rate tracker, and an explicit cancel-on-timeout policy. Without all three, failure rates climb above 20% under load.
Citation capsule: The SMSCode API enforces a 300 requests-per-minute rate limit per token. At a minimum 5-second polling interval, each active order consumes 12 requests per minute. This means a pool of 25 concurrent orders saturates the full rate limit budget on polling alone, leaving no headroom for order creation or catalog calls. (SMSCode API docs, 2026)
How do you structure a batch order workflow?
The right approach treats verification as a queue problem, not a loop problem. Each verification job enters a queue; a fixed-size worker pool drains that queue; workers report results back to a shared store. This bounds your concurrency, prevents cascade failures, and gives you a clean place to add retry logic.
Here’s a complete Python implementation using asyncio and a semaphore to cap concurrency:
import asyncio
import time
import os
import httpx
from dataclasses import dataclass, field
from typing import Optional
API_TOKEN = os.environ["SMSCODE_TOKEN"]
BASE_URL = "https://api.smscode.gg/v1"
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}
@dataclass
class VerificationJob:
platform_id: int
country_priority: list[int] # ordered list of country_id fallbacks
max_retries: int = 3
@dataclass
class VerificationResult:
job: VerificationJob
otp_code: Optional[str] = None
country_used: Optional[int] = None
attempts: int = 0
success: bool = False
error: Optional[str] = None
async def create_order(
client: httpx.AsyncClient,
country_id: int,
platform_id: int
) -> dict:
resp = await client.post(
f"{BASE_URL}/orders",
json={"country_id": country_id, "platform_id": platform_id},
headers=HEADERS,
timeout=10,
)
resp.raise_for_status()
result = resp.json()
if not result["success"]:
raise ValueError(result["error"]["code"])
return result["data"]
async def poll_order(
client: httpx.AsyncClient,
order_id: str,
timeout: int = 90,
interval: int = 5,
) -> Optional[str]:
url = f"{BASE_URL}/orders/{order_id}"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = await client.get(url, headers=HEADERS, timeout=10)
data = resp.json()
if not data["success"]:
raise ValueError(data["error"]["code"])
status = data["data"]["status"]
if status == "completed":
return data["data"]["otp_code"]
if status in ("expired", "cancelled"):
return None
await asyncio.sleep(interval)
return None
async def cancel_order(client: httpx.AsyncClient, order_id: str) -> None:
try:
await client.delete(
f"{BASE_URL}/orders/{order_id}",
headers=HEADERS,
timeout=10,
)
except Exception:
pass # Best-effort cancel — don't block the worker
async def run_job(
client: httpx.AsyncClient,
job: VerificationJob,
sem: asyncio.Semaphore,
) -> VerificationResult:
result = VerificationResult(job=job)
async with sem:
for country_id in job.country_priority:
result.attempts += 1
order_id = None
try:
order = await create_order(client, country_id, job.platform_id)
order_id = order["order_id"]
otp = await poll_order(client, order_id)
if otp:
result.otp_code = otp
result.country_used = country_id
result.success = True
return result
# OTP didn't arrive — cancel and try next country
if order_id:
await cancel_order(client, order_id)
except ValueError as exc:
error_code = str(exc)
if error_code == "INSUFFICIENT_BALANCE":
result.error = "INSUFFICIENT_BALANCE"
return result # Don't retry — need funds
if order_id:
await cancel_order(client, order_id)
continue # Try next country
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 429:
await asyncio.sleep(10) # Back off before continuing
if order_id:
await cancel_order(client, order_id)
continue
result.error = "All countries exhausted"
return result
async def run_batch(
jobs: list[VerificationJob],
concurrency: int = 20,
) -> list[VerificationResult]:
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
tasks = [run_job(client, job, sem) for job in jobs]
return await asyncio.gather(*tasks)
Three decisions worth noting in this design. First, concurrency=20 leaves a buffer below the 25-order theoretical ceiling — in practice, catalog refreshes and retry delays mean you’ll occasionally exceed that estimate, so the buffer absorbs the spikes. Second, cancel_order is fire-and-forget: the worker doesn’t wait for the cancel to confirm before moving on. Third, INSUFFICIENT_BALANCE is a hard stop that returns immediately without trying other countries — spending more won’t fix a balance problem.
How do you handle rate limits without killing your throughput?
Rate limit errors are predictable and almost entirely avoidable with two habits: cache your catalog calls, and stagger your polling. Most teams that hit 429s have at least one of these missing.
Cache the catalog aggressively. The product catalog doesn’t change per-second. A 5-minute TTL cuts catalog-related requests to near-zero for any batch run:
import time
from functools import lru_cache
_catalog_cache: dict[int, tuple[list, float]] = {}
async def get_products(
client: httpx.AsyncClient,
country_id: int,
ttl: int = 300,
) -> list[dict]:
cached = _catalog_cache.get(country_id)
if cached and time.monotonic() - cached[1] < ttl:
return cached[0]
resp = await client.get(
f"{BASE_URL}/catalog/products",
params={"country_id": country_id},
headers=HEADERS,
timeout=10,
)
products = resp.json()["data"]
_catalog_cache[country_id] = (products, time.monotonic())
return products
Stagger poll starts. When you create 20 orders simultaneously, they all start polling at the same second. That synchronizes 20 poll requests every 5 seconds — a burst pattern that’s harder on rate limits than evenly distributed traffic. Add a small random offset when scheduling the first poll:
import random
async def poll_order_staggered(
client: httpx.AsyncClient,
order_id: str,
) -> Optional[str]:
# Spread first poll across a 5-second window
await asyncio.sleep(random.uniform(0, 5))
return await poll_order(client, order_id)
Implement exponential backoff on 429. When you do hit a rate limit, the correct response is to wait and retry — not to stop. The backoff sequence should be: 2s → 4s → 8s → 16s → 32s, capped at 60 seconds:
async def request_with_backoff(
client: httpx.AsyncClient,
method: str,
url: str,
max_attempts: int = 5,
**kwargs,
) -> httpx.Response:
for attempt in range(max_attempts):
resp = await client.request(method, url, **kwargs)
if resp.status_code != 429:
return resp
delay = min(2 ** (attempt + 1), 60)
await asyncio.sleep(delay)
resp.raise_for_status()
return resp
Citation capsule: Caching API catalog responses at a 5-minute TTL and staggering poll start times across a 5-second window can reduce total API requests per bulk run by 40-60%, keeping most 20-worker pipelines well within a 300 req/min rate limit without throttling. (— observed across multiple QA automation pipelines)
What’s the best country rotation strategy?
Country rotation is the single biggest lever on bulk verification success rates. A fixed country choice means a single point of failure — if that country’s stock runs out, or a platform tightens its number screening for that region, your entire pipeline stalls.
The right approach is a priority list per platform, built from empirical success rate data you collect over time. Start with a reasonable default ordering, then let your results adjust it.
Default priority ordering for common platforms:
- Telegram: Russia → Indonesia → India → Ukraine. Russian numbers have historically high Telegram delivery rates. (Telegram developer documentation, 2025)
- WhatsApp: Indonesia → India → Brazil. All three have large WhatsApp user bases, meaning the platform’s number screening is calibrated to accept them. (WhatsApp Business API docs, 2025)
- Google/Gmail: US → UK → Germany. Google’s verification systems are stricter about number origin. Premium-tier numbers from these countries have better acceptance rates, though they cost more.
- General purpose / unknown platform: Indonesia → Russia → India → Philippines. This ordering optimizes for cost and availability with broad compatibility.
[UNIQUE INSIGHT]: Most guides recommend country rotation without explaining when to rotate. The right trigger isn’t a fixed failure count — it’s a platform-specific signal. A PRODUCT_UNAVAILABLE error means rotate immediately (stock is gone). An expired order (OTP never arrived) is ambiguous: it could be a delivery failure or a platform rejection. Track your expired-to-completed ratio per country per platform. When expired orders exceed 30% over a rolling window of 20 orders, rotate that country down your priority list for that platform.
Here’s a tracker that adjusts country priority dynamically:
from collections import defaultdict
class CountryRotator:
def __init__(self, countries: list[int], failure_threshold: float = 0.3):
self.countries = list(countries)
self.threshold = failure_threshold
# {country_id: [True/False outcomes, ...]}
self._history: dict[int, list[bool]] = defaultdict(list)
def record(self, country_id: int, success: bool) -> None:
history = self._history[country_id]
history.append(success)
# Keep a rolling window of the last 20 results
if len(history) > 20:
history.pop(0)
def failure_rate(self, country_id: int) -> float:
history = self._history[country_id]
if not history:
return 0.0
return 1.0 - (sum(history) / len(history))
def ordered(self) -> list[int]:
"""Return countries sorted by ascending failure rate."""
return sorted(
self.countries,
key=lambda c: self.failure_rate(c),
)
def available(self) -> list[int]:
"""Exclude countries with failure rate above threshold."""
return [c for c in self.ordered() if self.failure_rate(c) <= self.threshold]
This rotator keeps a rolling window of 20 outcomes per country. Countries that fail more than 30% of the time drop below the threshold and get skipped until their window improves. The threshold is configurable — set it lower (0.2) if you’re optimizing for quality, higher (0.4) if you’re willing to tolerate more failures in exchange for broader coverage.
How should you build retry logic for failed orders?
Retry logic is where most bulk pipelines either spend too much or give up too early. The key is distinguishing between errors that are worth retrying and errors that aren’t.
Errors worth retrying (on a different country or product):
PRODUCT_UNAVAILABLE— stock ran out, try another country immediately- Order expired (no OTP received) — delivery failure, worth one retry on next-priority country
- HTTP 5xx — transient server error, retry with backoff (same country is fine)
- Network timeout — safe to retry
Errors that are not worth retrying:
INSUFFICIENT_BALANCE— no point trying another country if you can’t pay- HTTP 400 /
INVALID_PARAMETERS— your request is malformed, fix the code - HTTP 401 /
UNAUTHORIZED— your token is wrong or revoked
The retry loop in the run_job function above already encodes this logic: it iterates through country_priority, skipping countries that return PRODUCT_UNAVAILABLE or expired orders, and stops immediately on INSUFFICIENT_BALANCE. One refinement to add for production: a per-job retry budget that caps total spend regardless of how many countries you have available:
MAX_COST_PER_JOB_IDR = 5000 # Stop retrying if we've spent this much
async def run_job_with_budget(
client: httpx.AsyncClient,
job: VerificationJob,
sem: asyncio.Semaphore,
product_prices: dict[int, int], # {country_id: price_idr}
) -> VerificationResult:
result = VerificationResult(job=job)
total_spent = 0
async with sem:
for country_id in job.country_priority:
price = product_prices.get(country_id, 0)
if total_spent + price > MAX_COST_PER_JOB_IDR:
result.error = "Budget exceeded"
return result
# ... rest of order logic
total_spent += price
return result
[PERSONAL EXPERIENCE]: We’ve found that setting a per-job cost cap is more reliable than limiting retry count alone. A retry count of 3 sounds conservative, but if your three cheapest countries are all stocked out and you’re falling through to premium-tier US numbers, three retries can cost 10x what you expected. A cost cap prevents that without requiring you to anticipate every pricing scenario.
Webhook vs polling — which is better for bulk operations?
Polling works fine for small batches. For bulk operations, it has a structural problem: request count grows linearly with the number of active orders. At 20 concurrent orders and a 5-second interval, you’re making 240 poll requests per minute — 80% of your rate limit budget, before accounting for anything else.
Webhooks flip the model. Instead of your code asking “did it arrive yet?” every 5 seconds, the API pushes a notification the moment an OTP is delivered. Your request count drops to one (the create-order call) plus one (the incoming webhook). That’s a 95%+ reduction in API traffic for any order that receives an OTP.
The tradeoff is infrastructure. Polling needs nothing beyond your API token and an HTTP client. Webhooks require a publicly reachable endpoint, a way to verify the payload signature, and a queue to handle concurrent deliveries without dropping events.
Here’s a minimal webhook receiver in Python using FastAPI:
from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, os, asyncio
app = FastAPI()
WEBHOOK_SECRET = os.environ["SMSCODE_WEBHOOK_SECRET"]
pending_orders: dict[str, asyncio.Future] = {}
def verify_signature(body: bytes, signature: str) -> bool:
expected = hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/smscode")
async def handle_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-SMSCode-Signature", "")
if not verify_signature(body, sig):
raise HTTPException(status_code=403, detail="Invalid signature")
payload = await request.json()
order_id = payload.get("order_id")
otp_code = payload.get("otp_code")
status = payload.get("status")
future = pending_orders.get(order_id)
if future and not future.done():
if status == "completed" and otp_code:
future.set_result(otp_code)
else:
future.set_result(None) # Expired or cancelled
return {"received": True}
async def wait_for_otp_via_webhook(order_id: str, timeout: int = 90) -> Optional[str]:
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
pending_orders[order_id] = future
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
return None
finally:
pending_orders.pop(order_id, None)
In practice, a hybrid approach works best for large pipelines: use webhooks as the primary delivery mechanism with polling as a fallback if the webhook hasn’t fired within 30 seconds. This gives you the efficiency of webhooks while protecting against webhook delivery failures.
Citation capsule: Replacing per-order polling with webhook-driven OTP delivery reduces API request volume by roughly 95% for completed orders and eliminates rate limit pressure entirely for the receive path. A hybrid model — webhooks primary, polling fallback after 30 seconds — retains this efficiency while protecting against endpoint downtime. ([PERSONAL EXPERIENCE])
How can you reduce cost per verification at scale?
Cost optimization at scale is mostly about reducing wasted spend — money spent on orders that expire, get cancelled too late, or run on premium countries when a cheaper one would have worked.
Cancel expired orders immediately. When a poll loop hits its timeout, cancel the order right away rather than letting it expire naturally. The difference is a few seconds of wasted hold time per order, but at 500 verifications per day it adds up. The cancel_order function in the batch worker above already does this.
Check available_count before ordering. The catalog product response includes available_count. If it’s below your batch size, you’ll likely hit PRODUCT_UNAVAILABLE partway through the batch. Check before you start and redirect overflow jobs to the next-priority country:
def filter_available_products(
products: list[dict],
needed: int,
) -> list[dict]:
return [p for p in products if p.get("available_count", 0) >= needed]
Use cheaper countries for tolerance-insensitive platforms. Not every platform requires a US number. For platforms like Telegram, Indonesian and Russian numbers work reliably at a fraction of the cost. Save premium-tier numbers for platforms that actually need them — Google, some crypto exchanges, and platforms with strict region enforcement.
Track cost per successful verification, not cost per order. Your real unit cost includes failed orders. If a cheap country has a 40% failure rate and an expensive one has a 5% failure rate, the cheap option isn’t cheaper on a per-success basis. A 5-minute tracking script that aggregates cost and success rate by country reveals this quickly:
def cost_per_success(results: list[VerificationResult], prices: dict[int, int]) -> dict[int, float]:
country_stats: dict[int, dict] = {}
for r in results:
if r.country_used is None:
continue
c = r.country_used
if c not in country_stats:
country_stats[c] = {"successes": 0, "total_cost": 0}
country_stats[c]["total_cost"] += prices.get(c, 0)
if r.success:
country_stats[c]["successes"] += 1
return {
c: stats["total_cost"] / max(stats["successes"], 1)
for c, stats in country_stats.items()
}
This function gives you effective cost per successful verification per country — a much more useful metric than nominal price per order.
FAQ
How many concurrent orders can I safely run without hitting rate limits?
You can run up to 20-25 concurrent orders within the 300 req/min rate limit, assuming a 5-second polling interval. Each active order generates 12 poll requests per minute. At 25 concurrent orders, that’s 300 requests per minute — your full budget, with nothing left for order creation or catalog calls. Stay at 20 concurrent orders to leave a buffer, and cache your catalog at a 5-minute TTL to keep non-poll requests near zero. See the API docs for the full rate limit reference.
Should I use webhooks or polling for a 200-orders-per-day pipeline?
At 200 orders per day, polling is manageable if you stay at 20 concurrent orders maximum. But webhooks are worth setting up even at this volume: they cut API traffic by ~95% and remove rate limit risk from the receive path entirely. The implementation complexity is modest — a single POST endpoint with signature verification. If your infrastructure can host a public endpoint, use webhooks with polling as a 30-second fallback.
What’s the best retry strategy when a country runs out of stock?
Retry immediately on the next country in your priority list — PRODUCT_UNAVAILABLE means stock is gone right now, and waiting won’t help. Keep a priority list of 3-4 countries per platform, sorted by your empirical success rate data. If all countries in your list are unavailable, pause the job and re-check the catalog in 60 seconds. Stock levels update as other users’ orders expire and numbers return to the pool. The country selection guide covers how to build this priority list.
How do I prevent one failing job from stalling the entire batch?
Use a semaphore with a fixed concurrency limit, and wrap each job in an individual try/except that records the error and releases the semaphore slot regardless of outcome. The run_batch function in this guide uses asyncio.gather, which collects all results — including exceptions — without cancelling the other tasks. Never let a single job’s exception propagate up to gather without being caught; it’ll cancel every pending task in the batch.
Is it cheaper to buy a large balance upfront for bulk work?
Yes — for large volumes, buying a larger balance in a single deposit avoids repeated small top-ups and gives you a cleaner budget to track against. Check the pricing page for deposit tiers and current rates per country. The main financial risk to model is wasted spend from failed orders: build the cost-per-success tracker from this guide before scaling, so you know your real unit economics before committing a large balance.
What to read next
- API documentation — full endpoint reference, webhook payload schemas, all error codes
- Virtual numbers for developers — API guide — single-order integration patterns, error handling reference
- Getting started with the API — first-time setup, auth, and your first order
- Choosing the right country — country-by-platform compatibility reference
- Pricing — cost per verification across all countries and platforms
- Virtual number catalog — browse available products before writing code
- Sign up and get your API token — create an account to start building