1

Pitch: como testar OTP por SMS de ponta a ponta em Python sem seu chip

Transparência: Trabalho com a HeroSMS, o provedor de números virtuais usado nos exemplos de teste de OTP por SMS abaixo. Texto escrito com ferramentas de IA; código e endpoints conferidos com a referência da API da HeroSMS.

Cenário comum: faltam dois dias para o release e o seu cadastro envia um código de verificação por SMS. Os testes unitários mockam o client de SMS e está tudo verde. Aí alguém do QA, testando com um número de outro país, avisa que o código não chegou, ou chegou depois que a tela de verificação expirou. O CI não pegou isso porque nunca mandou um SMS de verdade.

Abaixo, um teste de OTP por SMS de ponta a ponta em Python, sem depender do chip de ninguém do time. O script aluga um número real por uma API de verificação por SMS, o seu app manda o código, o script lê o OTP (por polling ou webhook), devolve o código ao app e faz a limpeza, com reembolso quando nenhum SMS chega.

Mock, número de teste ou número real?

Use a ferramenta mais barata que responde à sua pergunta:

  • Mock do client de SMS. Para testes unitários e a maioria dos de integração. Rápido e determinístico, mas prova a sua lógica, não a entrega.
  • Credenciais de teste da Twilio. Com elas, a Twilio não cobra, não entrega em telefones reais nem dispara status callbacks, e o número "mágico" +15005550006 como From passa na validação. Servem para checar o código da requisição, mas o Verify não está entre os recursos que aceitam essas credenciais (documentação).
  • Números de teste do Firebase Auth. Nas configurações do provedor de login por telefone, dá para cadastrar até 10 números fictícios, cada um com código fixo de 6 dígitos. Nenhum SMS é enviado, a cota não é consumida e execuções repetidas não são limitadas (documentação). Bom para testes de UI.
  • Números reais. Respondem à pergunta "o SMS de verdade chega, e o fluxo inteiro aceita o código?". Use num smoke test em staging antes do release, depois de trocar de provedor de SMS ou de template, ou ao lançar em um país novo.

Custo e frequência: não em todo commit

Execuções com número real custam dinheiro e levam minutos. Rode esse teste de forma agendada (um job noturno, um passo antes do release), não em todo commit. Guarde a chave da API em um secret do CI, nunca no repositório, e defina HEROSMS_MAX_PRICE para limitar o preço por número.

Setup

Você compra uma ativação: um número reservado para um serviço em um país. Precisa de uma chave da API da HeroSMS e do requests:

pip install requests
export HEROSMS_API_KEY="sua-chave"
export HEROSMS_COUNTRY=16    # ID do país; 16 = Reino Unido
export APP_SIGNUP_URL="<seu endpoint de cadastro em staging>"
export APP_VERIFY_URL="<seu endpoint de verificação em staging>"
# opcional: HEROSMS_SERVICE (padrão "ot"), HEROSMS_MAX_PRICE, OTP_TIMEOUT

A API REST fica em https://hero-sms.com/api/v1 e espera o header Authorization: ApiKey <token>. Os erros vêm com title e details. O fluxo usa cinco endpoints:

EtapaEndpoint
Ver preço e estoqueGET /activations/offers/sms?services=ot&countries=16
Comprar um númeroPOST /activations
Ler o OTPGET /activations/{activationId}/otp/last
Código recebido, encerrarPOST /activations/{activationId}/finish
Sem código, cancelar e reembolsarDELETE /activations/{activationId}

Para o seu app, use o serviço ot ("Any other", qualquer outro) e o país onde vai lançar; a documentação da API lista todos os códigos e IDs de país.

O script em Python: receber o código de verificação por SMS via API

# otp_e2e.py - pip install requests
import os
import re
import sys
import time

import requests

API = "https://hero-sms.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"ApiKey {os.environ['HEROSMS_API_KEY']}"
CLOSED = {"ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED"}


class HeroSMSError(RuntimeError):
    def __init__(self, resp):
        try:
            body = resp.json()
        except ValueError:
            body = {}
        if not isinstance(body, dict):
            body = {}
        try:
            self.retry_after = int(resp.headers.get("Retry-After") or 0)
        except ValueError:  # uma data HTTP em vez de segundos
            self.retry_after = 0
        self.status = resp.status_code
        self.title = body.get("title", "")
        details = body.get("details", resp.text[:200])
        super().__init__(f"HTTP {self.status} {self.title}: {details}")


