Cómo Usar la API de SMSCode (2026)

Cómo Usar la API de SMSCode (2026)

La API de SMSCode permite automatizar la obtención de números virtuales y la recepción de códigos de verificación SMS de forma programática. En lugar de hacer el proceso manualmente desde el panel web, podés integrar la verificación SMS directamente en tus scripts, pipelines de automatización o aplicaciones.

Esta guía cubre todo lo que necesitás para integrar la API de SMSCode en tus proyectos, desde la autenticación hasta el flujo completo de verificación con ejemplos reales de código.

TL;DR: La API de SMSCode usa autenticación por token Bearer. El flujo principal es: obtener número → polling/webhook para el código → confirmar o cancelar. No es compatible a nivel de protocolo con SMS-Activate: una migración debe adaptar la autenticación, los endpoints y las respuestas JSON. Los ejemplos en Python, Node.js y PHP están en esta guía.


¿Qué podés hacer con la API de SMSCode?

La API permite automatizar el ciclo completo de verificación SMS:

  1. Consultar disponibilidad: Ver qué países y plataformas tienen números disponibles en tiempo real, con precios.
  2. Obtener un número: Reservar un número virtual para una plataforma y país específicos.
  3. Recibir el código: Hacer polling al endpoint para verificar si llegó el SMS, o usar webhooks para notificación push.
  4. Confirmar o cancelar: Marcar la orden como completada o, cuando can_cancel sea true, cancelarla y leer del servidor el importe devuelto y el nuevo saldo.
  5. Consultar saldo: Ver el saldo disponible en la cuenta.

Para proyectos que necesitan verificar decenas o cientos de cuentas de forma automatizada — testing de QA, herramientas de marketing, investigación de mercado, o bots de automatización — la API es el camino correcto.


Obtener el token de API

Antes de hacer cualquier llamada a la API, necesitás tu token de autenticación.

Dónde encontrarlo

  1. Iniciá sesión en SMSCode.
  2. Andá a la configuración de tu cuenta (generalmente en el menú de usuario o en “Configuración” / “API”).
  3. Generá o copiá tu token de API.

El token es una cadena alfanumérica única asociada a tu cuenta. Trátalo como una contraseña — no lo compartas públicamente, no lo incluyas en repositorios de código abiertos, y rotalo periódicamente si lo usás en entornos de producción.

Formato de autenticación

La API usa autenticación Bearer. En cada request, incluís el token en el header de la siguiente forma:

Authorization: Bearer TU_TOKEN_AQUI

Endpoints principales

La API pública de SMSCode usa endpoints REST, autenticación Bearer y respuestas JSON. Si ya trabajaste con SMS-Activate u otro protocolo basado en acciones por query string y respuestas de texto, tenés que adaptar la integración; no alcanza con cambiar la URL base.

Base URL: https://api.smscode.gg/v1/

1. Obtener saldo

GET /v1/balance

Retorna el saldo actual de la cuenta.

Ejemplo de respuesta:

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

2. Consultar precios y disponibilidad

GET /v1/catalog/products?country_id={country_id}&platform_id={platform_id}

Retorna precios y números disponibles para una combinación de servicio y país.

Parámetros:

  • country_id: ID del país obtenido desde el catálogo.
  • platform_id: ID de la plataforma obtenido desde el catálogo.

Ejemplo de respuesta:

{
  "success": true,
  "data": [
    {
      "id": 1024,
      "name": "WhatsApp - Indonesia",
      "country_id": 7,
      "platform_id": 1,
      "available": 142,
      "price": 25000,
      "active": true,
      "catalog_product_id": 88
    }
  ],
  "meta": { "page": 1, "limit": 1000, "count": 1 }
}

3. Obtener número

POST /v1/orders/create

Crea una nueva orden para obtener un número virtual.

Body (JSON):

{
  "catalog_product_id": 88,
  "quantity": 1
}

Ejemplo de respuesta:

{
  "success": true,
  "data": {
    "orders": [{
      "id": 90210,
      "status": "ACTIVE",
      "phone_number": "628123456789",
      "otp_code": null,
      "otp_received_at": null,
      "expires_at": "2026-03-17T15:30:00Z",
      "failed_reason": null,
      "product_id": 1024,
      "catalog_product_id": 88,
      "operator_id": null,
      "operator_name": null,
      "amount": 750000,
      "can_finish": false,
      "can_resend": false,
      "can_cancel": false,
      "can_replace": false,
      "can_reactivate": false,
      "resend_available_at": null,
      "cancel_available_at": "2026-03-17T15:12:00Z",
      "replace_available_at": "2026-03-17T15:12:00Z"
    }],
    "failed_count": 0
  }
}

phone_number es opcional y puede ser null hasta que se asigne. Un flujo durable hace como máximo una lectura acotada de GET /v1/orders/{id} con la misma id entera; si el campo todavía no es un texto no vacío, queda en el estado local pending_assignment. Usá el número recién cuando esté asignado. Generá un Idempotency-Key antes del primer POST y, ante un resultado ambiguo, reutilizá esa misma clave con exactamente el mismo body.

