Se você precisa automatizar verificações por SMS, integrar números virtuais no seu pipeline de testes ou construir um produto em cima de infraestrutura de SMS, a API do SMSCode foi projetada exatamente para isso. Neste guia, vamos do básico à integração completa — com exemplos reais de código em múltiplas linguagens, boas práticas de produção e tratamento de erros.
TL;DR: A API REST do SMSCode usa autenticação por Bearer Token, retorna JSON e segue um fluxo simples: obter número → aguardar SMS → ler código. Acesse a documentação completa ou crie sua conta para gerar seu token de API.
Pré-requisitos
Antes de começar, você vai precisar de:
- Uma conta ativa no SMSCode
- Saldo de crédito disponível (veja planos e preços)
- Seu token de API — disponível em Configurações → API na conta
O token tem 64 caracteres hexadecimais e se parece com: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4...
Segurança do token: Trate o token exatamente como uma senha. Não comite em repositórios públicos, não exponha no frontend, não envie por e-mail ou chat. Se um token for comprometido, revogue-o imediatamente em Configurações → API e gere um novo.
Autenticação
Todas as requisições autenticadas usam o header HTTP Authorization:
Authorization: Bearer SEU_TOKEN_AQUI
Respostas de erro de autenticação:
401 Unauthorized— token ausente ou inválido403 Forbidden— token válido mas sem permissão para a operação
Formato de Resposta
A API retorna sempre JSON com estrutura consistente:
Sucesso:
{
"success": true,
"data": {}
}
Erro:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Descrição humana do erro"
}
}
Visão Geral do Fluxo
O ciclo de vida de uma verificação via API tem quatro etapas:
1. GET /v1/catalog/products → Listar serviços disponíveis e preços
2. POST /v1/orders/create → Criar pedido (obtém ID; `phone_number` pode ser nulo)
3. GET /v1/orders/{id} → Consultar status e ler SMS recebido (polling)
4. POST /v1/orders/cancel → Cancelar somente quando `can_cancel` permitir
Vamos ver cada etapa em detalhe, com exemplos práticos.
1. Consultando o Catálogo
Antes de criar um pedido, consulte os serviços e países disponíveis:
# Listar produtos de uma plataforma e um país
curl -X GET "https://api.smscode.gg/v1/catalog/products?country_id=7&platform_id=1" \
-H "Authorization: Bearer SEU_TOKEN"
Resposta:
{
"success": true,
"data": [
{
"id": 1024,
"name": "WhatsApp - Indonesia",
"catalog_product_id": 88,
"country_id": 7,
"platform_id": 1,
"available": 142,
"price": 15000,
"active": true
}
],
"meta": { "page": 1, "limit": 1000, "count": 1 }
}
Campos importantes:
price: Preço inteiro em IDRavailable: Quantidade aproximada disponívelactive: Indica se o produto está ativocatalog_product_id: Identificador usado no body de criaçãocountry_ideplatform_id: Filtros estáveis do catálogo
Dica: Consulte o catálogo com cache de alguns minutos — ele muda gradualmente, não a cada segundo. Verificar disponibilidade antes de cada pedido é boa prática para evitar erro NO_OFFER_AVAILABLE.
2. Criando um Pedido (Solicitando Número Virtual)
curl --connect-timeout 5 --max-time 30 -X POST "https://api.smscode.gg/v1/orders/create" \
-H "Authorization: Bearer SEU_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-example-001" \
-d '{
"catalog_product_id": 88,
"quantity": 1
}'
Resposta bem-sucedida:
{
"success": true,
"data": {
"orders": [{
"id": 90210,
"status": "ACTIVE",
"phone_number": "+5511987654321",
"otp_code": null,
"otp_received_at": null,
"expires_at": "2026-03-16T12:15: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-16T12:02:00Z",
"replace_available_at": "2026-03-16T12:02:00Z"
}],
"failed_count": 0
}
}
Campos importantes:
data.orders[0].id: Identificador único do pedido — guarde para consultas e cancelamentophone_number: O número virtual quando já atribuído; o campo é opcional/anulável até a atribuição terminarstatus: Estado atual (ACTIVE,OTP_RECEIVED,COMPLETED,EXPIRED,CANCELED)expires_at: Quando o pedido expira se nenhum SMS for recebido
Depois de uma criação resolvida, insira phone_number na plataforma alvo somente quando ele for uma string não vazia. O fluxo durável completo faz no máximo uma leitura limitada do mesmo order_id; se o número continuar ausente, nulo ou em branco, mantém o resultado explícito pending_assignment. Se o POST tiver resultado ambíguo, reutilize somente o mesmo Idempotency-Key e exatamente o mesmo body; não troque produto, key ou body antes de reconciliar a tentativa.
3. Consultando o Status e Lendo o SMS (Polling)
Após inserir o número na plataforma e solicitar o código, inicie o polling para aguardar o SMS:
curl --connect-timeout 5 --max-time 30 -X GET "https://api.smscode.gg/v1/orders/90210" \
-H "Authorization: Bearer SEU_TOKEN"
Projeção de V1OrderSummary enquanto aguarda (campos selecionados, não a resposta wire completa):
{
"success": true,
"data": {
"id": 90210,
"status": "ACTIVE",
"otp_code": null,
"otp_message": null,
"sms_revision": 0,
"can_cancel": true
}
}
Projeção de V1OrderSummary após o SMS (campos selecionados, não a resposta wire completa):
{
"success": true,
"data": {
"id": 90210,
"status": "OTP_RECEIVED",
"otp_code": null,
"otp_message": "Seu código do Instagram é 847291. Não compartilhe com ninguém.",
"sms_revision": 1,
"can_cancel": false
}
}
Campos importantes:
otp_message: Texto autoritativo do SMS, mesmo quandootp_codefornullsms_revision: Versão monotônica da mensagem por pedidocan_cancel: Autoridade para decidir se o cliente pode solicitar cancelamento
Para cada pedido, inicie a revisão aceita em -1. Leia sms_revision e otp_message antes de avaliar status; aceite apenas uma revisão inteira (não booleana), estritamente maior que a anterior, com uma mensagem string não vazia. Atualize a revisão e entregue a mensagem antes dos estados terminais, sem depender de OTP_RECEIVED nem de otp_code. Revisões inválidas ou antigas e mensagens vazias não alteram a revisão aceita.
4. Cancelando um Pedido
Se decidir não usar mais o número, consulte o snapshot atual e prossiga somente quando can_cancel for true:
curl --connect-timeout 5 --max-time 30 -X POST "https://api.smscode.gg/v1/orders/cancel" \
-H "Authorization: Bearer SEU_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": 90210}'
A resposta do servidor e a política do pedido são a autoridade para qualquer efeito no saldo; o cliente não deve inferir reembolso apenas por enviar a requisição.
Consultando Saldo
curl -X GET "https://api.smscode.gg/v1/balance" \
-H "Authorization: Bearer SEU_TOKEN"
{
"success": true,
"data": {
"currency": "IDR",
"balance": 425000
}
}
Útil para monitorar saldo programaticamente e emitir alertas quando estiver baixo.
Exemplos Completos em Linguagens Populares
Python (com tratamento de erros completo)
import requests
import time
import os
import uuid
from typing import Optional
API_TOKEN = os.environ.get("SMSCODE_TOKEN")
BASE_URL = "https://api.smscode.gg/v1"
ORDER_TIMEOUT = (5, 30)
HEADERS = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json"
}
class SMSCodeClient:
def __init__(self, token: str):
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
self.base_url = BASE_URL
def get_number(self, catalog_product_id: int) -> dict:
"""Cria um pedido e retorna os dados do número virtual."""
body = {"catalog_product_id": catalog_product_id, "quantity": 1}
idempotency_key = str(uuid.uuid4())
response = requests.post(
f"{self.base_url}/orders/create",
headers={**self.headers, "Idempotency-Key": idempotency_key},
json=body,
timeout=ORDER_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if not data["success"]:
raise ValueError(f"Erro ao criar pedido: {data['error']['message']}")
return data["data"]["orders"][0]
def get_order(self, order_id: int) -> dict:
"""Retorna o snapshot atual do pedido."""
response = requests.get(
f"{self.base_url}/orders/{order_id}",
headers=self.headers,
timeout=ORDER_TIMEOUT,
)
response.raise_for_status()
return response.json()["data"]
def wait_for_sms(self, order_id: int, timeout: int = 300, interval: int = 5) -> Optional[str]:
"""Aguarda o SMS com polling. Retorna o código ou None em timeout."""
elapsed = 0
last_seen_revision = -1
while elapsed < timeout:
time.sleep(interval)
elapsed += interval
data = self.get_order(order_id)
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
return message
lifecycle_status = data["status"]
if lifecycle_status in ["COMPLETED", "EXPIRED", "CANCELED"]:
print(f"Pedido finalizado com status: {lifecycle_status}")
return None
# ACTIVE e OTP_RECEIVED continuam em polling sem nova mensagem válida.
print("Timeout: SMS não chegou")
return None
def cancel_order(self, order_id: int) -> bool:
"""Cancela somente quando o snapshot atual permite."""
current = requests.get(
f"{self.base_url}/orders/{order_id}",
headers=self.headers,
timeout=ORDER_TIMEOUT,
).json()["data"]
if not current["can_cancel"]:
return False
response = requests.post(
f"{self.base_url}/orders/cancel",
headers=self.headers,
json={"id": order_id},
timeout=ORDER_TIMEOUT,
)
return response.json().get("success", False)
def get_balance(self) -> int:
"""Retorna o saldo atual como inteiro em IDR."""
response = requests.get(f"{self.base_url}/balance", headers=self.headers)
return response.json()["data"]["balance"]
# Uso prático
def get_verification_code(catalog_product_id: int) -> Optional[tuple]:
"""Retorna (phone, code) ou None em caso de falha."""
client = SMSCodeClient(API_TOKEN)
# Verificar saldo antes
balance = client.get_balance()
print(f"Saldo atual: Rp {balance:,}")
try:
order = client.get_number(catalog_product_id)
order_id = order["id"]
# `phone_number` é opcional/anulável até a atribuição. O create resolvido
# continua resolvido: uma única leitura limitada com o mesmo id, nunca
# outro create pago.
phone = order.get("phone_number")
if not (isinstance(phone, str) and phone.strip()):
phone = client.get_order(order["id"]).get("phone_number")
if not (isinstance(phone, str) and phone.strip()):
# pending_assignment: pedido resolvido e cobrado; não use nem recrie.
print(f"pending_assignment: pedido {order['id']} ainda sem número")
return None
print(f"Número obtido: {phone}")
code = client.wait_for_sms(order_id)
if code:
print(f"Código recebido: {code}")
return (phone, code)
else:
# Cancelar se não recebeu
client.cancel_order(order_id)
return None
except requests.exceptions.RequestException as e:
print(f"Erro de rede: {e}")
return None
except ValueError as e:
print(f"Erro da API: {e}")
return None
# Exemplo de uso
result = get_verification_code(88)
if result:
phone, code = result
print(f"Use o número {phone} com o código {code}")
Node.js / TypeScript
import { Agent, fetch } from 'undici';
const API_TOKEN = process.env.SMSCODE_TOKEN!;
const BASE_URL = 'https://api.smscode.gg/v1';
const orderDispatcher = new Agent({ connectTimeout: 5_000 });
const orderTransport = () => ({
dispatcher: orderDispatcher,
signal: AbortSignal.timeout(30_000)
});
const CANCEL_ERROR_CODES_BY_STATUS = new Map<number, Set<string>>([
[401, new Set(['UNAUTHORIZED'])],
[404, new Set(['NOT_FOUND'])],
[409, new Set(['CANCEL_TOO_EARLY'])],
[422, new Set(['VALIDATION_ERROR'])],
[429, new Set(['RATE_LIMIT_EXCEEDED'])],
]);
type OrderState = 'ACTIVE' | 'OTP_RECEIVED' | 'COMPLETED' | 'EXPIRED' | 'CANCELED';
interface CreatedOrderView {
id: number;
phone_number: string | null;
status: OrderState;
expires_at: string | null;
}
interface OrderSnapshotView {
id: number;
status: OrderState;
otp_code: string | null;
otp_message: string | null;
sms_revision: number;
can_cancel: boolean;
}
type PollOutcome =
| { kind: 'delivery'; orderId: number; message: string }
| { kind: 'terminal'; orderId: number; status: OrderState }
| { kind: 'timed_out'; orderId: number; status: OrderState | null }
| { kind: 'needs_reconciliation'; orderId: number; status: OrderState | null };
type CancelOutcome =
| { kind: 'skipped'; orderId: number; status: OrderState }
| { kind: 'receipt'; receipt: CancelReceipt }
| { kind: 'rejected'; orderId: number; status: OrderState; httpStatus: number; errorCode: string }
| { kind: 'confirmed_canceled'; snapshot: { id: number; status: 'CANCELED' } }
| { kind: 'ambiguous'; orderId: number; latestStatus: OrderState | null };
interface CancelReceipt {
order_id: number;
status: 'CANCELED';
refund_amount: number;
new_balance: number;
}
const isOrderState = (value: unknown): value is OrderState =>
['ACTIVE', 'OTP_RECEIVED', 'COMPLETED', 'EXPIRED', 'CANCELED'].includes(
value as OrderState
);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const isInt32 = (value: unknown): value is number =>
Number.isInteger(value) &&
(value as number) >= -(2 ** 31) &&
(value as number) <= (2 ** 31) - 1;
const isSnapshot = (value: unknown, orderId: number): value is OrderSnapshotView => {
if (!isRecord(value)) return false;
return isInt32(value.id) &&
value.id === orderId &&
isOrderState(value.status) &&
Number.isSafeInteger(value.sms_revision) &&
(value.sms_revision as number) >= 0 &&
(value.otp_code === null || typeof value.otp_code === 'string') &&
(value.otp_message === null || typeof value.otp_message === 'string') &&
typeof value.can_cancel === 'boolean' &&
!('refund_amount' in value) &&
!('new_balance' in value);
};
const validCancelReceipt = (payload: unknown, orderId: number): CancelReceipt | null => {
if (!isRecord(payload) || payload.success !== true || !isRecord(payload.data)) return null;
if (Object.keys(payload).some(field => !['success', 'data', 'meta'].includes(field))) return null;
const receipt = payload.data;
if (Object.keys(receipt).sort().join(',') !== 'new_balance,order_id,refund_amount,status') {
return null;
}
if (!isInt32(receipt.order_id) || receipt.order_id !== orderId || receipt.status !== 'CANCELED') {
return null;
}
if (!Number.isSafeInteger(receipt.refund_amount) || (receipt.refund_amount as number) < 0 ||
!Number.isSafeInteger(receipt.new_balance)) return null;
return receipt as unknown as CancelReceipt;
};
const documentedCancelError = (
httpStatus: number,
payload: unknown
): { code: string; message: string } | null => {
if (!isRecord(payload) || Object.keys(payload).sort().join(',') !== 'error,success' ||
payload.success !== false || !isRecord(payload.error)) return null;
const error = payload.error;
const fields = Object.keys(error);
if (!fields.includes('code') || !fields.includes('message') ||
fields.some(field => !['code', 'message', 'details'].includes(field)) ||
typeof error.code !== 'string' || typeof error.message !== 'string' ||
('details' in error && !isRecord(error.details))) return null;
return CANCEL_ERROR_CODES_BY_STATUS.get(httpStatus)?.has(error.code)
? { code: error.code, message: error.message }
: null;
};
const lastSeenRevisionByOrder = new Map<number, number>();
const headers = {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
};
async function createOrder(catalogProductId: number): Promise<CreatedOrderView> {
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 envelope = await res.json() as any;
if (!envelope.success) throw new Error(envelope.error.message);
return envelope.data.orders[0];
}
async function pollForSms(
orderId: number,
timeoutMs = 300_000,
intervalMs = 5_000
): Promise<PollOutcome> {
if (
!Number.isFinite(timeoutMs) ||
timeoutMs <= 0 ||
!Number.isFinite(intervalMs) ||
intervalMs <= 0
) {
console.log('Timeout');
return { kind: 'timed_out', orderId, status: null };
}
const deadline = Date.now() + timeoutMs;
let lastSeenRevision = -1;
let observedStatus: OrderState | null = null;
const storedRevision = lastSeenRevisionByOrder.get(orderId);
if (storedRevision !== undefined) lastSeenRevision = storedRevision;
while (true) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
await new Promise(r => setTimeout(r, Math.min(intervalMs, remainingMs)));
if (Date.now() >= deadline) break;
let res;
let body: unknown;
try {
res = await fetch(`${BASE_URL}/orders/${orderId}`, {
headers,
...orderTransport()
});
body = await res.json();
} catch {
return { kind: 'needs_reconciliation', orderId, status: observedStatus };
}
if (!res.ok || !isRecord(body) || body.success !== true || !isSnapshot(body.data, orderId)) {
return { kind: 'needs_reconciliation', orderId, status: observedStatus };
}
const data = body.data;
observedStatus = data.status;
const smsRevision = data.sms_revision;
const otpMessage = data.otp_message;
if (
Number.isInteger(smsRevision) &&
smsRevision > lastSeenRevision &&
typeof otpMessage === 'string' &&
otpMessage.trim().length > 0
) {
lastSeenRevision = smsRevision;
lastSeenRevisionByOrder.set(orderId, lastSeenRevision);
return { kind: 'delivery', orderId, message: otpMessage };
}
const lifecycleStatus = data.status;
if (['COMPLETED', 'EXPIRED', 'CANCELED'].includes(lifecycleStatus)) {
console.log(`Pedido finalizado: ${lifecycleStatus}`);
return { kind: 'terminal', orderId, status: lifecycleStatus };
}
}
console.log('Timeout');
return { kind: 'timed_out', orderId, status: observedStatus };
}
async function reconcileCancellation(orderId: number): Promise<CancelOutcome> {
try {
const latestRes = await fetch(`${BASE_URL}/orders/${orderId}`, {
headers,
...orderTransport()
});
const latestBody = await latestRes.json();
if (!latestRes.ok || !isRecord(latestBody) || latestBody.success !== true ||
!isSnapshot(latestBody.data, orderId)) {
return { kind: 'ambiguous', orderId, latestStatus: null };
}
if (latestBody.data.status === 'CANCELED') {
return { kind: 'confirmed_canceled', snapshot: { id: orderId, status: 'CANCELED' } };
}
return { kind: 'ambiguous', orderId, latestStatus: latestBody.data.status };
} catch {
return { kind: 'ambiguous', orderId, latestStatus: null };
}
}
async function cancelOrder(orderId: number): Promise<CancelOutcome> {
let current: OrderSnapshotView;
try {
const currentRes = await fetch(`${BASE_URL}/orders/${orderId}`, {
headers,
...orderTransport()
});
const currentBody = await currentRes.json() as any;
if (
!currentRes.ok ||
!isRecord(currentBody) ||
currentBody.success !== true ||
!isSnapshot(currentBody.data, orderId)
) {
return { kind: 'ambiguous', orderId, latestStatus: null };
}
current = currentBody.data;
} catch {
return { kind: 'ambiguous', orderId, latestStatus: null };
}
if (!current.can_cancel) {
return { kind: 'skipped', orderId, status: current.status };
}
if (current.status !== 'ACTIVE') {
return { kind: 'ambiguous', orderId, latestStatus: current.status };
}
let cancelRes;
let cancelBody: unknown;
try {
cancelRes = await fetch(`${BASE_URL}/orders/cancel`, {
method: 'POST',
headers,
body: JSON.stringify({ id: orderId }),
...orderTransport(),
});
cancelBody = await cancelRes.json();
} catch {
return reconcileCancellation(orderId);
}
if (cancelRes.status === 200) {
const receipt = validCancelReceipt(cancelBody, orderId);
return receipt ? { kind: 'receipt', receipt } : reconcileCancellation(orderId);
}
const error = documentedCancelError(cancelRes.status, cancelBody);
if (error) {
return {
kind: 'rejected',
orderId,
status: current.status,
httpStatus: cancelRes.status,
errorCode: error.code,
};
}
return reconcileCancellation(orderId);
}
// Uso
async function getVerificationCode(catalogProductId: number) {
const order = await createOrder(catalogProductId);
// `phone_number` é opcional/anulável até a atribuição. Uma única leitura
// limitada com o mesmo id; nunca outro create pago. Um throw por truthiness
// aceitaria " " e ainda descartaria um pedido já cobrado.
const atribuido = (v: unknown): v is string =>
typeof v === 'string' && v.trim().length > 0;
const phone = order.phone_number;
if (!atribuido(phone)) {
return { kind: 'pending_assignment', orderId: order.id } as const;
}
console.log(`Número: ${phone}`);
const poll = await pollForSms(order.id);
if (poll.kind === 'delivery') {
console.log(`Código: ${poll.message}`);
return { ...poll, phone } as const;
}
if (poll.kind === 'terminal' || poll.kind === 'needs_reconciliation') {
return poll;
}
return cancelOrder(order.id);
}
getVerificationCode(88)
.then((resultado) => {
if (resultado.kind === 'pending_assignment') {
console.log(`pending_assignment: pedido ${resultado.orderId} ainda sem número`);
return;
}
if (resultado.kind === 'delivery') {
console.log(`SMS recebido para o pedido ${resultado.orderId}`);
return;
}
if (resultado.kind === 'terminal') {
console.log(`Pedido ${resultado.orderId} terminou com status ${resultado.status}`);
return;
}
if (resultado.kind === 'receipt') {
console.log(`Pedido ${resultado.receipt.order_id} cancelado com recibo validado`);
return;
}
if (resultado.kind === 'confirmed_canceled') {
console.log(`Pedido ${resultado.snapshot.id} consta como CANCELED, sem inferir reembolso`);
return;
}
if (resultado.kind === 'rejected') {
console.log(`Cancelamento rejeitado: ${resultado.errorCode} (HTTP ${resultado.httpStatus})`);
return;
}
if (resultado.kind === 'skipped') {
console.log(`Cancelamento não enviado; can_cancel=false em ${resultado.status}`);
return;
}
if (resultado.kind === 'needs_reconciliation') {
console.log(`Pedido ${resultado.orderId} requer reconciliação; status observado: ${resultado.status}`);
return;
}
console.log(`Pedido ${resultado.orderId} requer reconciliação; status observado: ${resultado.latestStatus}`);
})
.catch(console.error);
PHP
<?php
class SMSCodeClient {
private string $token;
private string $baseUrl = 'https://api.smscode.gg/v1';
public function __construct(string $token) {
$this->token = $token;
}
private function request(
string $method,
string $endpoint,
?array $data = null,
array $extraHeaders = []
): array {
$ch = curl_init($this->baseUrl . $endpoint);
$headers = array_merge([
"Authorization: Bearer {$this->token}",
"Content-Type: application/json",
], $extraHeaders);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
]);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
throw new \RuntimeException('Erro de rede na requisição');
}
$decoded = json_decode($response, true);
if (!$decoded['success']) {
throw new \RuntimeException($decoded['error']['message']);
}
return $decoded['data'];
}
public function createOrder(int $catalogProductId): array {
$body = ['catalog_product_id' => $catalogProductId, 'quantity' => 1];
$key = bin2hex(random_bytes(16));
$data = $this->request(
'POST',
'/orders/create',
$body,
['Idempotency-Key: ' . $key]
);
return $data['orders'][0];
}
public function getOrder(int $orderId): array {
return $this->request('GET', "/orders/{$orderId}");
}
public function cancelOrder(int $orderId): ?array {
$current = $this->getOrder($orderId);
if (!$current['can_cancel']) {
return null;
}
$result = $this->request('POST', '/orders/cancel', ['id' => $orderId]);
if ($result['status'] !== 'CANCELED') {
throw new \RuntimeException('Cancelamento não confirmado pelo servidor');
}
return $result;
}
public function waitForSms(int $orderId, int $timeoutSeconds = 300): ?string {
$deadline = time() + $timeoutSeconds;
$lastSeenRevision = -1;
while (time() < $deadline) {
sleep(5);
$data = $this->getOrder($orderId);
$smsRevision = $data['sms_revision'] ?? null;
$otpMessage = $data['otp_message'] ?? null;
if (
is_int($smsRevision) &&
$smsRevision > $lastSeenRevision &&
is_string($otpMessage) &&
trim($otpMessage) !== ''
) {
$lastSeenRevision = $smsRevision;
return $otpMessage;
}
$lifecycleStatus = $data['status'];
if (in_array($lifecycleStatus, ['COMPLETED', 'EXPIRED', 'CANCELED'], true)) {
return null;
}
// ACTIVE e OTP_RECEIVED continuam em polling sem nova mensagem válida.
}
return null;
}
}
// Uso
$client = new SMSCodeClient(getenv('SMSCODE_TOKEN'));
try {
$order = $client->createOrder(88);
// `phone_number` é opcional/anulável até a atribuição: uma única leitura
// limitada com o mesmo id, nunca outro create pago.
$phone = $order['phone_number'] ?? null;
if (!(is_string($phone) && trim($phone) !== '')) {
// pending_assignment: pedido resolvido e cobrado; não use nem recrie.
echo "pending_assignment: pedido {$order['id']} ainda sem número\n";
exit(0);
}
echo "Número: {$phone}\n";
$code = $client->waitForSms($order['id']);
if ($code) {
echo "Código: {$code}\n";
} else {
$client->cancelOrder($order['id']);
echo "SMS não chegou\n";
}
} catch (\RuntimeException $e) {
echo "Erro: " . $e->getMessage() . "\n";
}
?>
Tratamento de Erros
A API retorna erros no formato padrão com código e mensagem:
| Código HTTP | Código de erro | Descrição | Ação recomendada |
|---|---|---|---|
| 401 | UNAUTHORIZED |
Token inválido ou ausente | Verifique o token |
| 409 | INSUFFICIENT_BALANCE |
Saldo insuficiente | Adicione créditos |
| 422 | NO_OFFER_AVAILABLE |
Nenhum produto atende à seleção | Encerre esta compra; outra seleção é uma nova ação explícita |
| 404 | NOT_FOUND |
ID de pedido inexistente | Verifique o ID |
| 429 | RATE_LIMIT_EXCEEDED |
Muitas requisições | Aguarde Retry-After |
| 503 | SERVICE_UNAVAILABLE |
Serviço temporariamente indisponível | Em create pago, pare e reconcilie antes de outra compra |
Implementando Retry com Backoff Exponencial
import time
import requests
def api_get_with_retry(url, headers, max_retries=3):
delay = 1
for attempt in range(max_retries):
try:
response = requests.get(url, headers=headers, timeout=(5, 30))
if response.status_code == 429:
raw_retry_after = response.headers.get("Retry-After")
try:
retry_after = int(raw_retry_after) if raw_retry_after else delay
except (TypeError, ValueError):
retry_after = delay
retry_after = max(1, min(retry_after, 60))
print(f"Rate limit. Aguardando {retry_after}s...")
time.sleep(retry_after)
continue
if response.status_code == 503:
print(f"Serviço indisponível. Tentativa {attempt + 1}/{max_retries}")
time.sleep(delay)
delay = min(delay * 2, 60) # backoff exponencial até 60s
continue
return response
except requests.exceptions.ConnectionError:
print(f"Erro de conexão. Tentativa {attempt + 1}/{max_retries}")
time.sleep(delay)
delay = min(delay * 2, 30)
raise Exception(f"Falha após {max_retries} tentativas")
Boas Práticas de Integração
Variáveis de ambiente para o token
# .env
SMSCODE_TOKEN=seu_token_aqui
# .gitignore — SEMPRE inclua:
.env
*.env
Polling eficiente
Configure um intervalo e um timeout total limitados pela aplicação. Em respostas 429, respeite um
Retry-After positivo; se estiver ausente ou inválido, use um fallback limitado.
# Polling com backoff adaptativo
def smart_poll(order_id, max_attempts=60):
delay = 3
last_seen_revision = -1
for attempt in range(max_attempts):
time.sleep(delay)
result = client.get_order(order_id)
revision = result.get("sms_revision")
message = result.get("otp_message")
if (
type(revision) is int
and revision > last_seen_revision
and isinstance(message, str)
and message.strip()
):
last_seen_revision = revision
return message
lifecycle_status = result["status"]
if lifecycle_status in ["COMPLETED", "EXPIRED", "CANCELED"]:
return None
# ACTIVE e OTP_RECEIVED continuam em polling sem nova mensagem válida.
# Aumenta delay gradualmente após 30s
if attempt > 6:
delay = min(delay * 1.2, 15)
return None
Sempre cancele pedidos não utilizados
# No finally block para garantir cancelamento
try:
order = client.get_number(88)
# ... lógica principal
code = client.wait_for_sms(order["id"])
return code
except Exception as e:
# O método consulta can_cancel antes do POST de cancelamento
if order and order["id"]:
client.cancel_order(order["id"])
raise
Log estruturado para depuração
import logging
import json
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("smscode")
def logged_get_number(catalog_product_id: int):
logger.info(json.dumps({
"action": "create_order",
"catalog_product_id": catalog_product_id
}))
try:
order = client.get_number(catalog_product_id)
logger.info(json.dumps({
"action": "order_created",
"order_id": order["id"],
# `or` aceitaria " " porque espaços são truthy; teste o valor sem espaços.
"phone": (order.get("phone_number") or "").strip() or "pending_assignment"
}))
return order
except Exception as e:
logger.error(json.dumps({
"action": "create_order_failed",
"error": str(e)
}))
raise
Casos de Uso Comuns para Desenvolvedores
Testes automatizados de cadastro (E2E): Integre o SMSCode na suite de testes E2E (Playwright, Cypress, Selenium) para testar fluxos completos de registro com OTP real — sem mocks que não refletem o comportamento em produção.
# Exemplo com Playwright + SMSCode
from playwright.async_api import async_playwright
import asyncio
async def test_signup_flow():
client = SMSCodeClient(API_TOKEN)
order = client.get_number(88)
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
# `phone_number` é opcional/anulável até a atribuição: uma única leitura
# limitada com o mesmo id antes de preencher o formulário, nunca outro
# create pago.
phone = order.get("phone_number")
if not (isinstance(phone, str) and phone.strip()):
phone = client.get_order(order["id"]).get("phone_number")
if not (isinstance(phone, str) and phone.strip()):
# pending_assignment: não inicie o cadastro com número ausente.
await browser.close()
return {"kind": "pending_assignment", "order_id": order["id"]}
await page.goto("https://www.instagram.com/accounts/signup/")
await page.fill("[name='phone_number']", phone)
await page.click("[type='submit']")
# Aguardar SMS
code = client.wait_for_sms(order["id"])
await page.fill("[name='verification_code']", code)
await page.click("[type='submit']")
# Verificar sucesso
await page.wait_for_selector(".success-indicator")
print("Teste passou!")
await browser.close()
asyncio.run(test_signup_flow())
Monitoramento de disponibilidade: Verifique periodicamente se um serviço ainda está aceitando números de determinado país — útil para sistemas que dependem de verificações regulares.
Pipeline de onboarding em massa: Para plataformas que criam contas em serviços de terceiros em nome de usuários (como automação de agências), integre a API do SMSCode no fluxo de onboarding.
FAQ
A API do SMSCode é compatível com SMS-Activate?
Não diretamente. A API do SMSCode usa padrão REST com JSON, diferente do protocolo legado do SMS-Activate (que usa parâmetros de query string e respostas em texto plano). Se você está migrando de outro serviço, será necessário adaptar a integração — mas o modelo conceitual é o mesmo (criar pedido → polling → ler código).
Qual o intervalo recomendado para polling?
Use um intervalo e um timeout total limitados pela sua aplicação. Ao receber 429, respeite um
Retry-After positivo ou aplique um fallback limitado; não trate um intervalo local nem um prazo de
entrega como garantia da API.
É possível receber webhook quando o SMS chegar?
Sim. Valide X-Webhook-Signature no formato sha256={hex} sobre os bytes brutos, com comparação em tempo constante, antes de fazer parse do JSON. Processe e persista ou enfileire o evento de forma durável antes de responder 2xx; use polling como reconciliação.
O token de API tem o mesmo acesso que a conta web?
Sim. O token dá acesso completo à conta, incluindo criação de pedidos e leitura do histórico. Trate como senha: faça rotação periódica, não compartilhe, revogue imediatamente se suspeitar de comprometimento. Acesse Configurações → API para gerenciar tokens.
Como lidar com o caso onde o SMS nunca chega?
Implemente timeout máximo. Se o status ainda for ACTIVE, consulte o snapshot e envie POST /v1/orders/cancel apenas se can_cancel for true. Antes de qualquer nova compra, reconcilie a tentativa anterior; para REQUEST_IN_PROGRESS, reutilize de forma limitada a mesma key e o mesmo body.
Posso usar a API para verificar qualquer serviço disponível no catálogo?
Sim. Qualquer produto ativo retornado pelo catálogo pode ser usado pela API. GET /v1/catalog/products é paginado: percorra meta.page, meta.limit e meta.count para reunir as páginas necessárias, ou consulte a documentação da API para o contrato do endpoint.