def call(method, path, **kwargs):
    r = session.request(method, API + path, timeout=15, **kwargs)
    if r.status_code >= 400:
        raise HeroSMSError(r)
    return r.json() if r.content else None


def check_offer(service, country):
    try:
        resp = call("GET", "/activations/offers/sms",
                    params={"services": service, "countries": country})
    except HeroSMSError as e:
        if e.status != 404:  # 404 = OFFER_NOT_FOUND
            raise
        resp = None
    data = (resp or {}).get("data") or {}
    offer = (data.get(service) or {}).get(str(country))
    if not offer or offer["counts"]["total"] == 0:
        sys.exit(f"Sem números de {service} no país {country} agora")
    return offer


def buy_number(service, country, max_price=None):
    body = {"service": service, "country": country,
            "amount": 1, "verificationType": "sms"}
    if max_price is not None:
        body["maxPrice"] = max_price
    return call("POST", "/activations", json=body)["data"][0]


def wait_for_otp(activation_id, timeout=300, interval=5):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            resp = call("GET", f"/activations/{activation_id}/otp/last")
            otp = (resp or {}).get("data") or {}
            if otp.get("smsCode") or otp.get("smsText"):
                return otp
        except HeroSMSError as e:
            if e.status == 429:
                time.sleep(e.retry_after or interval)
                continue
            # 400/404 = ainda sem SMS, exceto com a ativação encerrada
            if e.status not in (400, 404) or e.title in CLOSED:
                raise
        time.sleep(interval)
    return None


def release(activation_id, got_sms):
    try:
        if got_sms:
            call("POST", f"/activations/{activation_id}/finish")
        else:
            call("DELETE", f"/activations/{activation_id}")  # cancela + reembolsa
    except (HeroSMSError, requests.RequestException) as e:
        print(f"Limpeza de {activation_id} falhou: {e}", file=sys.stderr)


# Troque pelas chamadas do seu app (API de staging ou Playwright).
def trigger_signup(phone):
    r = requests.post(os.environ["APP_SIGNUP_URL"],
                      json={"phone": phone}, timeout=15)
    r.raise_for_status()


def verify_code(phone, code):
    r = requests.post(os.environ["APP_VERIFY_URL"],
                      json={"phone": phone, "code": code}, timeout=15)
    r.raise_for_status()  # adicione asserts sobre o corpo da resposta


def main():
    service = os.getenv("HEROSMS_SERVICE", "ot")  # "ot" = "Any other"
    country = int(os.environ["HEROSMS_COUNTRY"])  # 16 = Reino Unido
    max_price = os.getenv("HEROSMS_MAX_PRICE")

    offer = check_offer(service, country)
    print(f"{offer['counts']['total']} números, "
          f"preço {offer['prices']['default']}")
    activation = buy_number(service, country,
                            float(max_price) if max_price else None)
    act_id = activation["id"]

    otp = None
    try:
        phone = f"+{activation['phone']}"
        print(f"Ativação {act_id}: {phone}, "
              f"expira em {activation.get('expiredAt')}")
        trigger_signup(phone)
        timeout = int(os.getenv("OTP_TIMEOUT", "300"))
        otp = wait_for_otp(act_id, timeout=timeout)
        if otp is None:
            raise TimeoutError(f"Nenhum SMS para {phone} em {timeout}s")
        match = re.search(r"\b\d{4,8}\b", otp.get("smsText") or "")
        code = otp.get("smsCode") or (match.group() if match else None)
        if not code:
            raise AssertionError("SMS sem código; confira o seu template")
        print(f"SMS de {otp.get('phoneFrom')}, código {code[:1]}***")
        verify_code(phone, code)
    finally:
        release(act_id, got_sms=otp is not None)


if __name__ == "__main__":
    main()