4. Verificar el estado de la orden (polling)

GET /v1/orders/{order_id}

Retorna el snapshot actual de la orden, incluido el otp_message durable y su sms_revision monótona cuando existe; otp_code puede seguir siendo null.

Proyección de V1OrderSummary con mensaje persistido (campos seleccionados, no la respuesta wire completa):

{
  "success": true,
  "data": {
    "id": 90210,
    "phone_number": "628123456789",
    "status": "OTP_RECEIVED",
    "otp_code": null,
    "otp_message": "Tu código de verificación es 123456",
    "sms_revision": 1,
    "can_cancel": false,
    "otp_received_at": "2026-03-17T15:25:14Z"
  }
}

Estados posibles:

  • ACTIVE: Esperando el SMS.
  • OTP_RECEIVED: Estado de ciclo de vida, no condición para consumir el mensaje.
  • COMPLETED: La orden ya terminó; procesá primero una revisión nueva y después detené el polling.
  • EXPIRED: La orden expiró en el servidor; procesá primero una revisión nueva y después detené el polling.
  • CANCELED: La orden fue cancelada; procesá primero una revisión nueva y después detené el polling.

Un snapshot posterior puede tener un estado terminal y conservar un SMS cuya revisión todavía no procesaste. Inicializá last_seen_revision en -1 para cada orden y, antes de evaluar el estado terminal, consumí solamente un sms_revision entero y estrictamente mayor con un otp_message de texto no vacío.

5. Cancelar una orden

POST /v1/orders/cancel

Enviá {"id":90210} solo cuando el snapshot actual indique can_cancel: true.

Ejemplo de respuesta:

{
  "success": true,
  "data": {
    "order_id": 90210,
    "status": "CANCELED",
    "refund_amount": 750000,
    "new_balance": 2000000
  }
}

Vinculá cada validación al ID solicitado: data.order_id en el recibo del POST y data.id en cada snapshot GET deben estar presentes como enteros int32 no booleanos y coincidir exactamente con ese ID. No uses valores por defecto; si falta o no coincide, tratá el resultado como ambiguous, sin exponer refund_amount ni new_balance.


Flujo completo de verificación: pseudocódigo

El flujo estándar para usar la API en una verificación es:

1. Verificar saldo disponible
2. Crear orden (POST /v1/orders/create) con un producto del catálogo
3. Si phone_number no es un texto no vacío, hacer una única lectura acotada de GET /v1/orders/{id} con la misma id; si sigue ausente, quedar en pending_assignment
4. Usar el número asignado en la plataforma a verificar
5. Inicializar last_seen_revision = -1 y hacer polling con intervalo y timeout acotados por la aplicación:
   GET /v1/orders/{order_id}
   Si sms_revision es entero, mayor que last_seen_revision y otp_message no está vacío → actualizar la revisión, procesar el mensaje → EXIT
   Después, si status es COMPLETED, EXPIRED o CANCELED → EXIT
6. Si el código no llega en el tiempo límite:
   Leer el snapshot más reciente
   Si can_cancel == true: POST /v1/orders/cancel con id
   Antes de otra compra, reconciliar por completo la orden anterior

Ejemplos de código

Python

import json
import requests
import time
import uuid

API_TOKEN = "TU_TOKEN_AQUI"
BASE_URL = "https://api.smscode.gg/v1"
ORDER_TIMEOUT = (5, 30)
HEADERS = {
    "Authorization": f"Bearer {API_TOKEN}",
    "Content-Type": "application/json",
}

def get_balance():
    r = requests.get(f"{BASE_URL}/balance", headers=HEADERS)
    data = r.json()
    return data["data"]["balance"]

def order_number(catalog_product_id: int) -> dict:
    body = {"catalog_product_id": catalog_product_id, "quantity": 1}
    idempotency_key = str(uuid.uuid4())
    r = requests.post(
        f"{BASE_URL}/orders/create",
        json=body,
        headers={**HEADERS, "Idempotency-Key": idempotency_key},
        timeout=ORDER_TIMEOUT,
    )
    order = r.json()["data"]["orders"][0]

    # `phone_number` es opcional/anulable hasta que se asigne. El create resuelto
    # sigue resuelto: una sola lectura acotada con el mismo id, nunca otro create pago.
    return order

def is_assigned(phone) -> bool:
    return isinstance(phone, str) and bool(phone.strip())

def wait_for_code(order_id: int, timeout: int = 90) -> str | None:
    start = time.time()
    last_seen_revision = -1
    while time.time() - start < timeout:
        r = requests.get(
            f"{BASE_URL}/orders/{order_id}",
            headers=HEADERS,
            timeout=ORDER_TIMEOUT,
        )
        data = r.json()["data"]
        revision = data.get("sms_revision")
        message = data.get("otp_message")
        if (
            type(revision) is int
            and revision > last_seen_revision
            and isinstance(message, str)
            and message.strip()
        ):
            last_seen_revision = revision
            print(f"SMS revisión {revision}")
            return message
        if data["status"] in ("COMPLETED", "EXPIRED", "CANCELED"):
            return None
        time.sleep(3)
    return None

