Getting Started with the SMSCode API

Getting Started with the SMSCode API

If you’re building anything that touches phone verification — account provisioning, QA automation, multi-region testing — doing it by hand through a dashboard gets old fast. The SMSCode API lets you automate the entire OTP flow from your backend: request a number, wait for the SMS, extract the code, move on. No clicking required.

This guide walks through authentication, the core endpoints, working code examples in both curl and Python, and a few common mistakes that’ll save you debugging time.

TL;DR: The SMSCode V1 API uses bearer token auth. The core flow is: check balance → browse catalog → create order → poll for OTP → complete or cancel. All responses follow { success, data } or { success, error } shapes. Rate limits apply; cache your catalog calls.

Authentication

Every request to the V1 API needs a bearer token in the Authorization header. You’ll find yours under Account Settings in the dashboard.

Authorization: Bearer YOUR_API_TOKEN

Keep this token server-side. Don’t put it in client-side JavaScript, environment files committed to git, or anywhere else it could leak. If a token is compromised, regenerate it from your account settings immediately.

The full API reference is in the docs. If you haven’t created an account yet, sign up here — you’ll need a balance to place orders.

Step 1: Verify your balance

Before placing any orders, check that your account has enough credits:

curl -X GET https://api.smscode.gg/v1/balance \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Response:

{
  "success": true,
  "data": {
    "currency": "IDR",
    "balance": 150000
  }
}

Balance is denominated in IDR (Indonesian Rupiah). If it’s zero or lower than the product you want, add funds on the pricing page before continuing.

Python equivalent:

import requests

API_TOKEN = "YOUR_API_TOKEN"
BASE_URL = "https://api.smscode.gg/v1"
headers = {"Authorization": f"Bearer {API_TOKEN}"}

resp = requests.get(f"{BASE_URL}/balance", headers=headers)
resp.raise_for_status()
data = resp.json()
print(f"Balance: {data['data']['balance']} {data['data']['currency']}")

Step 2: Browse the catalog

The catalog endpoint returns available virtual number products, filterable by the integer country and service IDs returned by /v1/catalog/countries and /v1/catalog/services. This is where you find the product_id you’ll use to place an order.

curl -X GET "https://api.smscode.gg/v1/catalog/products?country_id=7&platform_id=1" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

The response includes product IDs, pricing, and the integer available stock estimate. You can filter by country_id and platform_id — the virtual number catalog on the website gives you the same data visually if you want to explore first.

Practical advice: Don’t fetch the catalog on every order. Product lists don’t change by the second. Cache it for at least 60 seconds in production, longer if your traffic is high. This respects rate limits and keeps latency down.

Not sure which country to pick? The country selection guide covers the tradeoffs between price, reliability, and platform compatibility in detail.

Python equivalent:

params = {"country_id": 7, "platform_id": 1}
resp = requests.get(f"{BASE_URL}/catalog/products", headers=headers, params=params)
resp.raise_for_status()
products = resp.json()["data"]
# Pick the lowest-price product with stock available
product = min((p for p in products if p["available"] > 0), key=lambda p: p["price"])
product_id = product["id"]

Step 3: Create an order

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

IDEMPOTENCY_KEY="$(uuidgen)" # Generate once for this logical create
curl -X POST https://api.smscode.gg/v1/orders/create \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{"product_id": 42}'

Response:

{
  "success": true,
  "data": {
    "orders": [
      {
        "id": 90210,
        "phone_number": "+628123456789",
        "status": "ACTIVE",
        "expires_at": "2026-03-14T12:05:00Z",
        "product_id": 42,
        "amount": 350
      }
    ],
    "failed_count": 0
  }
}

The phone_number is what you enter in the app or website you’re verifying. The order status starts as ACTIVE — that’s normal. Now go trigger the OTP from the target service using that number.

Python equivalent:

import uuid

idempotency_key = str(uuid.uuid4())  # Generate once before the request
resp = requests.post(
    f"{BASE_URL}/orders/create",
    headers={
        **headers,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    },
    json={"product_id": product_id}
)
resp.raise_for_status()
order = resp.json()["data"]["orders"][0]
order_id = order["id"]
phone_number = order["phone_number"]
print(f"Use this number: {phone_number}")

Order creation charges balance. If the response is lost or a transient error is retried, reuse the same Idempotency-Key with the exact same request body. Never generate a new key inside the retry loop; a new key can create and debit a second order.

Step 4: Poll for the OTP

Check the order status until the SMS arrives:

curl -X GET https://api.smscode.gg/v1/orders/90210 \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Response when SMS arrives:

{
  "success": true,
  "data": {
    "id": 90210,
    "status": "OTP_RECEIVED",
    "otp_code": "847291",
    "otp_message": "Your verification code is 847291",
    "sms_revision": 1,
    "phone_number": "+628123456789"
  }
}

The status field tells you where you are:

  • ACTIVE — number activated, no SMS yet
  • OTP_RECEIVED — at least one SMS arrived; inspect both nullable otp_code and otp_message
  • COMPLETED — order finished
  • CANCELED — order was cancelled (refund issued if no SMS was received)
  • EXPIRED — the verification window closed

Python polling loop:

import time

max_wait = 120  # seconds
interval = 5    # poll every 5 seconds
elapsed = 0

while elapsed < max_wait:
    resp = requests.get(f"{BASE_URL}/orders/{order_id}", headers=headers)
    order_data = resp.json()["data"]
    status = order_data["status"]

    if order_data.get("otp_received_at") is not None:
        otp = order_data.get("otp_code")
        message = order_data.get("otp_message")
        if otp:
            print(f"OTP received: {otp}")
        else:
            print(f"SMS received without a detected code: {message}")
        break
    elif status in ("COMPLETED", "CANCELED", "EXPIRED"):
        print(f"Order ended with status: {status}")
        break

    time.sleep(interval)
    elapsed += interval
else:
    print("Timed out waiting for OTP")

A few things to know about polling: 5-second intervals are reasonable for most use cases. Going faster burns through rate limit budget without meaningful benefit — SMS networks don’t deliver codes faster just because you ask more often.

Step 5: Complete or cancel

When you’ve used the OTP, the order completes automatically. If you no longer need the number before any SMS arrives, cancel it while the server-authoritative can_cancel capability is true:

curl -X POST https://api.smscode.gg/v1/orders/cancel \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id": 90210}'

Cancelling before any SMS arrives issues an automatic refund. Once an SMS has been received, even without a detected code, delivery was successful and cancellation/refund is closed. Use the server-authoritative can_cancel capability rather than inferring eligibility from otp_code.

Error handling

All API errors follow the same shape:

{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Account balance is too low to place this order."
  }
}

Common error codes you’ll encounter:

Code What it means
UNAUTHORIZED Invalid or missing token
INSUFFICIENT_BALANCE Not enough credits — top up
NO_OFFER_AVAILABLE Stock ran out between catalog fetch and order
NOT_FOUND Wrong order ID or order doesn’t belong to your account
RATE_LIMIT_EXCEEDED Too many requests — back off

The NO_OFFER_AVAILABLE error catches a lot of people off guard. Catalog stock is live data. By the time you fetch the catalog and place an order, popular products can sell out. Always handle this error with a retry on a different product from the same catalog response.

Rate limits

The API enforces per-account rate limits. Hitting the limit returns a 429 status with a RATE_LIMIT_EXCEEDED error code. Standard accounts have limits that cover normal automation workloads.

Two practices that keep you well under limits:

  1. Cache catalog responses — don’t fetch the full catalog on every order cycle
  2. Poll at 5-second intervals rather than hammering the order endpoint

If you’re running bulk operations at scale and standard limits aren’t enough, reach out through the support channel. The API integration guide covers high-volume patterns in more depth.

Building a complete automation flow

For teams building production systems, here’s a pattern that handles the most common edge cases:

import requests
import time
import uuid
from typing import Optional

API_TOKEN = "YOUR_API_TOKEN"
BASE_URL = "https://api.smscode.gg/v1"
headers = {"Authorization": f"Bearer {API_TOKEN}"}

