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 re-read server capabilities before finishing or canceling an order. 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 capability check after timeout. 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 json
import time
import os
import uuid
import httpx
from dataclasses import dataclass
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
catalog_product_by_country: dict[int, int] # ids from GET /v1/catalog/products
max_attempts: int = 3
@dataclass
class VerificationResult:
job: VerificationJob
otp_code: Optional[str] = None
otp_message: Optional[str] = None
sms_revision: Optional[int] = None
country_used: Optional[int] = None
attempts: int = 0
delivered: bool = False
success: bool = False
error: Optional[str] = None
@dataclass
class PaidCreateRecovery:
"""Evidence needed to reconcile an unexpected failure after a paid create."""
idempotency_key: Optional[str] = None
country_id: Optional[int] = None
request_body: Optional[dict[str, int]] = None
order_id: Optional[int] = None
def paid_create_evidence(recovery: PaidCreateRecovery) -> str:
"""Return the exact key/body identity needed for an idempotent replay."""
if (
recovery.idempotency_key is None
or recovery.country_id is None
or recovery.request_body is None
):
return "incomplete paid-create recovery evidence"
request_body = json.dumps(
recovery.request_body,
sort_keys=True,
separators=(",", ":"),
)
return (
f"country {recovery.country_id}; request body {request_body}; "
f"Idempotency-Key {recovery.idempotency_key}"
)
class ApiBusinessError(Exception):
"""A definitive error from a well-formed API response."""
class AmbiguousResponseError(Exception):
"""The server outcome cannot be established from the response body."""
# These create errors definitively reject the request without creating an order.
# Do not broaden this list by default: unknown/future codes are ambiguous.
DEFINITIVE_CREATE_ERRORS = {
"NO_OFFER_AVAILABLE", # No matching offer was selected.
"VALIDATION_ERROR", # The request body was rejected.
"PROVIDER_ERROR", # The upstream operation was rejected or failed.
"IDEMPOTENCY_KEY_REUSED", # This key belongs to a different request body.
}
def parse_api_response(response, operation: str):
"""Separate definitive API errors from malformed/ambiguous responses."""
try:
payload = response.json()
except ValueError as exc:
# Response parsing can raise JSONDecodeError or UnicodeDecodeError.
raise AmbiguousResponseError(
f"{operation}: malformed response body"
) from exc
if not isinstance(payload, dict) or not isinstance(payload.get("success"), bool):
raise AmbiguousResponseError(f"{operation}: malformed API envelope")
if not payload["success"]:
error = payload.get("error")
code = error.get("code") if isinstance(error, dict) else None
if not isinstance(code, str) or not code:
raise AmbiguousResponseError(f"{operation}: malformed error envelope")
raise ApiBusinessError(code)
if "data" not in payload:
raise AmbiguousResponseError(f"{operation}: missing response data")
return payload["data"]
async def create_order(
client: httpx.AsyncClient,
request_body: dict[str, int],
idempotency_key: str,
max_retries: int = 3,
) -> dict:
# The caller creates this key/body pair ONCE for one logical paid create.
# Every transport/5xx retry below reuses that exact pair.
for attempt in range(max_retries + 1):
try:
resp = await client.post(
f"{BASE_URL}/orders/create",
json=request_body,
headers={**HEADERS, "Idempotency-Key": idempotency_key},
timeout=10,
)
if resp.status_code == 429 or resp.status_code >= 500:
resp.raise_for_status()
data = parse_api_response(resp, "create order")
resp.raise_for_status()
orders = data.get("orders") if isinstance(data, dict) else None
if (
not isinstance(orders, list)
or not orders
or not isinstance(orders[0], dict)
or not isinstance(orders[0].get("id"), int)
):
raise AmbiguousResponseError("create order: malformed order data")
return orders[0]
except ApiBusinessError as exc:
error_code = str(exc)
if error_code == "REQUEST_IN_PROGRESS":
# The server still owns this key's create claim. Retry only the
# same key/body pair; never switch country while it is settling.
if attempt >= max_retries:
raise AmbiguousResponseError(
"create order: REQUEST_IN_PROGRESS did not settle "
"after bounded same-key retries"
) from exc
await asyncio.sleep(min(2**attempt, 30))
continue
if (
error_code == "INSUFFICIENT_BALANCE"
or error_code in DEFINITIVE_CREATE_ERRORS
):
raise
raise AmbiguousResponseError(
f"create order: non-definitive error {error_code}"
) from exc
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
retryable = isinstance(exc, httpx.RequestError) or (
exc.response.status_code == 429 or exc.response.status_code >= 500
)
if not retryable or attempt >= max_retries:
raise
await asyncio.sleep(min(2**attempt, 30))
raise RuntimeError("unreachable")
async def poll_order(
client: httpx.AsyncClient,
order_id: int,
timeout: int = 90,
interval: int = 5,
) -> Optional[dict]:
url = f"{BASE_URL}/orders/{order_id}"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = await client.get(url, headers=HEADERS, timeout=10)
order = parse_api_response(resp, "poll order")
resp.raise_for_status()
if not isinstance(order, dict) or not isinstance(order.get("status"), str):
raise AmbiguousResponseError("poll order: malformed order data")
if order.get("otp_received_at") is not None:
return order
if order["status"] in ("COMPLETED", "EXPIRED", "CANCELED"):
return order
await asyncio.sleep(interval)
# A local timeout is not lifecycle evidence. Re-read once so the caller can
# follow current capabilities rather than assuming the order is refundable.
resp = await client.get(url, headers=HEADERS, timeout=10)
order = parse_api_response(resp, "final order read")
resp.raise_for_status()
if not isinstance(order, dict) or not isinstance(order.get("status"), str):
raise AmbiguousResponseError("final order read: malformed order data")
return order
async def cancel_order(client: httpx.AsyncClient, order_id: int) -> bool:
current = await client.get(
f"{BASE_URL}/orders/{order_id}", headers=HEADERS, timeout=10
)
order = parse_api_response(current, "cancel capability read")
current.raise_for_status()
can_cancel = order.get("can_cancel") if isinstance(order, dict) else None
if not isinstance(can_cancel, bool):
raise AmbiguousResponseError("cancel capability read: malformed order data")
if not can_cancel:
return False
response = await client.post(
f"{BASE_URL}/orders/cancel",
json={"id": order_id},
headers=HEADERS,
timeout=10,
)
response.raise_for_status()
return True
async def run_job(
client: httpx.AsyncClient,
job: VerificationJob,
sem: asyncio.Semaphore,
recovery: Optional[PaidCreateRecovery] = None,
result: Optional[VerificationResult] = None,
) -> VerificationResult:
if result is None:
result = VerificationResult(job=job)
if recovery is None:
recovery = PaidCreateRecovery()
async with sem:
for country_id in job.country_priority[:job.max_attempts]:
recovery.idempotency_key = None
recovery.country_id = None
recovery.request_body = None
recovery.order_id = None
result.attempts += 1
result.country_used = country_id
order_id = None
create_key = str(uuid.uuid4()) # One key for this country/body attempt
try:
catalog_product_id = job.catalog_product_by_country[country_id]
request_body = {"catalog_product_id": catalog_product_id}
# Record the same dict passed to create_order. If an unexpected
# exception escapes, the wrapper can reproduce this exact attempt.
recovery.idempotency_key = create_key
recovery.country_id = country_id
recovery.request_body = request_body
order = await create_order(
client,
request_body,
idempotency_key=create_key,
)
order_id = order["id"]
recovery.order_id = order_id
snapshot = await poll_order(client, order_id)
if snapshot and snapshot.get("otp_received_at") is not None:
result.otp_code = snapshot.get("otp_code")
result.otp_message = snapshot.get("otp_message")
result.sms_revision = snapshot.get("sms_revision")
result.country_used = country_id
result.delivered = True
result.success = result.otp_code is not None
if not result.success:
result.error = "SMS delivered without a classified code"
return result
if snapshot and snapshot["status"] in ("COMPLETED", "EXPIRED", "CANCELED"):
continue
if order_id and not await cancel_order(client, order_id):
result.error = "Order is still active and cannot be canceled"
return result
except AmbiguousResponseError as exc:
if order_id is None:
result.error = (
f"Ambiguous create outcome: {exc}; replay exactly with "
f"{paid_create_evidence(recovery)}"
)
else:
result.error = (
f"Ambiguous response after order {order_id}; "
f"{exc}; re-read it before any action; "
"recovery evidence: "
f"{paid_create_evidence(recovery)}"
)
return result
except ApiBusinessError as exc:
error_code = str(exc)
if order_id is not None:
# Once an order exists, a later API error cannot prove that
# another paid create is safe. Stop and reconcile this order.
result.error = (
f"Non-definitive response after order {order_id}: "
f"{error_code}; recovery evidence: "
f"{paid_create_evidence(recovery)}"
)
return result
if error_code == "INSUFFICIENT_BALANCE":
result.error = "INSUFFICIENT_BALANCE"
return result # Don't retry — need funds
if error_code not in DEFINITIVE_CREATE_ERRORS:
# Defence in depth: create_order already converts every
# unlisted code to ambiguity. Never fail open on a new code.
result.error = (
f"Ambiguous create error {error_code}; replay exactly with "
f"{paid_create_evidence(recovery)}"
)
return result
continue # Try next country
except httpx.HTTPStatusError:
if order_id is not None:
result.error = (
f"HTTP failure after order {order_id}; re-read it before retrying; "
f"recovery evidence: {paid_create_evidence(recovery)}"
)
return result
# create_order already retried every ambiguous create with the
# same key. Do not switch to a new key/country when its outcome
# is still unknown; surface the key for reconciliation.
result.error = (
"Ambiguous create outcome; replay exactly with "
f"{paid_create_evidence(recovery)}"
)
return result
except httpx.RequestError:
if order_id is not None:
result.error = (
f"Network failure after order {order_id}; re-read it before retrying; "
f"recovery evidence: {paid_create_evidence(recovery)}"
)
return result
result.error = (
"Ambiguous create outcome; replay exactly with "
f"{paid_create_evidence(recovery)}"
)
return result
result.error = "All countries exhausted"
return result
async def guarded_run_job(
client: httpx.AsyncClient,
job: VerificationJob,
sem: asyncio.Semaphore,
) -> VerificationResult:
recovery = PaidCreateRecovery()
result = VerificationResult(job=job)
try:
return await run_job(client, job, sem, recovery, result)
except Exception as exc:
if recovery.idempotency_key is not None:
if recovery.order_id is not None:
result.error = (
f"Unexpected failure after order {recovery.order_id}; "
"re-read it before any action; recovery evidence: "
f"{paid_create_evidence(recovery)}"
)
return result
result.error = (
"Unexpected create failure; replay exactly with "
f"{paid_create_evidence(recovery)}"
)
return result
result.error = f"Unexpected job failure: {exc}"
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 = [guarded_run_job(client, job, sem) for job in jobs]
return await asyncio.gather(*tasks)
Four 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 reads the server-authoritative capability and confirms the cancel instead of inferring refund eligibility from a missing code. Third, INSUFFICIENT_BALANCE is a hard stop that returns immediately without trying other countries — spending more won’t fix a balance problem. Fourth, paid create retries reuse the same Idempotency-Key and request body; when all same-pair retries remain ambiguous, the job stops instead of minting another key and risking a second debit.
A malformed, truncated, or invalidly encoded response body is also an ambiguous
outcome, even when the HTTP status is 200. The example catches the full response
parse-error family, stops, and surfaces the existing idempotency key for
reconciliation; only a well-formed success: false response with a valid error
code may trigger country failover. The guarded wrapper retains the current
attempt count, country, exact request body, key, and known order ID if an
unexpected exception escapes after the paid request.
An idempotency key is safe to replay only with the exact request body that was first sent with it, because the server fingerprints the body as part of the idempotency record. Preserve the reported country, request body, and key together, and reproduce that body byte-for-byte during reconciliation. Never guess a body or substitute a new key: either change makes the retry a new paid order that can create a second debit.
Create-error handling is intentionally fail-closed. Only
NO_OFFER_AVAILABLE, VALIDATION_ERROR, PROVIDER_ERROR, and
IDEMPOTENCY_KEY_REUSED are definitive rejections that may advance to another
country with a fresh key; INSUFFICIENT_BALANCE is a hard stop. The server uses
REQUEST_IN_PROGRESS to say that the existing key is still settling, so the
worker retries that same key and body a bounded number of times. If it remains
pending, or if any unknown/future error code appears, the outcome is ambiguous:
stop and surface the existing replay evidence instead of creating another paid
order.
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
_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 = parse_api_response(resp, "catalog products")
resp.raise_for_status()
if not isinstance(products, list):
raise AmbiguousResponseError("catalog products: malformed product 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: int,
) -> Optional[dict]:
# 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 NO_OFFER_AVAILABLE 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-delivered 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]:
"""Demote unhealthy countries without permanently excluding them."""
return sorted(
self.countries,
key=lambda c: (
self.failure_rate(c) > self.threshold,
self.failure_rate(c),
),
)
This rotator keeps a rolling window of 20 outcomes per country. Countries that fail more than 30% of the time move behind the healthier choices but remain eligible, so later attempts can collect new evidence and let them recover. 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.
Definitive outcomes that may advance to another country or product:
NO_OFFER_AVAILABLE,VALIDATION_ERROR, andPROVIDER_ERROR— the create was rejected without an orderIDEMPOTENCY_KEY_REUSED— the key belongs to a different request body, so the next logical create needs a fresh key- Order expired with no SMS received — delivery failed, so the next-priority country may be tried
Outcomes that must keep the existing body and key or stop:
REQUEST_IN_PROGRESS— retry the same body andIdempotency-Keywith bounded backoff; if it does not settle, stop and reconcile- HTTP 5xx or a network timeout — retry only the exact create with the same key, then stop with its recovery evidence if ambiguity remains
INSUFFICIENT_BALANCE— stop after the first rejection; another country cannot fix the balance- HTTP 401 /
UNAUTHORIZED— stop and correct the token - Any unknown or future create error — treat it as ambiguous and stop; it is not evidence that a fresh paid create is safe
The run_job loop above encodes this positive allowlist. It advances only for the four named definitive create codes or for an expired order with no SMS, stops immediately on INSUFFICIENT_BALANCE, and fails closed on every unknown code. create_order generates no key itself: the caller creates one key per logical body, while REQUEST_IN_PROGRESS, 5xx, and network retries retain that exact body/key pair. One refinement to add for production is 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 asyncio
import hashlib
import hmac
import os
app = FastAPI()
WEBHOOK_SECRET = os.environ["SMSCODE_WEBHOOK_SECRET"]
pending_orders: dict[int, asyncio.Future[dict | None]] = {}
def verify_signature(body: bytes, signature: str) -> bool:
if not signature.startswith("sha256="):
return False
try:
provided = bytes.fromhex(signature.removeprefix("sha256="))
except ValueError:
return False
expected = hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256,
).digest()
return len(provided) == len(expected) and hmac.compare_digest(expected, provided)
@app.post("/webhooks/smscode")
async def handle_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-Webhook-Signature", "")
if not verify_signature(body, sig):
raise HTTPException(status_code=403, detail="Invalid signature")
payload = await request.json()
event = payload.get("event")
data = payload.get("data", {})
order_id = data.get("order_id")
future = pending_orders.get(order_id)
if future and not future.done():
if event == "order.otp_received":
future.set_result(data)
elif event in ("order.expired", "order.canceled"):
future.set_result(None)
return {"received": True}
async def wait_for_sms_via_webhook(order_id: int, timeout: int = 90) -> Optional[dict]:
loop = asyncio.get_running_loop()
future: asyncio.Future[dict | None] = 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.
Re-read capabilities after a timeout. A timeout proves only that no classified code was returned. OTP_RECEIVED means an SMS arrived, including message-only delivery where otp_code is null. If can_finish is true, the order should be finished; call cancel only when can_cancel is true. The cancel_order function above enforces that boundary.
Check available before ordering. The catalog product response includes an integer available estimate. If it’s below your batch size, you’ll likely hit NO_OFFER_AVAILABLE 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", 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 — NO_OFFER_AVAILABLE 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 guarded_run_job function in this guide converts unexpected exceptions into a per-job result before asyncio.gather sees them, so one failure cannot cancel the other jobs.
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