CANCEL_ERRORS_BY_STATUS = {
    401: {"UNAUTHORIZED"},
    404: {"NOT_FOUND"},
    409: {"CONFLICT", "CANCEL_TOO_EARLY"},
    422: {"PROVIDER_ERROR"},
    429: {"RATE_LIMIT_EXCEEDED"},
    500: {"INTERNAL_ERROR"},
    503: {"SERVICE_UNAVAILABLE"},
}
ORDER_STATES = {"ACTIVE", "OTP_RECEIVED", "COMPLETED", "EXPIRED", "CANCELED"}
INT32_MIN = -(2 ** 31)
INT32_MAX = (2 ** 31) - 1

def parse_json(response) -> dict | None:
    try:
        payload = response.json()
    except ValueError:
        return None
    return payload if isinstance(payload, dict) else None

def is_matching_int32(value, requested_order_id: int) -> bool:
    return bool(
        type(value) is int
        and INT32_MIN <= value <= INT32_MAX
        and type(requested_order_id) is int
        and INT32_MIN <= requested_order_id <= INT32_MAX
        and value == requested_order_id
    )

def valid_snapshot(payload: dict | None, requested_order_id: int) -> bool:
    return bool(
        payload
        and payload.get("success") is True
        and isinstance(payload.get("data"), dict)
        and "id" in payload["data"]
        and is_matching_int32(payload["data"]["id"], requested_order_id)
        and payload["data"].get("status") in ORDER_STATES
        and type(payload["data"].get("can_cancel")) is bool
        and "refund_amount" not in payload["data"]
        and "new_balance" not in payload["data"]
    )

def valid_cancel_receipt(
    response,
    payload: dict | None,
    requested_order_id: int,
) -> bool:
    data = (payload or {}).get("data")
    return bool(
        response.status_code == 200
        and payload
        and payload.get("success") is True
        and isinstance(data, dict)
        and data.get("status") == "CANCELED"
        and "order_id" in data
        and is_matching_int32(data["order_id"], requested_order_id)
        and type(data.get("refund_amount")) is int
        and data["refund_amount"] >= 0
        and type(data.get("new_balance")) is int
    )

def cancel_receipt_has_invalid_identity(
    response,
    payload: dict | None,
    requested_order_id: int,
) -> bool:
    data = (payload or {}).get("data")
    return bool(
        response.status_code == 200
        and payload
        and payload.get("success") is True
        and isinstance(data, dict)
        and data.get("status") == "CANCELED"
        and (
            "order_id" not in data
            or not is_matching_int32(data["order_id"], requested_order_id)
        )
    )

def reconcile_cancel(order_id: int) -> dict:
    try:
        latest_response = requests.get(
            f"{BASE_URL}/orders/{order_id}",
            headers=HEADERS,
            timeout=ORDER_TIMEOUT,
        )
        latest_payload = parse_json(latest_response)
    except requests.RequestException:
        return {"kind": "ambiguous", "latest": None}

    if not latest_response.ok or not valid_snapshot(latest_payload, order_id):
        return {"kind": "ambiguous", "latest": None}
    latest = latest_payload["data"]
    if latest["status"] == "CANCELED":
        snapshot = {
            key: value
            for key, value in latest.items()
            if key not in ("refund_amount", "new_balance")
        }
        return {"kind": "confirmed_canceled", "snapshot": snapshot}
    return {"kind": "ambiguous", "latest": latest}

def documented_cancel_rejection(response, payload: dict | None) -> bool:
    error = (payload or {}).get("error")
    code = error.get("code") if isinstance(error, dict) else None
    return bool(
        response.status_code != 200
        and payload
        and payload.get("success") is False
        and code in CANCEL_ERRORS_BY_STATUS.get(response.status_code, set())
    )

def cancel_order(order_id: int) -> dict:
    try:
        current_response = requests.get(
            f"{BASE_URL}/orders/{order_id}",
            headers=HEADERS,
            timeout=ORDER_TIMEOUT,
        )
        current_payload = parse_json(current_response)
    except requests.RequestException:
        return {"kind": "ambiguous", "latest": None}

    if not current_response.ok or not valid_snapshot(current_payload, order_id):
        return {"kind": "ambiguous", "latest": None}
    current = current_payload["data"]
    if current["can_cancel"] is not True:
        return {"kind": "skipped", "snapshot": current}

    try:
        response = requests.post(
            f"{BASE_URL}/orders/cancel",
            headers=HEADERS,
            json={"id": order_id},
            timeout=ORDER_TIMEOUT,
        )
        payload = parse_json(response)
    except requests.RequestException:
        return reconcile_cancel(order_id)

    if valid_cancel_receipt(response, payload, order_id):
        return {
            "kind": "receipt",
            "order_id": payload["data"]["order_id"],
            "refund_amount": payload["data"]["refund_amount"],
            "new_balance": payload["data"]["new_balance"],
        }
    if cancel_receipt_has_invalid_identity(response, payload, order_id):
        return {"kind": "ambiguous", "latest": None}
    if documented_cancel_rejection(response, payload):
        return {"kind": "rejected", "error": payload["error"]}
    return reconcile_cancel(order_id)