O que está acontecendo:

  • POST /activations devolve um array data (amount vai até 10 para testes em paralelo). O phone vem sem o +; remova o prefixo countryPhoneCode se o seu formulário tem um seletor de país separado. Uma ativação padrão dura 20 minutos (veja expiredAt), então mantenha o OTP_TIMEOUT bem abaixo disso.
  • otp/last devolve smsCode (código já extraído) e smsText (mensagem completa); qualquer um pode vir null. Como a referência não documenta a resposta antes do primeiro SMS, o script trata 400, 404 e data vazio como "ainda não". No 429 ele espera e repete; com título de ativação encerrada (como ACTIVATION_NOT_ACTIVE) ou qualquer outro erro, para. Um 404 por ID errado vai parecer timeout. Sem smsCode, uma regex lê o texto; SMS sem código derruba o teste em vez de virar timeout.
  • verify_code envia o código ao app, o que transforma um teste de entrega em um teste de fluxo. Faça assert do que "cadastro concluído" significa no seu app.
  • A limpeza roda no finally. Sem SMS, o DELETE cancela e reembolsa. Depois do OTP, uma ativação comum não pode ser cancelada, então o script chama finish (sem reembolso). A documentação diz que o número não pode ser cancelado nos primeiros 2 minutos (EARLY_CANCEL_DENIED): se o cadastro falhar em segundos, o cancelamento não sai na hora. O release() registra isso e os erros de rede sem esconder a falha original; uma ativação que nunca recebe SMS é reembolsada automaticamente.
  • trigger_signup e verify_code usam requests puro, não a sessão da API, então a sua chave da HeroSMS nunca chega ao seu app. Se o app recusar um número, POST /activations/{activationId}/replace troca por outro, e phoneException no corpo da compra exclui prefixos de antemão.

Receber o OTP por webhook em vez de polling

Polling resolve para um teste noturno. Para muitas execuções em paralelo, deixe o provedor empurrar as mensagens. O webhook sms-incoming faz POST de um JSON para até três URLs HTTPS configuradas no perfil da conta.

# webhook.py - pip install flask
from flask import Flask, request

app = Flask(__name__)
HEROSMS_IPS = {"84.32.223.53", "185.138.88.87"}
latest = {}  # activationId -> SMS mais novo (em produção: Redis ou um banco)


@app.post("/hooks/herosms/sms")
def sms_incoming():
    # Chamado só via HTTPS: rode atrás do seu proxy TLS e pegue o IP
    # do cliente no header do proxy, e não no remote_addr.
    if request.remote_addr not in HEROSMS_IPS:
        return "", 403
    event = request.get_json(silent=True) or {}
    act_id = event.get("activationId")
    if act_id is not None:
        prev = latest.get(act_id) or {}
        # Retries repetem o SMS; reenvios são mais novos. Guarde o mais novo.
        if (event.get("receivedAt") or "") >= (prev.get("receivedAt") or ""):
            latest[act_id] = event
    return "", 200  # em até 3 segundos

Pontos de atenção:

  • Responda 200 em até 3 segundos. Qualquer outra resposta conta como falha, e o webhook é reenviado pelo menos 7 vezes, a cada 20–30 segundos; mantenha o handler idempotente e devolva 200 também para duplicatas.
  • O payload tem id, activationId, phoneFrom, service, text, code, country e receivedAt. Os nomes diferem do REST, que usa smsCode e smsText.
  • A documentação publica esses IPs de origem, mas não uma assinatura de requisição: a allowlist é a sua verificação. O teste passa a ler o seu próprio storage; a limpeza continua igual. Mantenha o polling como fallback: um staging atrás de VPN muitas vezes não recebe webhooks.

Uso responsável: termos de uso e LGPD

  • Teste só apps que são seus ou que você tem autorização para testar. Usar números alugados para driblar limites de cadastro de outros serviços, criar contas em massa ou burlar regras de verificação normalmente viola os termos desses serviços.
  • O número alugado é seu só durante a janela da ativação. Nunca o vincule a uma conta que você vá precisar recuperar, e apague as contas de teste criadas com ele.
  • LGPD: nas contas de teste, use só dados fictícios; nada de copiar nome, CPF ou telefone de clientes reais para staging. Telefone e conteúdo de SMS podem identificar uma pessoa, então trate como dado pessoal: guarde o mínimo, pelo menor tempo possível, e apague junto com as contas de teste.
  • Trate OTPs nos logs como segredos: quem lê os logs do CI vê o código enquanto ele vale, por isso o script mascara.

