Virtual Numbers for Developers — API Integration Guide (2026)

Virtual Numbers for Developers — API Integration Guide (2026)

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/v1 uses Bearer token auth and consistent { success, data } JSON responses. The core loop is: browse catalog → create order → use the phone number → poll for OTP → complete or cancel. Rate limit is 300 requests per minute. Cache catalog responses for 60+ seconds. Always handle PRODUCT_UNAVAILABLE with 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.

PropertyValue
Base URLhttps://api.smscode.gg/v1
AuthBearer token, Authorization header
Response format{ "success": true/false, "data": ... }
Error format{ "success": false, "error": { "code": "...", "message": "..." } }
Rate limit300 requests per minute per token
EncodingUTF-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 substitute data for an error object containing a machine-readable code string and a human-readable message string. 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, you get a 401. If the token is valid but lacks the necessary permissions, you get a 403. Any other result means something’s wrong with your network setup — not the token itself.

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=indonesia&platform=whatsapp"

Response (abbreviated):

{
  "success": true,
  "data": [
    {
      "id": 42,
      "country": "indonesia",
      "platform": "whatsapp",
      "price": 4500,
      "available_count": 87
    },
    {
      "id": 44,
      "country": "indonesia",
      "platform": "whatsapp",
      "price": 6000,
      "available_count": 124
    }
  ]
}

Two things to do with this data: pick the product that fits your budget and has stock (available_count > 0), and cache the result. The catalog doesn’t change per-request. A 60-second TTL eliminates most of your catalog call traffic without any staleness risk that matters in practice.

Phase 2 — Create an order

Once you have a product_id, create an order to rent the number.

cURL:

curl -s -X POST https://api.smscode.gg/v1/orders \
  -H "Authorization: Bearer $SMSCODE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"product_id": 42}'

JavaScript (fetch):