# Uso:
balance = get_balance()
print(f"Saldo: Rp {balance:,}")

order = order_number(88)
print(f"Orden: {order['id']}")

if not is_assigned(order.get("phone_number")):
    # pending_assignment: la orden sigue resuelta y cobrada; no la uses todavía
    # ni crees otra. El poll por id observará la asignación.
    print(f"pending_assignment: la orden {order['id']} aún no tiene número")
    raise SystemExit(0)

print(f"Número: {order['phone_number']}")

# Recién acá usarías order['phone_number'] en la plataforma...

code = wait_for_code(order["id"])
if code:
    print(f"Código recibido: {code}")
else:
    cancellation = cancel_order(order["id"])
    if cancellation["kind"] == "receipt":
        print(
            "Orden cancelada; "
            f"importe devuelto Rp {cancellation['refund_amount']:,}; "
            f"nuevo saldo Rp {cancellation['new_balance']:,}"
        )
    elif cancellation["kind"] == "confirmed_canceled":
        print("El snapshot confirmó el estado CANCELED; no es un recibo de esta llamada")
    elif cancellation["kind"] == "skipped":
        print("La orden no permitía cancelar; no se envió ningún POST")
    elif cancellation["kind"] == "rejected":
        print(f"Cancelación rechazada: {cancellation['error']['code']}")
    else:
        print("Cancelación sin resolver; conservá la orden para reconciliarla")

Node.js (con fetch nativo)

const { Agent, fetch } = require("undici");

const API_TOKEN = "TU_TOKEN_AQUI";
const BASE_URL = "https://api.smscode.gg/v1";
const ORDER_CONNECT_TIMEOUT_MS = 5_000;
const ORDER_TOTAL_TIMEOUT_MS = 30_000;
const orderDispatcher = new Agent({ connectTimeout: ORDER_CONNECT_TIMEOUT_MS });
const orderTransport = () => ({
  dispatcher: orderDispatcher,
  signal: AbortSignal.timeout(ORDER_TOTAL_TIMEOUT_MS)
});
const headers = {
  "Authorization": `Bearer ${API_TOKEN}`,
  "Content-Type": "application/json"
};

async function getBalance() {
  const res = await fetch(`${BASE_URL}/balance`, { headers });
  const data = await res.json();
  return data.data.balance;
}

async function orderNumber(catalogProductId) {
  const body = { catalog_product_id: catalogProductId, quantity: 1 };
  const idempotencyKey = crypto.randomUUID();
  const res = await fetch(`${BASE_URL}/orders/create`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": idempotencyKey },
    body: JSON.stringify(body),
    ...orderTransport()
  });
  const data = await res.json();
  const order = data.data.orders[0];

  // `phone_number` es opcional/anulable hasta que se asigne. El create resuelto
  // sigue resuelto: una sola lectura acotada con el mismo id, nunca otro create pago.
  return order;
}

function isAssigned(phone) {
  return typeof phone === "string" && phone.trim() !== "";
}

async function waitForCode(orderId, timeoutMs = 90000) {
  const start = Date.now();
  let lastSeenRevision = -1;
  while (Date.now() - start < timeoutMs) {
    const res = await fetch(`${BASE_URL}/orders/${orderId}`, {
      headers,
      ...orderTransport()
    });
    const data = (await res.json()).data;
    const revision = data.sms_revision;
    const message = data.otp_message;
    if (
      Number.isInteger(revision) &&
      revision > lastSeenRevision &&
      typeof message === "string" &&
      message.trim()
    ) {
      lastSeenRevision = revision;
      console.log(`SMS revisión ${revision}`);
      return message;
    }
    if (["COMPLETED", "EXPIRED", "CANCELED"].includes(data.status)) return null;
    await new Promise(r => setTimeout(r, 3000));
  }
  return null;
}

const CANCEL_ERRORS_BY_STATUS = new Map([
  [401, new Set(["UNAUTHORIZED"])],
  [404, new Set(["NOT_FOUND"])],
  [409, new Set(["CONFLICT", "CANCEL_TOO_EARLY"])],
  [422, new Set(["PROVIDER_ERROR"])],
  [429, new Set(["RATE_LIMIT_EXCEEDED"])],
  [500, new Set(["INTERNAL_ERROR"])],
  [503, new Set(["SERVICE_UNAVAILABLE"])],
]);
const ORDER_STATES = new Set(["ACTIVE", "OTP_RECEIVED", "COMPLETED", "EXPIRED", "CANCELED"]);
const INT32_MIN = -(2 ** 31);
const INT32_MAX = (2 ** 31) - 1;