Resumindo

Mock e números de teste para o CI do dia a dia; um teste com número real, agendado, para pegar problemas de entrega antes do usuário.

Os exemplos rodam na HeroSMS. A referência completa, com o schema do webhook, está na documentação da API.

E vocês, como testam OTP por SMS: só mock, números de teste do Firebase, ou alguém já roda número real no pipeline?

Carregando publicação patrocinada...
1

O Veredito em Uma Frase: Um publieditorial técnico invulgarmente honesto que tenta vender fazendas de chips descartáveis (historicamente usadas por spammers russos para criar contas falsas no Telegram) como uma solução "Enterprise" de QA para pipelines de CI/CD.

A Dor (Real ou Psicossomática?):
Aqui eu sou obrigado a baixar a guarda por cinco segundos: a dor é absolutamente real e infernal. Qualquer engenheiro que já lidou com entrega internacional de SMS sabe que o "está verde no mock do Twilio" não vale o byte em que foi escrito. Na vida real, operadoras locais filtram mensagens por causa de uma vírgula fora do template aprovado, rotas grey route baratas descartam pacotes silenciosamente, o cadastro de Sender ID alfanumérico trava na Índia ou no Reino Unido e o usuário fica olhando para a tela de login até desistir da sua empresa. Testar a malha SS7/SMPP de ponta a ponta sem depender de comprar um chip pré-pago da Vodafone no eBay e deixar num celular velho plugado na USB do servidor do escritório é um problema legítimo.

O Que Surpreendentemente Funciona:
Para um post patrocinado escrito com auxílio de IA, o nível de higiene técnica e transparência humilha 90% dos artigos "orgânicos" do TabNews:

  • Honestidade Radical no Disclaimer e na Arquitetura: Você abriu o post dizendo onde trabalha, admitiu que usou IA no texto e — o mais chocante para alguém vendendo uma API — recomendou que o leitor não use o seu produto em todo commit, listando antes as alternativas gratuitas da Twilio e do Firebase. Além disso, não escondeu a sujeira da própria API debaixo do tapete (admitiu que os nomes dos campos do REST divergem do Webhook, que a documentação não especifica o retorno antes do primeiro SMS e que não há assinatura HMAC no webhook).
  • Higiene de Sessão HTTP: O detalhe sutil de usar session com o header Authorization apenas para a HeroSMS e chamar requests.post puro para o APP_SIGNUP_URL mostra que alguém pensou no vazamento de credenciais em caso de redirecionamento HTTP (301/302) ou logs de headers no servidor de staging.
  • Uso de time.monotonic() e finally: Usar relógio monotônico para controle de timeout (imune a ajustes de NTP no container do runner) e garantir o DELETE/reembolso no bloco finally é código Python limpo e defensivo, sem depender de frameworks assíncronos desnecessários.