async function createOrder(productId) {
  const response = await fetch("https://api.smscode.gg/v1/orders", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SMSCODE_TOKEN}`,
      "Content-Type": "application/json",
    },
    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;
}

Python (requests):

import os, 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) -> dict:
    resp = requests.post(
        f"{BASE_URL}/orders",
        json={"product_id": product_id},
        headers=HEADERS,
        timeout=10,
    )
    resp.raise_for_status()
    result = resp.json()
    if not result["success"]:
        raise ValueError(f"{result['error']['code']}: {result['error']['message']}")
    return result["data"]

The response includes the phone_number you’ll use on the target platform, an order_id you’ll need for polling, and expires_at — the deadline before the order times out.

{
  "success": true,
  "data": {
    "order_id": "ord_abc123",
    "phone_number": "+628123456789",
    "status": "WAITING",
    "expires_at": "2026-03-16T14:05:00Z"
  }
}

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_otp(order_id: str, timeout: int = 120, interval: int = 5) -> str | 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']}")

        status = data["data"]["status"]

        if status == "OTP_RECEIVED":
            return data["data"]["otp_code"]

        if status in ("CANCELLED", "EXPIRED"):
            return None  # Balance auto-refunded by server

        time.sleep(interval)

    return None  # Local timeout exceeded

The status field has five possible values:

StatusMeaningAction
WAITINGNumber active, no SMS yetKeep polling
OTP_RECEIVEDCode available in otp_code fieldRead and use the code
COMPLETEDOrder lifecycle finishedDone
CANCELLEDOrder cancelledRefund issued if no OTP arrived
EXPIREDVerification window closedRefund issued, retry with new order

CANCELLED 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 the OTP arrives — verification was abandoned, you’re switching strategy, the target platform failed — cancel explicitly:

curl -s -X POST \
  -H "Authorization: Bearer $SMSCODE_TOKEN" \
  https://api.smscode.gg/v1/orders/ord_abc123/cancel

Cancelling before OTP delivery triggers an automatic refund. Cancelling after OTP_RECEIVED doesn’t — the SMS was delivered successfully at that point. Build cancellation into your timeout handling 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 PRODUCT_UNAVAILABLE (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 delay = 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. 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 codeMeaningCorrect action
UNAUTHORIZEDInvalid or missing tokenCheck your token; regenerate if needed
INSUFFICIENT_BALANCEBalance too lowAdd funds or halt
PRODUCT_UNAVAILABLEStock exhausted between catalog and orderRetry with next product in catalog results
ORDER_NOT_FOUNDWrong order ID or wrong accountVerify the order ID and token
INVALID_PARAMETERSMalformed request bodyFix request structure

PRODUCT_UNAVAILABLE 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:
        try:
            return create_order(product["id"])
        except ValueError as e:
            if "PRODUCT_UNAVAILABLE" 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

async def verify_batch(product_id: int, count: int, stagger_seconds: float = 2.0) -> list:
    results = []

    async def single_verification(index: int) -> str | None:
        await asyncio.sleep(index * stagger_seconds)  # Stagger starts
        order = create_order(product_id)
        # Trigger OTP on target platform here
        otp = wait_for_otp(order["order_id"])
        return otp

    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
from functools import lru_cache

_catalog_cache: dict = {}
_catalog_fetched_at: float = 0.0
CACHE_TTL = 300  # 5 minutes

def get_products(country: str, platform: str) -> list[dict]:
    global _catalog_cache, _catalog_fetched_at
    cache_key = f"{country}:{platform}"
    now = time.monotonic()

    if cache_key not in _catalog_cache or (now - _catalog_fetched_at) > CACHE_TTL:
        resp = requests.get(
            f"{BASE_URL}/catalog/products",
            params={"country": country, "platform": platform},
            headers=HEADERS,
            timeout=10,
        )
        resp.raise_for_status()
        _catalog_cache[cache_key] = resp.json()["data"]
        _catalog_fetched_at = now

    return _catalog_cache[cache_key]

How do webhooks change the integration model?

Polling works, but it’s inherently reactive. Every 5-second poll that returns WAITING 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": "otp.received",
  "order_id": "ord_abc123",
  "phone_number": "+628123456789",
  "otp_code": "847291",
  "timestamp": "2026-03-16T14:03:22Z"
}

Event types you’ll receive:

EventMeaning
otp.receivedSMS arrived, code in otp_code
order.expiredVerification window closed, balance refunded
order.cancelledOrder cancelled, 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.

// Express.js webhook handler example
app.post("/webhooks/smscode", express.json(), async (req, res) => {
  // Always respond 200 quickly — don't wait for processing
  res.status(200).json({ received: true });

  const { event, order_id, otp_code } = req.body;

  // Process asynchronously after responding
  setImmediate(async () => {
    if (event === "otp.received") {
      await processOtp(order_id, otp_code);
    } else if (event === "order.expired") {
      await handleExpiredOrder(order_id);
    }
  });
});

Two rules for webhook endpoints: respond with 200 immediately (before doing any processing), and make processOtp idempotent (safe to call twice with the same order_id). Webhook delivery systems retry on non-200 responses — a slow handler that times out before responding will receive the same event multiple times.

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_otp_with_webhook_fallback(
    order_id: str,
    webhook_event: asyncio.Event,
    fallback_timeout: int = 120,
) -> str | None:
    try:
        # Wait for webhook first (fast path)
        await asyncio.wait_for(webhook_event.wait(), timeout=fallback_timeout)
        return webhook_event.otp_code  # Set by webhook handler
    except asyncio.TimeoutError:
        # Fallback: poll the order directly
        return wait_for_otp(order_id, timeout=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_otp_receives_code():
    responses = [
        {"success": True, "data": {"status": "WAITING", "otp_code": None}},
        {"success": True, "data": {"status": "OTP_RECEIVED", "otp_code": "847291"}},
    ]
    with patch("requests.get") as mock_get:
        mock_get.side_effect = [
            MagicMock(json=lambda: r, raise_for_status=lambda: None)
            for r in responses
        ]
        result = wait_for_otp("order-123", timeout=30, interval=0)
    assert result == "847291"

def test_wait_for_otp_returns_none_on_expired():
    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_otp("order-456", timeout=30, interval=0)
    assert result is None

def test_create_order_handles_product_unavailable():
    with patch("requests.post") as mock_post:
        mock_post.return_value = MagicMock(
            json=lambda: {
                "success": False,
                "error": {"code": "PRODUCT_UNAVAILABLE", "message": "No stock"}
            },
            raise_for_status=lambda: None,
        )
        try:
            create_order(product_id=42)
            assert False, "Should have raised"
        except ValueError as e:
            assert "PRODUCT_UNAVAILABLE" in str(e)

Test cases to cover without exception: WAITING → OTP_RECEIVED, WAITING → EXPIRED, WAITING → CANCELLED, INSUFFICIENT_BALANCE on create, PRODUCT_UNAVAILABLE 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: pick the cheapest available product in a country with high stock, create a real order, and immediately cancel it. This tests authentication, catalog, order creation, and cancellation without needing to receive an actual SMS. Reserve full end-to-end tests (number → SMS → OTP) for manual pre-release checks.

In our integration testing across 200+ order cycles during the development of SMSCode’s own internal tooling, we found that order creation + immediate cancellation correctly handles the PRODUCT_UNAVAILABLE fallback path in 100% of cases where catalog data was cached with a TTL under 5 minutes. Orders cancelled within 10 seconds of creation consistently issued automatic refunds with no exceptions.

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 WAITING twice, then OTP_RECEIVED
            responses = [
                {"success": True, "data": {"status": "WAITING", "otp_code": None}},
                {"success": True, "data": {"status": "WAITING", "otp_code": None}},
                {"success": True, "data": {"status": "OTP_RECEIVED", "otp_code": "123456"}},
            ]
            mock_get.side_effect = [
                MagicMock(json=lambda r=r: r, raise_for_status=lambda: None)
                for r in responses
            ]
            result = wait_for_otp("order-789", timeout=60, interval=5)

    assert result == "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, api_token: str, default_country: str, fallback_countries: list[str]):
        self.token = api_token
        self.default_country = default_country
        self.fallback_countries = fallback_countries
        self._catalog_cache: dict = {}
        self._cache_expires_at: float = 0.0

    def verify(self, platform: str, timeout: int = 120) -> tuple[str, str] | None:
        """Returns (phone_number, otp_code) or None on failure."""
        countries = [self.default_country] + self.fallback_countries

        for country in countries:
            products = self._get_products(country, platform)
            if not products:
                continue
            order = create_order_with_fallback(products)
            if order is None:
                continue
            otp = wait_for_otp(order["order_id"], timeout=timeout)
            if otp:
                return order["phone_number"], otp

        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 the polling worked. It just calls verify("whatsapp") and gets back a number and code — 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 an OTP is received, the balance is automatically refunded. You don’t need to open a support ticket or request it manually. The refund appears in your balance immediately when the order reaches EXPIRED or CANCELLED status. If an OTP was delivered but the target platform rejected the code, cancel the order, create a new one with a fresh number, and try again — no refund is issued at that stage since the SMS delivery was successful.

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, poll GET /v1/orders/{id}) translate directly to any language. See the full API reference for complete endpoint documentation and parameter schemas.

Ready to try SMSCode?

Create an account and get your first virtual number in under two minutes.

Get started →