async function parseJson(response) {
  try {
    const payload = await response.json();
    return payload && typeof payload === "object" ? payload : null;
  } catch (_) {
    return null;
  }
}

function isMatchingInt32(value, requestedOrderId) {
  return Number.isInteger(value)
    && value >= INT32_MIN
    && value <= INT32_MAX
    && Number.isInteger(requestedOrderId)
    && requestedOrderId >= INT32_MIN
    && requestedOrderId <= INT32_MAX
    && value === requestedOrderId;
}

function validSnapshot(payload, requestedOrderId) {
  return payload?.success === true
    && payload.data
    && Object.hasOwn(payload.data, "id")
    && isMatchingInt32(payload.data.id, requestedOrderId)
    && ORDER_STATES.has(payload.data.status)
    && typeof payload.data.can_cancel === "boolean"
    && !("refund_amount" in payload.data)
    && !("new_balance" in payload.data);
}

function validCancelReceipt(response, payload, requestedOrderId) {
  const data = payload?.data;
  return response.status === 200
    && payload?.success === true
    && data?.status === "CANCELED"
    && Object.hasOwn(data, "order_id")
    && isMatchingInt32(data.order_id, requestedOrderId)
    && Number.isInteger(data.refund_amount)
    && data.refund_amount >= 0
    && Number.isInteger(data.new_balance);
}

function cancelReceiptHasInvalidIdentity(response, payload, requestedOrderId) {
  const data = payload?.data;
  return response.status === 200
    && payload?.success === true
    && data?.status === "CANCELED"
    && (
      !Object.hasOwn(data, "order_id")
      || !isMatchingInt32(data.order_id, requestedOrderId)
    );
}

async function reconcileCancel(orderId) {
  try {
    const latestResponse = await fetch(`${BASE_URL}/orders/${orderId}`, {
      headers,
      ...orderTransport()
    });
    const latestPayload = await parseJson(latestResponse);
    if (!latestResponse.ok || !validSnapshot(latestPayload, orderId)) {
      return { kind: "ambiguous", latest: null };
    }
    if (latestPayload.data.status === "CANCELED") {
      const { refund_amount, new_balance, ...snapshot } = latestPayload.data;
      return { kind: "confirmed_canceled", snapshot };
    }
    return { kind: "ambiguous", latest: latestPayload.data };
  } catch (_) {
    return { kind: "ambiguous", latest: null };
  }
}

function documentedCancelRejection(response, payload) {
  const allowed = CANCEL_ERRORS_BY_STATUS.get(response.status);
  return response.status !== 200
    && payload?.success === false
    && typeof payload.error?.code === "string"
    && allowed?.has(payload.error.code) === true;
}

async function cancelOrder(orderId) {
  let currentResponse;
  let currentPayload;
  try {
    currentResponse = await fetch(`${BASE_URL}/orders/${orderId}`, {
      headers,
      ...orderTransport()
    });
    currentPayload = await parseJson(currentResponse);
  } catch (_) {
    return { kind: "ambiguous", latest: null };
  }

  if (!currentResponse.ok || !validSnapshot(currentPayload, orderId)) {
    return { kind: "ambiguous", latest: null };
  }
  if (currentPayload.data.can_cancel !== true) {
    return { kind: "skipped", snapshot: currentPayload.data };
  }

  let cancelResponse;
  let cancelPayload;
  try {
    cancelResponse = await fetch(`${BASE_URL}/orders/cancel`, {
      method: "POST",
      headers,
      body: JSON.stringify({ id: orderId }),
      ...orderTransport()
    });
    cancelPayload = await parseJson(cancelResponse);
  } catch (_) {
    return reconcileCancel(orderId);
  }

  if (validCancelReceipt(cancelResponse, cancelPayload, orderId)) {
    return {
      kind: "receipt",
      order_id: cancelPayload.data.order_id,
      refund_amount: cancelPayload.data.refund_amount,
      new_balance: cancelPayload.data.new_balance
    };
  }
  if (cancelReceiptHasInvalidIdentity(cancelResponse, cancelPayload, orderId)) {
    return { kind: "ambiguous", latest: null };
  }
  if (documentedCancelRejection(cancelResponse, cancelPayload)) {
    return { kind: "rejected", error: cancelPayload.error };
  }
  return reconcileCancel(orderId);
}