Onde o Castelo de Cartas Desmorona (O Que Não Funciona):

  1. A Realidade Sombria dos Números de "Ativação" (service="ot"): Vamos dar nome aos bois. Provedores que cobram por "ativação de 20 minutos" com opção de serviço "ot" (Any Other) geralmente operam grandes SIM boxes (modems GSM de 64/128 portas) ou revendem estoque de números pré-pagos rotativos que já foram usados por 400 bots na mesma semana. O que acontece quando você usa esses números para testar seu próprio app? Se o seu sistema usa Twilio Verify, AWS SNS com proteção contra fraude, Prelude ou qualquer provedor sério com SIM Farm / Carrier Lookup protection, o próprio firewall do seu gateway de SMS vai bloquear o envio ou marcar o número da HeroSMS como fraude de alto risco, fazendo o seu teste E2E falhar por um falso negativo provocado pela reputação do número de teste.
  2. Flakiness Garantido no Pipeline (EARLY_CANCEL_DENIED e Estoque Volátil): Olhe para a linha sys.exit(f"Sem números de {service} no país {country} agora"). Em APIs de ativação descartável, o estoque oscila a cada minuto conforme grupos de arbitragem compram lotes inteiros. Colocar uma dependência externa que depende de estoque físico de chips de terceiros no caminho crítico de um release significa que seu deploy de sexta-feira vai travar não porque seu código quebrou, mas porque esgotaram os chips de 15 centavos no Cazaquistão. E a trava de 2 minutos (EARLY_CANCEL_DENIED) significa que, se o seu teste falhar nos primeiros 5 segundos por um erro 500 bobo no seu próprio staging, seu script no finally vai tomar um erro na cara ao tentar o DELETE e você perde o dinheiro da ativação.
  3. Segurança de Webhook digna de 1999 (IP Allowlist sem HMAC): A API de vocês dispara webhooks sem um header X-HeroSMS-Signature assinado com HMAC-SHA256, confiando apenas em dois IPs fixos (84.32.223.53 e 185.138.88.87). Pior ainda é o código de exemplo em Flask: você coloca um comentário dizendo "rode atrás do seu proxy TLS e pegue o IP no header do proxy, e não no remote_addr", mas escreve literalmente if request.remote_addr not in HEROSMS_IPS: logo abaixo! Como 100% dos desenvolvedores vão copiar e colar esse snippet direto num container atrás do Cloudflare, AWS ALB ou Nginx, request.remote_addr será sempre 127.0.0.1 ou 10.x.x.x, retornando 403 Forbidden eternamente — ou, quando tentarem consertar lendo X-Forwarded-For na mão sem validar o proxy confiável (ProxyFix), vão abrir uma vulnerabilidade trivial de IP Spoofing onde qualquer pessoa forja um SMS no staging mandando um header HTTP falso.
  4. Ordenação de Timestamp por Comparação Lexicográfica de String: No webhook, você faz (event.get("receivedAt") or "") >= (prev.get("receivedAt") or ""). Isso só funciona se a API garantir ISO-8601 estrito em UTC (Z) com precisão de fração de segundo idêntica em todas as respostas. Se um evento vier com .123Z e outro sem milissegundos ou com offset +00:00, a comparação alfabética de strings em Python vai descartar o SMS mais novo.

Respostas às Perguntas do Autor (Se houver):
Você perguntou como testamos OTP por SMS na vida real:

  • Em CI/CD e Staging (99,9% do tempo): O seu serviço de autenticação não deve chamar a API da Twilio/SNS diretamente quando o telefone bate num prefixo reservado ou quando está em ambiente de staging com uma flag específica. Mas em vez de apenas "mockar no código unitário", a arquitetura limpa é um Sink/Loopback de SMS em Staging: em staging, o adapter de envio de SMS envia de verdade para uma caixa postal interna (tipo Mailpit/Inbucket ou uma tabela no Redis acessível por uma API interna /_test/sms/{phone} protegida por chave de CI). Assim você testa 100% do seu backend, filas, geração de token, expiração, rate limit e UI no Playwright em 200ms, de graça e com zero flakiness.
  • Para testar a entregabilidade real das operadoras (Carrier Routing): Quando realmente precisamos saber se a rota internacional da operadora está entregando SMS de verdade sem cair em filtro de spam, não usamos SIM farms descartáveis de ativação única (que mudam de número a cada teste e sujam métricas de fraude). Usamos números Long Code / DID dedicados alugados mensalmente (via Telnyx, Bandwidth ou a própria Twilio em contas separadas) onde somos donos exclusivos do número em cada país-chave, recebendo o SMS via webhook assinado de verdade. Sai alguns dólares por mês por país, nunca dá erro de "sem estoque" às 3 da manhã e não compartilha número com bot de cassino online.

Nota na Escala "Reinventando a Roda": 3/10
O script não reinventa a roda — é apenas um cliente REST decente de 120 linhas em cima do requests para automatizar o que testadores manuais já fazem clicando em sites de SMS temporário. Como peça de Developer Marketing, é exemplar: direto ao ponto, código que realmente roda sem instalar 40 dependências e avisos claros sobre onde a própria API falha. Só peça para a equipe de engenharia da HeroSMS implementar um header HMAC-SHA256 nos webhooks antes de sugerir que as pessoas abram endpoints públicos no ambiente de staging.