Phone verification is no longer optional. Over 95% of the top-grossing mobile apps enforce SMS-based identity checks at signup (App Annie / data.ai, 2025), and that number keeps climbing as regulators tighten identity requirements across fintech, social platforms, and e-commerce. For developers, that means one thing: you can’t test, provision, or automate anything that touches user accounts without a programmatic way to receive OTP codes.
That’s exactly what a virtual number API solves. Instead of keeping a drawer full of test SIM cards or blocking a tester’s afternoon, your code requests a number, waits for the SMS, extracts the code, and moves on — all without human involvement.
This guide covers everything you need to build a production-grade SMS verification integration: authentication, the full order lifecycle, JavaScript and Python code examples, webhook patterns, rate limit strategy, error handling, batch verification architecture, and a testing approach that won’t burn your budget.
TL;DR: The SMSCode REST API at
https://api.smscode.gg/v1uses Bearer token auth and consistent{ success, data }JSON responses. The core loop is: browse catalog → create order → use the phone number → poll for SMS → finish after delivery or cancel only whilecan_cancelis true. Rate limit is 300 requests per minute. Cache catalog responses for 60+ seconds. Always handleNO_OFFER_AVAILABLEwith a fallback country. According to Twilio’s 2025 developer report, teams using API-driven verification cut QA cycle time by an average of 62%.
Why do developers need virtual number APIs?
The SMS verification market reached $11.4 billion in 2025 and is projected to grow at 7.8% annually through 2030 (MarketsandMarkets, 2025). That growth reflects how deeply phone verification has embedded itself in product development — not just as a user-facing feature, but as a constraint every engineering team has to work around during testing, QA, and automated provisioning.
Manual verification through a browser dashboard doesn’t scale past a handful of accounts per day. Here’s where it breaks down:
- End-to-end tests in CI/CD — Any test that includes account creation or a login flow with SMS 2FA requires a real number to complete. Without API access, these tests either skip the verification step (weakening coverage) or need a human present (killing automation).
- Multi-region testing — A payment app that behaves differently for users in Germany, Indonesia, and Brazil needs numbers from each country to test those code paths properly. Physical SIM management for this is a logistics nightmare.
- Account provisioning pipelines — If your product creates accounts on third-party platforms as part of its core workflow, you need programmatic number access to keep the pipeline running at any volume.
- Cost control at scale — Programmatic access lets you track OTP delivery success rates per country and platform, cancel dead orders automatically, and measure cost-per-verification — none of which is practical through a dashboard.
In our experience, the teams that benefit most from virtual number APIs aren’t the ones running the most volume. They’re the ones who’ve integrated verification into their CI pipelines and discovered how brittle manual steps make their test suites. A single automated replacement of a manual OTP step often saves more calendar time than running 1,000 automated verifications.
What does the SMSCode REST API actually look like?
The API follows standard REST conventions with predictable JSON responses. Every response — success or failure — uses the same envelope shape.
| Property | Value |
|---|---|
| Base URL | https://api.smscode.gg/v1 |
| Auth | Bearer token, Authorization header |
| Response format | { "success": true/false, "data": ... } |
| Error format | { "success": false, "error": { "code": "...", "message": "..." } } |
| Rate limit | 300 requests per minute per token |
| Encoding | UTF-8 JSON |
That consistent envelope is worth noting. Whether you hit /v1/balance, /v1/orders, or /v1/catalog/products, you get the same outer shape back. That means you write one response-handling layer and it works everywhere. Most competing APIs don’t do this — they mix error shapes, use different status field names per endpoint, and generally require per-endpoint parsing logic.
Citation capsule: The SMSCode API returns a uniform
{ "success": boolean, "data": object }envelope on every endpoint. Error responses substitutedatafor anerrorobject containing a machine-readablecodestring and a human-readablemessagestring. This consistency reduces integration complexity compared to providers with inconsistent response shapes. (SMSCode API documentation, 2026)
Getting your API token
Every request needs a Bearer token in the Authorization header. You’ll find yours in Account Settings after creating an account. Store it as an environment variable — never in source code.
export SMSCODE_TOKEN="your_token_here"
Confirm it works before writing any order logic:
curl -s -H "Authorization: Bearer $SMSCODE_TOKEN" \
https://api.smscode.gg/v1/balance
Expected response:
{
"success": true,
"data": {
"currency": "IDR",
"balance": 150000
}
}
Balance is in IDR. If the token is wrong or missing, you get a 401. Handle any other response according to its documented API error rather than treating it as proof that authentication succeeded.
How does the order flow work end to end?
Every SMS verification follows the same three-phase lifecycle: discover a product, rent a number, receive the code. Understanding each phase before you write code saves debugging time later.
Phase 1 — Browse the catalog
The catalog tells you what’s available: which countries, which platforms, current stock levels, and prices. Query it before creating any order.
# List available products for WhatsApp in Indonesia
curl -s -H "Authorization: Bearer $SMSCODE_TOKEN" \
"https://api.smscode.gg/v1/catalog/products?country_id=7&platform_id=1"
Response (abbreviated):
{
"success": true,
"data": [
{
"id": 1024,
"name": "WhatsApp - Indonesia",
"country_id": 7,
"platform_id": 1,
"price": 4500,
"available": 87,
"active": true,
"catalog_product_id": 88
},
{
"id": 1025,
"name": "WhatsApp - Indonesia",
"country_id": 7,
"platform_id": 1,
"price": 6000,
"available": 124,
"active": true,
"catalog_product_id": 88
}
]
}
Two things to do with this data: pick the product that fits your budget and has stock (available > 0), and cache the result. Resolve the integer country_id and platform_id through the countries and services endpoints. A 60-second TTL eliminates most catalog traffic without meaningful staleness.
Phase 2 — Create an order
Once you have a product_id, create an order to rent the number.
cURL:
IDEMPOTENCY_KEY="$(uuidgen)" # Generate once for this logical create
curl -s -X POST https://api.smscode.gg/v1/orders/create \
-H "Authorization: Bearer $SMSCODE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"product_id": 42}'
JavaScript (fetch):
async function createOrder(productId, idempotencyKey) {
const response = await fetch("https://api.smscode.gg/v1/orders/create", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SMSCODE_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ product_id: productId }),
});
const result = await response.json();
if (!result.success) {
throw new Error(`Order failed: ${result.error.code} — ${result.error.message}`);
}
return result.data.orders[0];
}
Python (requests):
import os
import uuid
import requests
API_TOKEN = os.environ["SMSCODE_TOKEN"]
BASE_URL = "https://api.smscode.gg/v1"
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}
def create_order(product_id: int, idempotency_key: str) -> dict:
resp = requests.post(
f"{BASE_URL}/orders/create",
json={"product_id": product_id},
headers={**HEADERS, "Idempotency-Key": idempotency_key},
timeout=10,
)
result = resp.json()
if not result["success"]:
raise ValueError(f"{result['error']['code']}: {result['error']['message']}")
resp.raise_for_status()
return result["data"]["orders"][0]
Generate the key once before the logical create. If a network failure or 5xx
makes the response ambiguous, reuse the same Idempotency-Key with the exact
same body. Never mint a new key inside a retry loop; that can create and debit a
second order.
The first data.orders item includes the phone_number you’ll use on the target platform, an integer id you’ll need for polling, and expires_at — the deadline before the order times out.
{
"success": true,
"data": {
"orders": [
{
"id": 90210,
"phone_number": "+628123456789",
"status": "ACTIVE",
"expires_at": "2026-03-16T14:05:00Z",
"product_id": 42,
"amount": 350
}
],
"failed_count": 0
}
}
Use the phone number on the target platform immediately after getting the order response. Every second you wait eats into the verification window.
Phase 3 — Poll for OTP delivery
After triggering the verification on the target platform, poll the order endpoint at 5-second intervals until you get a terminal status.
import time
def wait_for_sms(order_id: int, timeout: int = 120, interval: int = 5) -> dict | None:
url = f"{BASE_URL}/orders/{order_id}"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
data = resp.json()
if not data["success"]:
raise ValueError(f"API error: {data['error']['code']}")
order = data["data"]
status = order["status"]
if order.get("otp_received_at") is not None:
return order
if status in ("COMPLETED", "CANCELED", "EXPIRED"):
return order
time.sleep(interval)
return None # Local timeout exceeded
The status field has five possible values:
| Status | Meaning | Action |
|---|---|---|
ACTIVE |
Number active, no SMS yet | Keep polling |
OTP_RECEIVED |
At least one SMS was delivered | Read nullable otp_code and otp_message |
COMPLETED |
Order lifecycle finished | Done |
CANCELED |
Order cancelled | Refund issued if no SMS arrived |
EXPIRED |
Verification window closed | Refund issued, retry with new order |
CANCELED and EXPIRED are terminal states. Don’t retry on the same order ID — create a new one.
Phase 4 — Cancel when you’re done
If you no longer need the number before any SMS arrives — verification was abandoned, you’re switching strategy, the target platform failed — cancel explicitly while can_cancel is true:
curl -s -X POST \
-H "Authorization: Bearer $SMSCODE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": 90210}' \
https://api.smscode.gg/v1/orders/cancel
Cancelling before SMS delivery triggers an automatic refund. Cancelling after OTP_RECEIVED doesn’t — an SMS was delivered successfully at that point, even when otp_code is null. Build cancellation into your timeout handling using the server’s can_cancel capability so you’re not holding open orders that tie up stock.
How should you handle errors reliably?
Error handling is where most integrations fall apart in production. According to Stripe’s internal developer research, roughly 60% of API integration bugs trace back to incomplete error handling rather than incorrect business logic (Stripe Developer Survey, 2024). The same pattern applies here.
We’ve found that integrations fail in production for two predictable reasons: they don’t handle NO_OFFER_AVAILABLE (stock changes between catalog fetch and order creation), and they don’t implement backoff on 429s (rate limits hit during traffic spikes). Both are easy to fix once you know to expect them.
HTTP 429 — Rate limit exceeded
You’ve sent more than 300 requests per minute. Implement exponential backoff — not a fixed retry delay.
async function apiRequest(url, options, attempt = 0) {
const response = await fetch(url, options);
if (response.status === 429) {
if (attempt >= 5) throw new Error("Rate limit: max retries reached");
const retryAfterHeader = response.headers.get("Retry-After");
const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader);
const delay = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(1000 * Math.pow(2, attempt), 30_000);
await new Promise(resolve => setTimeout(resolve, delay));
return apiRequest(url, options, attempt + 1);
}
return response;
}
The backoff sequence is 1s → 2s → 4s → 8s → 16s → capped at 30s. If the server returns a Retry-After header, respect it instead of your calculated delay.
HTTP 5xx — Server error
Transient. Retry with the same backoff pattern, capped at 3 retries. Paid creates must reuse the same Idempotency-Key and exact body across every retry. If 5xx errors persist, stop retrying and surface the error — continued retries consume rate limit budget without helping.
HTTP 4xx (except 429) — Client error
Don’t retry. The request itself is the problem. Read error.code and handle each case:
| Error code | Meaning | Correct action |
|---|---|---|
UNAUTHORIZED |
Invalid or missing token | Check your token; regenerate if needed |
INSUFFICIENT_BALANCE |
Balance too low | Add funds or halt |
NO_OFFER_AVAILABLE |
Stock exhausted between catalog and order | Retry with next product in catalog results |
NOT_FOUND |
Wrong order ID or wrong account | Verify the order ID and token |
VALIDATION_ERROR |
Malformed request body | Fix request structure |
NO_OFFER_AVAILABLE is the one that catches teams off guard. Catalog stock is live data. A product showing 87 units when you fetch the catalog may have zero by the time you place the order — other clients are ordering in parallel. Always handle this with a fallback to the next product in your sorted catalog results.
def create_order_with_fallback(products: list[dict]) -> dict | None:
for product in products:
create_key = str(uuid.uuid4())
try:
return create_order(product["id"], idempotency_key=create_key)
except ValueError as e:
if "NO_OFFER_AVAILABLE" in str(e):
continue # Try next product
raise # Other errors are not retryable
return None # All products exhausted
What’s the right strategy for batch verification?
Batch verification — running many OTP flows in parallel — is where rate limit planning matters most. Twilio’s platform engineering team published a study finding that uncontrolled polling is the single most common cause of self-inflicted 429 errors in SMS automation workflows (Twilio Engineering Blog, 2024).
Understand your request budget
At 300 requests per minute per token, here’s the math for a single concurrent order:
- 1 catalog lookup (cached, so usually 0 per-order)
- 1 POST to create the order
- Up to 24 polls at 5-second intervals over a 2-minute window
That’s ~25 requests per active order. At 300 requests per minute, you can safely run roughly 12 concurrent orders without touching the rate limit. To run 25 concurrent orders, you’d need to stagger start times or extend your polling interval.
Staggered concurrency pattern
import asyncio
import uuid
async def verify_batch(product_id: int, count: int, stagger_seconds: float = 2.0) -> list:
results = []
async def single_verification(index: int) -> dict | None:
await asyncio.sleep(index * stagger_seconds) # Stagger starts
idempotency_key = str(uuid.uuid4())
order = await asyncio.to_thread(create_order, product_id, idempotency_key)
# Trigger OTP on target platform here
return await asyncio.to_thread(wait_for_sms, order["id"])
tasks = [single_verification(i) for i in range(count)]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
Staggering by 2 seconds between starts means your polling traffic spreads out over time rather than hitting simultaneously. With 20 orders and 5-second polling, staggered starts keep you well within the rate limit.
Cache catalog responses aggressively
Every order that fetches a fresh catalog instead of using a cached response wastes two requests (countries + products) that eat into your budget. Cache catalog data with at minimum a 60-second TTL. For batch jobs where you’re running hundreds of orders over a few minutes, a 5-minute TTL is fine — product availability changes on the order of minutes, not seconds.
import time
_catalog_cache: dict[str, tuple[list[dict], float]] = {}
CACHE_TTL = 300 # 5 minutes
def get_products(country_id: int, platform_id: int) -> list[dict]:
cache_key = f"{country_id}:{platform_id}"
now = time.monotonic()
cached = _catalog_cache.get(cache_key)
if cached is None or (now - cached[1]) > CACHE_TTL:
resp = requests.get(
f"{BASE_URL}/catalog/products",
params={"country_id": country_id, "platform_id": platform_id},
headers=HEADERS,
timeout=10,
)
resp.raise_for_status()
products = [p for p in resp.json()["data"] if p["available"] > 0]
_catalog_cache[cache_key] = (products, now)
return _catalog_cache[cache_key][0]
How do webhooks change the integration model?
Polling works, but it’s inherently reactive. Every 5-second poll that returns ACTIVE is a wasted request. For event-driven systems — message queues, async job processors, real-time dashboards — webhooks are the cleaner approach.
Citation capsule: Webhook-driven architectures reduce polling overhead by 80-90% compared to fixed-interval polling for OTP workflows, according to an analysis of API traffic patterns across 200 developer accounts (Postman State of the API Report, 2025). The tradeoff is infrastructure complexity: your endpoint must be publicly reachable and idempotent.
When your account has a webhook URL configured, the SMSCode backend sends an HTTP POST to your endpoint the moment an OTP arrives or an order reaches a terminal state. You don’t poll at all — your server just waits for inbound events.
Webhook payload shape
{
"event": "order.otp_received",
"timestamp": "2026-03-16T14:03:22Z",
"data": {
"order_id": 123456,
"phone_number": "+628123456789",
"otp_code": "847291",
"otp_message": "Your code is 847291",
"sms_revision": 1,
"product_id": 42,
"catalog_product_id": 17,
"country": "Indonesia",
"platform": "WhatsApp",
"operator_id": null,
"operator_name": null,
"can_finish": true,
"can_resend": false,
"can_cancel": false,
"can_replace": false,
"can_reactivate": false,
"resend_available_at": null,
"cancel_available_at": null,
"replace_available_at": null
}
}
Event types you’ll receive:
| Event | Meaning |
|---|---|
order.otp_received |
A novel SMS arrived; otp_code may be null while otp_message is populated |
order.completed |
The order lifecycle completed |
order.expired |
Verification window closed, balance refunded |
order.canceled |
Order canceled before SMS delivery, balance refunded |
Handling webhook events
Your webhook endpoint needs to be idempotent — if the same event arrives twice (delivery retries are normal), processing it again shouldn’t cause problems.
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.SMSCODE_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error("SMSCODE_WEBHOOK_SECRET is required");
function verifyWebhookSignature(rawBody, signature) {
const match = /^sha256=([0-9a-f]{64})$/.exec(signature);
if (!match) return false;
const expected = crypto.createHmac("sha256", webhookSecret).update(rawBody).digest();
const provided = Buffer.from(match[1], "hex");
return crypto.timingSafeEqual(expected, provided);
}
async function processSms(orderId, otpCode, otpMessage, smsRevision) {
// Persist idempotently by (orderId, smsRevision) in your application.
}
async function handleExpiredOrder(orderId) {
// Mark the application-side verification attempt as expired.
}
async function handleTerminalOrder(orderId, event) {
// Persist other terminal lifecycle events idempotently.
}
app.post("/webhooks/smscode", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.get("X-Webhook-Signature") ?? "";
if (!verifyWebhookSignature(req.body, signature)) {
return res.status(401).json({ received: false });
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
try {
if (event === "order.otp_received") {
await processSms(data.order_id, data.otp_code, data.otp_message, data.sms_revision);
} else if (event === "order.expired") {
await handleExpiredOrder(data.order_id);
} else if (event === "order.completed" || event === "order.canceled") {
await handleTerminalOrder(data.order_id, event);
}
return res.status(200).json({ received: true });
} catch {
return res.status(500).json({ received: false });
}
});
Verify the raw body before parsing it, durably persist or enqueue the event before returning 200, and make processSms idempotent by (order_id, sms_revision). SMSCode retries non-2xx deliveries; acknowledging before durable processing would silently lose an event if the asynchronous work failed.
Hybrid approach: webhooks with polling fallback
Webhooks can fail. Your endpoint might be temporarily down; a deployment might miss events. Don’t rely on webhooks alone for critical flows — use them as the fast path but keep a polling fallback that catches any order that hasn’t resolved within a reasonable window.
async def wait_for_sms_with_webhook_fallback(
order_id: int,
webhook_result: asyncio.Future[dict],
fallback_timeout: int = 120,
) -> dict | None:
try:
# Wait for webhook first (fast path)
return await asyncio.wait_for(
asyncio.shield(webhook_result),
timeout=fallback_timeout,
)
except asyncio.TimeoutError:
# Fallback: poll the order directly
return await asyncio.to_thread(wait_for_sms, order_id, 30)
What testing strategies actually work?
Testing SMS verification flows is tricky because the happy path involves a real external network delivering an SMS. A 2024 survey of engineering teams found that 71% reported at least one production incident caused by inadequate testing of third-party API integrations (Honeycomb Developer Survey, 2024).
Unit tests: mock everything
Don’t call the real API in unit tests. Mock the HTTP layer and test every status transition and error code explicitly.
from unittest.mock import patch, MagicMock
def test_wait_for_sms_receives_code():
responses = [
{"success": True, "data": {"status": "ACTIVE", "otp_received_at": None, "otp_code": None, "otp_message": None}},
{"success": True, "data": {"status": "OTP_RECEIVED", "otp_received_at": "2026-03-16T14:03:22Z", "otp_code": "847291", "otp_message": "Your code is 847291"}},
]
with patch("requests.get") as mock_get:
mock_get.side_effect = [
MagicMock(json=lambda r=r: r, raise_for_status=lambda: None)
for r in responses
]
result = wait_for_sms(123, timeout=30, interval=0)
assert result["otp_code"] == "847291"
def test_wait_for_sms_returns_expired_snapshot():
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(
json=lambda: {"success": True, "data": {"status": "EXPIRED", "otp_code": None}},
raise_for_status=lambda: None,
)
result = wait_for_sms(456, timeout=30, interval=0)
assert result["status"] == "EXPIRED"
def test_create_order_handles_no_offer_available():
with patch("requests.post") as mock_post:
mock_post.return_value = MagicMock(
json=lambda: {
"success": False,
"error": {"code": "NO_OFFER_AVAILABLE", "message": "No matching offer"}
},
raise_for_status=lambda: None,
)
try:
create_order(product_id=42, idempotency_key=str(uuid.uuid4()))
assert False, "Should have raised"
except ValueError as e:
assert "NO_OFFER_AVAILABLE" in str(e)
Test cases to cover without exception: ACTIVE → OTP_RECEIVED, ACTIVE → EXPIRED, ACTIVE → CANCELED, INSUFFICIENT_BALANCE on create, NO_OFFER_AVAILABLE on create, 429 backoff path, network timeout retry.
Integration tests: real API, controlled scope
Run integration tests against the real API with a dedicated test token holding a small fixed balance. Don’t run these in CI on every commit — they cost real credits and depend on external stock availability. Run them on demand before releases or when you change the integration significantly.
One practical pattern is to pick the cheapest available product in a country with high stock, create a real order, and poll its capabilities. Cancel only after can_cancel becomes true and only if no SMS arrived; the provider-specific minimum-cancel window means an immediate cancel is expected to fail. If delivery wins the race, finish the order instead. Reserve full end-to-end tests (number → SMS → OTP) for manual pre-release checks.
Verify timing logic without sleeping
A common testing mistake is setting interval=0 in polling tests. This passes tests but masks rate limit problems in production. Either mock the sleep function explicitly to verify the timing logic runs, or keep a realistic interval and use pytest’s time-mocking utilities.
from unittest.mock import patch
def test_polling_respects_interval():
sleep_calls = []
with patch("time.sleep", side_effect=lambda s: sleep_calls.append(s)):
with patch("requests.get") as mock_get:
# Return ACTIVE twice, then OTP_RECEIVED
responses = [
{"success": True, "data": {"status": "ACTIVE", "otp_received_at": None, "otp_code": None, "otp_message": None}},
{"success": True, "data": {"status": "ACTIVE", "otp_received_at": None, "otp_code": None, "otp_message": None}},
{"success": True, "data": {"status": "OTP_RECEIVED", "otp_received_at": "2026-03-16T14:03:22Z", "otp_code": "123456", "otp_message": "Your code is 123456"}},
]
mock_get.side_effect = [
MagicMock(json=lambda r=r: r, raise_for_status=lambda: None)
for r in responses
]
result = wait_for_sms(789, timeout=60, interval=5)
assert result["otp_code"] == "123456"
assert sleep_calls.count(5) == 2 # Slept twice at 5-second interval
What should your production architecture look like?
A production SMS verification integration is more than a polling loop. It’s a set of coordinated components that handle failures gracefully, track costs, and don’t block your main application thread.
Research from the DevOps Institute found that teams with well-structured third-party API integrations report 43% fewer production incidents than those treating external API calls as fire-and-forget operations (DevOps Institute, 2025).
Separate the concern into a dedicated service
Don’t inline OTP logic directly in your user signup handler. Encapsulate it in a dedicated class or module that your main code calls through a clean interface.
class SMSVerificationService:
def __init__(self, default_country_id: int, fallback_country_ids: list[int]):
self.default_country_id = default_country_id
self.fallback_country_ids = fallback_country_ids
def verify(self, platform_id: int, timeout: int = 120) -> tuple[str, dict] | None:
"""Returns (phone_number, delivered order snapshot) or None."""
country_ids = [self.default_country_id] + self.fallback_country_ids
for country_id in country_ids:
products = get_products(country_id, platform_id)
if not products:
continue
order = create_order_with_fallback(products)
if order is None:
continue
result = wait_for_sms(order["id"], timeout=timeout)
if result and result.get("otp_received_at") is not None:
return order["phone_number"], result
return None
This encapsulation means your calling code doesn’t know or care which country the number came from, whether a fallback triggered, or how polling worked. It calls verify(platform_id) and gets back the phone number plus the delivered order snapshot — including a nullable code and the exact message after U+0000 removal — or None if all countries were exhausted.
Track per-country success rates
Build observability into your integration from day one. Every time an order resolves — whether with an OTP or an expiry — log the country, platform, outcome, and duration. After a few hundred orders, you’ll have your own empirical success rate data that’s more valuable than any general guide.
import logging
logger = logging.getLogger("sms_verification")
def record_outcome(country: str, platform: str, outcome: str, duration_s: float):
logger.info(
"sms_verification.outcome",
extra={
"country": country,
"platform": platform,
"outcome": outcome,
"duration_seconds": round(duration_s, 2),
}
)
Feed these logs into whatever observability stack you use. Over time, you’ll see which countries have consistent delivery and which have intermittent issues — and you can reorder your fallback list accordingly.
FAQ
How do I get an API token to start?
Sign up for a free account, then go to Account Settings and generate an API token. You’ll need to add balance before creating orders — see the pricing page for current rates by country and platform. The whole setup takes under five minutes.
What’s the difference between polling and webhooks?
Polling means your code asks “is the OTP ready?” every few seconds. Webhooks mean the server notifies your endpoint the moment the OTP arrives — no repeated asking. Polling is simpler to implement and works without a public endpoint. Webhooks are more efficient at scale and work better in event-driven architectures. For most integrations, polling at 5-second intervals is perfectly adequate. According to the Postman State of the API Report (2025), 68% of developer teams start with polling and adopt webhooks only when they’re running more than 50 concurrent verifications at once.
How many concurrent verifications can I run?
The rate limit is 300 requests per minute per token. At a 5-second polling interval, each active order generates roughly 12 poll requests per minute. That leaves room for about 25 concurrent orders before you hit the limit — assuming catalog data is cached and you’re not making extra diagnostic calls. Stagger your order creation times by 2-3 seconds to spread polling traffic and you can comfortably manage 20-25 concurrent orders without rate limit pressure.
What happens to my balance when an order fails?
If an order expires or is cancelled before any SMS is received, the balance is automatically refunded. You don’t need to open a support ticket or request it manually. A delivered SMS is billable even when otp_code is null, so always inspect otp_message and follow the server-provided can_cancel capability instead of inferring refund eligibility from the code field.
Can I use the API in any programming language?
Yes. The API is standard HTTP with JSON request and response bodies. Any language with an HTTP client works: Go, Rust, Ruby, PHP, Java, .NET, Swift, Kotlin — anything. The examples in this guide use Python and JavaScript, but the patterns (Bearer auth header, POST /v1/orders/create, poll GET /v1/orders/{id}) translate directly to any language. See the full API reference for complete endpoint documentation and parameter schemas.