// Uso:
(async () => {
  const balance = await getBalance();
  console.log(`Saldo: Rp ${balance}`);

  const order = await orderNumber(88);
  if (!isAssigned(order.phone_number)) {
    // pending_assignment: el pedido sigue resuelto y cobrado; no lo uses en
    // ninguna plataforma todavía ni crees otro. El poll por id verá la asignación.
    console.log(`pending_assignment: el pedido ${order.id} aún no tiene número`);
    return;
  }
  console.log(`Número: ${order.phone_number}`);

  // Recién acá usarías order.phone_number en la plataforma...

  const code = await waitForCode(order.id);
  if (code) {
    console.log(`Código recibido: ${code}`);
  } else {
    const cancellation = await cancelOrder(order.id);
    if (cancellation.kind === "receipt") {
      console.log(
        `Orden cancelada; importe devuelto Rp ${cancellation.refund_amount}; ` +
        `nuevo saldo Rp ${cancellation.new_balance}`
      );
    } else if (cancellation.kind === "confirmed_canceled") {
      console.log("El snapshot confirmó el estado CANCELED; no es un recibo de esta llamada");
    } else if (cancellation.kind === "skipped") {
      console.log("La orden no permitía cancelar; no se envió ningún POST");
    } else if (cancellation.kind === "rejected") {
      console.log(`Cancelación rechazada: ${cancellation.error.code}`);
    } else {
      console.log("Cancelación sin resolver; conservá la orden para reconciliarla");
    }
  }
})();

PHP

<?php
$API_TOKEN = "TU_TOKEN_AQUI";
$BASE_URL = "https://api.smscode.gg/v1";
$CANCEL_ERRORS_BY_STATUS = [
    401 => ["UNAUTHORIZED"],
    404 => ["NOT_FOUND"],
    409 => ["CONFLICT", "CANCEL_TOO_EARLY"],
    422 => ["PROVIDER_ERROR"],
    429 => ["RATE_LIMIT_EXCEEDED"],
    500 => ["INTERNAL_ERROR"],
    503 => ["SERVICE_UNAVAILABLE"],
];
$ORDER_STATES = ["ACTIVE", "OTP_RECEIVED", "COMPLETED", "EXPIRED", "CANCELED"];

function smscode_transport($method, $endpoint, $data = null, $extraHeaders = []) {
    global $API_TOKEN, $BASE_URL;
    $ch = curl_init("$BASE_URL$endpoint");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => array_merge([
            "Authorization: Bearer $API_TOKEN",
            "Content-Type: application/json"
        ], $extraHeaders),
    ]);
    if ($method === "POST" && $data) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    }
    $response = curl_exec($ch);
    $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($response === false) {
        throw new RuntimeException("Error de transporte");
    }
    return [
        "http_status" => $httpStatus,
        "payload" => json_decode($response, true),
    ];
}

function smscode_request($method, $endpoint, $data = null, $extraHeaders = []) {
    $transport = smscode_transport($method, $endpoint, $data, $extraHeaders);
    if (!is_array($transport["payload"])) {
        throw new UnexpectedValueException("Respuesta JSON inválida");
    }
    return $transport["payload"];
}

function is_matching_int32($value, $requestedOrderId) {
    return is_int($value)
        && $value >= -(2 ** 31)
        && $value <= (2 ** 31) - 1
        && is_int($requestedOrderId)
        && $requestedOrderId >= -(2 ** 31)
        && $requestedOrderId <= (2 ** 31) - 1
        && $value === $requestedOrderId;
}

function valid_order_snapshot($payload, $requestedOrderId) {
    global $ORDER_STATES;
    return is_array($payload)
        && ($payload["success"] ?? null) === true
        && is_array($payload["data"] ?? null)
        && array_key_exists("id", $payload["data"])
        && is_matching_int32($payload["data"]["id"], $requestedOrderId)
        && in_array($payload["data"]["status"] ?? null, $ORDER_STATES, true)
        && is_bool($payload["data"]["can_cancel"] ?? null)
        && !array_key_exists("refund_amount", $payload["data"])
        && !array_key_exists("new_balance", $payload["data"]);
}

function valid_cancel_receipt($httpStatus, $payload, $requestedOrderId) {
    $data = is_array($payload) && is_array($payload["data"] ?? null)
        ? $payload["data"]
        : null;
    return $httpStatus === 200
        && is_array($payload)
        && ($payload["success"] ?? null) === true
        && ($data["status"] ?? null) === "CANCELED"
        && array_key_exists("order_id", $data)
        && is_matching_int32($data["order_id"], $requestedOrderId)
        && is_int($data["refund_amount"] ?? null)
        && $data["refund_amount"] >= 0
        && is_int($data["new_balance"] ?? null);
}

function cancel_receipt_has_invalid_identity($httpStatus, $payload, $requestedOrderId) {
    $data = is_array($payload) && is_array($payload["data"] ?? null)
        ? $payload["data"]
        : null;
    return $httpStatus === 200
        && is_array($payload)
        && ($payload["success"] ?? null) === true
        && ($data["status"] ?? null) === "CANCELED"
        && (
            !array_key_exists("order_id", $data)
            || !is_matching_int32($data["order_id"], $requestedOrderId)
        );
}

function reconcile_cancel($orderId) {
    try {
        $latestTransport = smscode_transport("GET", "/orders/$orderId");
    } catch (Throwable $error) {
        return ["kind" => "ambiguous", "latest" => null];
    }
    $latestPayload = $latestTransport["payload"];
    if (
        $latestTransport["http_status"] !== 200
        || !valid_order_snapshot($latestPayload, $orderId)
    ) {
        return ["kind" => "ambiguous", "latest" => null];
    }
    $latest = $latestPayload["data"];
    if ($latest["status"] === "CANCELED") {
        unset($latest["refund_amount"], $latest["new_balance"]);
        return ["kind" => "confirmed_canceled", "snapshot" => $latest];
    }
    return ["kind" => "ambiguous", "latest" => $latest];
}

