Guia Completo da API SMSCode para Desenvolvedores

Guia Completo da API SMSCode para Desenvolvedores

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:

  1. Uma conta ativa no SMSCode
  2. Saldo de crédito disponível (veja planos e preços)
  3. 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álido
  • 403 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.

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 IDR
  • available: Quantidade aproximada disponível
  • active: Indica se o produto está ativo
  • catalog_product_id: Identificador usado no body de criação
  • country_id e platform_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 cancelamento
  • phone_number: O número virtual quando já atribuído; o campo é opcional/anulável até a atribuição terminar
  • status: 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 quando otp_code for null
  • sms_revision: Versão monotônica da mensagem por pedido
  • can_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.

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.

Pronto para experimentar o SMSCode?

Crie uma conta e obtenha seu primeiro número virtual em menos de dois minutos.

Começar →