def get_virtual_number(country_id: int, platform_id: int) -> Optional[dict]:
    """Get a virtual number, trying multiple products if needed."""
    params = {"country_id": country_id, "platform_id": platform_id}
    resp = requests.get(f"{BASE_URL}/catalog/products", headers=headers, params=params)
    resp.raise_for_status()
    products = [p for p in resp.json().get("data", []) if p["available"] > 0]

    if not products:
        return None

    # Sort by price, try in order
    products.sort(key=lambda p: p["price"])

    for product in products:
        idempotency_key = str(uuid.uuid4())  # One key for this product/body
        resp = requests.post(
            f"{BASE_URL}/orders/create",
            headers={
                **headers,
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
            json={"product_id": product["id"]}
        )
        result = resp.json()

        if result.get("success"):
            return result["data"]["orders"][0]

        # If product unavailable, try next
        if result.get("error", {}).get("code") == "NO_OFFER_AVAILABLE":
            continue

        # Other errors — stop
        break

    return None

def wait_for_delivery(order_id: int, timeout: int = 120) -> Optional[dict]:
    """Poll for the first SMS delivery with timeout."""
    deadline = time.monotonic() + timeout

    while time.monotonic() < deadline:
        resp = requests.get(f"{BASE_URL}/orders/{order_id}", headers=headers)
        resp.raise_for_status()
        data = resp.json().get("data", {})

        if data.get("otp_received_at") is not None:
            return data

        if data.get("status") in ("COMPLETED", "CANCELED", "EXPIRED"):
            return None

        time.sleep(5)

    return None

# Usage
order = get_virtual_number(country_id=7, platform_id=1)
if order:
    print(f"Phone: {order['phone_number']}")
    # ... trigger OTP on target service ...
    delivery = wait_for_delivery(order["id"])
    if delivery and delivery.get("otp_code"):
        print(f"OTP: {delivery['otp_code']}")
    elif delivery:
        print(f"SMS received without a detected code: {delivery.get('otp_message')}")
    else:
        print("No SMS received")

This pattern handles NO_OFFER_AVAILABLE gracefully, respects rate limits through the 5-second polling interval, and has a clear timeout.

Common mistakes to avoid

Not handling NO_OFFER_AVAILABLE. Stock can empty between your catalog call and your order. Always catch this and fall back to the next product in your catalog results.

Polling too aggressively. Hitting the order endpoint every second won’t make SMS arrive faster. It will, however, get you rate limited faster. Five seconds is the sweet spot.

Hardcoding availability or price. Product IDs are stable tier-slot identifiers, but their price and available count change. Resolve the current product from the catalog at runtime using country_id and platform_id.

Ignoring expires_at. Orders have a finite verification window. If you wait too long to trigger the OTP after getting the number, the order expires. Trigger the OTP from the target service immediately after getting the phone number.

Not caching the catalog. The catalog endpoint can be called reasonably often, but fetching it on every single order in a high-volume loop is wasteful and will eat into your rate limit budget.

Assuming one country will always work. Different platforms have different acceptance rates for different country numbers. Build in fallback logic to try alternative countries if your primary choice fails.

Webhooks

Webhooks are available now. Configure the HTTPS URL and signing secret through PATCH /v1/webhook; SMSCode then sends signed order.otp_received and terminal events to your endpoint. Verify X-Webhook-Signature: sha256=<hex> against the raw request body before trusting an event, and keep polling as a recovery path. See the API reference for the configuration and test endpoints.

For a broader look at virtual number services and how SMSCode fits in, see the best virtual number services guide. If cost is a factor, finding cheap virtual numbers has a practical breakdown of where to look.


FAQ

Where do I find my API token?

In the dashboard under Account Settings. If you haven’t created an account yet, sign up here — it takes under a minute.

Is there a sandbox or test mode?

Not currently. All API calls run against the live system with real numbers. For development and testing, use the cheapest available products — costs stay low when you’re just validating your integration logic.

What’s the difference between COMPLETED and OTP_RECEIVED?

OTP_RECEIVED means at least one SMS arrived. A safely detected code appears in otp_code; otherwise that field is null and the exact delivered text after U+0000 removal remains available in otp_message. COMPLETED means the order lifecycle is fully done.

Can I use the API to receive multiple OTPs from the same number?

Some platforms send follow-up messages to the same number. Every novel SMS updates otp_message and increments sms_revision. otp_code updates only when the new message contains a safely detected code; a link-only or text-only follow-up keeps the last detected code. Compare sms_revision when ordering updates.

How do I handle a failed verification where the OTP did arrive?

If an SMS arrived but the target service rejected it (wrong code, expired, etc.), the delivered order cannot be canceled. Let it complete, create a new order, and try again with a fresh number. The number quality guide has tips on choosing products with better delivery and acceptance rates.

What languages does the API work with?

The API is a standard REST API over HTTPS. It works with any language that can make HTTP requests — Python, Node.js, Go, Ruby, PHP, Java, and so on. The examples in this guide use Python and curl, but the concepts translate directly.

How do I pick the best product when multiple options are available?

Sort catalog results by price and start with the cheapest option that has available stock. If that fails with NO_OFFER_AVAILABLE, try the next. For platforms where delivery rates vary significantly by country, the country selection guide has specific recommendations.

Ready to try SMSCode?

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

Get started →