function documented_cancel_rejection($httpStatus, $payload) {
    global $CANCEL_ERRORS_BY_STATUS;
    $code = is_array($payload) && is_array($payload["error"] ?? null)
        ? ($payload["error"]["code"] ?? null)
        : null;
    return $httpStatus !== 200
        && is_array($payload)
        && ($payload["success"] ?? null) === false
        && in_array($code, $CANCEL_ERRORS_BY_STATUS[$httpStatus] ?? [], true);
}

function cancel_order($orderId) {
    try {
        $currentTransport = smscode_transport("GET", "/orders/$orderId");
    } catch (Throwable $error) {
        return ["kind" => "ambiguous", "latest" => null];
    }
    $currentPayload = $currentTransport["payload"];
    if (
        $currentTransport["http_status"] !== 200
        || !valid_order_snapshot($currentPayload, $orderId)
    ) {
        return ["kind" => "ambiguous", "latest" => null];
    }
    if ($currentPayload["data"]["can_cancel"] !== true) {
        return ["kind" => "skipped", "snapshot" => $currentPayload["data"]];
    }

    try {
        $cancelTransport = smscode_transport(
            "POST",
            "/orders/cancel",
            ["id" => $orderId]
        );
    } catch (Throwable $error) {
        return reconcile_cancel($orderId);
    }
    $cancelPayload = $cancelTransport["payload"];
    if (valid_cancel_receipt($cancelTransport["http_status"], $cancelPayload, $orderId)) {
        return [
            "kind" => "receipt",
            "order_id" => $cancelPayload["data"]["order_id"],
            "refund_amount" => $cancelPayload["data"]["refund_amount"],
            "new_balance" => $cancelPayload["data"]["new_balance"],
        ];
    }
    if (
        cancel_receipt_has_invalid_identity(
            $cancelTransport["http_status"],
            $cancelPayload,
            $orderId
        )
    ) {
        return ["kind" => "ambiguous", "latest" => null];
    }
    if (documented_cancel_rejection($cancelTransport["http_status"], $cancelPayload)) {
        return ["kind" => "rejected", "error" => $cancelPayload["error"]];
    }
    return reconcile_cancel($orderId);
}

// Obtener un número usando un producto elegido previamente en el catálogo
$body = ["catalog_product_id" => 88, "quantity" => 1];
$idempotencyKey = bin2hex(random_bytes(16));
$order = smscode_request(
    "POST",
    "/orders/create",
    $body,
    ["Idempotency-Key: $idempotencyKey"]
);
$orderId = $order["data"]["orders"][0]["id"];

// `phone_number` es opcional/anulable hasta que se asigne. El create resuelto sigue
// resuelto: una sola lectura acotada con el mismo id, nunca otro create pago.
$phone = $order["data"]["orders"][0]["phone_number"] ?? null;
if (!(is_string($phone) && trim($phone) !== "")) {
    // pending_assignment: el pedido sigue resuelto y cobrado; no lo uses ni crees otro.
    echo "pending_assignment: el pedido $orderId todavía no tiene número\n";
    exit(0);
}
echo "Número: $phone\n";

// Polling para el código
$deliveryMessage = null;
$lastSeenRevision = -1;
$start = time();
while (time() - $start < 90) {
    $status = smscode_request("GET", "/orders/$orderId");
    $revision = $status["data"]["sms_revision"] ?? null;
    $message = $status["data"]["otp_message"] ?? null;
    if (
        is_int($revision)
        && $revision > $lastSeenRevision
        && is_string($message)
        && trim($message) !== ""
    ) {
        $lastSeenRevision = $revision;
        $deliveryMessage = $message;
        break;
    }
    if (in_array($status["data"]["status"], ["COMPLETED", "EXPIRED", "CANCELED"])) break;
    sleep(3);
}

if ($deliveryMessage !== null) {
    echo "SMS: $deliveryMessage\n";
} else {
    $cancellation = cancel_order($orderId);
    if ($cancellation["kind"] === "receipt") {
        echo "Cancelación confirmada; reembolso Rp {$cancellation['refund_amount']}; "
            . "nuevo saldo Rp {$cancellation['new_balance']}\n";
    } elseif ($cancellation["kind"] === "confirmed_canceled") {
        echo "El snapshot confirmó el estado CANCELED; no es un recibo de esta llamada\n";
    } elseif ($cancellation["kind"] === "skipped") {
        echo "La orden no permitía cancelar; no se envió ningún POST\n";
    } elseif ($cancellation["kind"] === "rejected") {
        echo "Cancelación rechazada: {$cancellation['error']['code']}\n";
    } else {
        echo "Cancelación sin resolver; conservá la orden para reconciliarla.\n";
    }
}
?>

