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:
- Consultar disponibilidad: Ver qué países y plataformas tienen números disponibles en tiempo real, con precios.
- Obtener un número: Reservar un número virtual para una plataforma y país específicos.
- Recibir el código: Hacer polling al endpoint para verificar si llegó el SMS, o usar webhooks para notificación push.
- Confirmar o cancelar: Marcar la orden como completada o, cuando
can_cancelseatrue, cancelarla y leer del servidor el importe devuelto y el nuevo saldo. - 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
- Iniciá sesión en SMSCode.
- Andá a la configuración de tu cuenta (generalmente en el menú de usuario o en “Configuración” / “API”).
- 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 |
|---|---|
wa |
|
| Telegram | tg |
| Google / Gmail | go |
ig |
|
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:
- La autenticación por query string a
Authorization: Bearer .... - Las acciones de un único endpoint a los endpoints REST de
/v1. - Las respuestas de texto a los envelopes JSON de SMSCode.
- 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.