Códigos de servicios y países más comunes

Servicios populares

Servicio Código
WhatsApp wa
Telegram tg
Google / Gmail go
Instagram ig
Facebook fb
TikTok tt
Discord ds
Twitter / X tw
Snapchat sc
Microsoft / Outlook ms

Países populares (código numérico)

País Código
Indonesia 6
Rusia 7
EE.UU. 1
India 22
Brasil 73
México 52
España 34
Argentina 54
Colombia 57
China 86

Para la lista completa de códigos de servicio y país, consultá la documentación de la API.


Rate limits y buenas prácticas

Usá un intervalo de polling y un timeout total acotados por tu aplicación. Si recibís 429, respetá un Retry-After positivo; si falta o es inválido, aplicá una espera de respaldo limitada. Distribuí las consultas cuando proceses varias órdenes en paralelo.

Al vencer el timeout local, leé el snapshot más reciente. Cancelá únicamente cuando can_cancel sea true y tomá la respuesta del servidor como autoridad sobre el estado y cualquier efecto en el saldo.

No reenvíes un POST pago de forma genérica después de un error de red, un 5xx, JSON malformado o una respuesta ambigua. Guardá endpoint, body exacto, Idempotency-Key y cantidad acumulada de intentos para reconciliar. Solamente REQUEST_IN_PROGRESS permite un retry acotado con la misma key y el mismo body.


Migración desde SMS-Activate

La API pública de SMSCode no es un reemplazo directo del protocolo de SMS-Activate. Una integración existente debe adaptar, como mínimo:

  1. La autenticación por query string a Authorization: Bearer ....
  2. Las acciones de un único endpoint a los endpoints REST de /v1.
  3. Las respuestas de texto a los envelopes JSON de SMSCode.
  4. Los identificadores de producto y orden, y los estados del ciclo de vida.

El flujo conceptual sigue siendo parecido — elegir un producto, crear una orden y esperar el SMS — pero no cambies solamente la URL base ni reutilices los códigos de servicio, país o estado sin mapearlos al contrato publicado por SMSCode.


Casos de uso avanzados

Bot de Telegram para automatizar verificaciones

Un bot de Telegram puede ser una interfaz cómoda para acceder a SMSCode sin necesitar el panel web. El bot solicita números y muestra los códigos directamente en el chat, facilitando el uso desde el móvil.

Pipeline de QA para testing de registro

Si desarrollás una aplicación con registro por SMS, podés usar la API de SMSCode en tus tests automatizados para probar el flujo completo sin usar números reales.

Ejemplo de integración con pytest (Python):

import pytest
from smscode_client import SmsCodeClient  # tu wrapper de la API

@pytest.fixture
def verified_phone():
    client = SmsCodeClient(api_token="TEST_TOKEN")
    order = client.order_number("tu_app", 6)
    # Aquí iniciás el registro en tu app con order.phone
    code = client.wait_for_code(order.order_id)
    yield order.phone, code
    # Cleanup si es necesario

Automatización de onboarding masivo

Para plataformas SaaS que necesitan probar la experiencia de registro de usuarios en escala, la API permite crear decenas de cuentas de prueba verificadas sin intervención manual.


FAQ

¿La API de SMSCode tiene una versión gratuita o de prueba?

No hay una versión de prueba separada: la API usa el saldo real de tu cuenta SMSCode. Cargá un importe pequeño mediante un método disponible en tu página de depósito y probá con un producto de bajo precio; cada creación paga debe reconciliarse antes de repetirla.

¿Qué formato retorna la API si hay un error?

La API retorna errores en formato JSON consistente:

{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Saldo insuficiente para completar la orden"
  }
}

Tomá error.code como identificador estable y consultá el contrato OpenAPI para su status HTTP. Por ejemplo, UNAUTHORIZED usa 401, VALIDATION_ERROR usa 422 y RATE_LIMIT_EXCEEDED usa 429 con Retry-After.

¿Puedo usar la API con un lenguaje que no está en los ejemplos?

Sí. La API es REST estándar con JSON — funciona con cualquier lenguaje que pueda hacer peticiones HTTP. Bash (con curl), Go, Ruby, Java, C#, Rust — todos funcionan. La lógica es la misma que en los ejemplos.

¿Hay webhooks disponibles para no tener que hacer polling?

Los webhooks se configuran con webhook_url y webhook_secret. Verificá la firma sobre el body raw, procesá el evento de forma idempotente y persistilo o encolalo antes de responder 2xx; mantené el polling como reconciliación.

¿Puedo usar la API en un entorno serverless (Lambda, Cloud Functions)?

Sí. La API es stateless — cada llamada es independiente. Funciona perfectamente en entornos serverless, siempre que el token esté almacenado de forma segura (variables de entorno, AWS Secrets Manager, etc.) y no hardcodeado en el código.

¿Listo para probar SMSCode?

Crea una cuenta y obtén tu primer número virtual en menos de dos minutos.

Comenzar →