Referência da API · v1

Documentação da API

Tudo o que você precisa para validar contatos antes do envio. Os exemplos usam números fictícios: nos seus testes, use apenas números autorizados.

Primeiros passos

  • Peça acesso ao time comercial. Você recebe uma chave de API para o seu sistema.
  • Chame POST /v1/validate com os números como estão no seu cadastro.
  • Envie a mensagem somente quando status for valid, e sempre para o número em send_to.

Regra de integração: nunca envie para o número digitado. O destino certo é send_to.

Autenticação

Envie a chave em um dos headers abaixo, em toda requisição:

headers
X-API-Key: <sua-chave>
# ou
Authorization: Bearer <sua-chave>
  • A chave é exibida uma única vez, na criação. Guarde em um cofre de segredos.
  • Chave revogada ou ausente responde 401 unauthorized. Cliente desativado responde 403 client_disabled.
  • Toda escrita usa Content-Type: application/json.
  • Há dois tipos de chave: produção (wav_…) e teste (wav_test_…, ver sandbox). Crie as duas no portal, em Chaves de API.

Chaves de teste (sandbox)

Para desenvolver e homologar sem custo. Com uma chave de teste, a API responde com resultados simulados e determinísticos: nada é consultado no WhatsApp, o cache não é lido nem gravado e nenhum crédito ou quota é consumido. Toda resposta traz "sandbox": true e o header X-Sandbox: true; cada resultado vem com source: "sandbox".

Número enviadostatus simuladoDetalhe
Formato inválidoinvalid_numberMesma validação de produção.
Termina em 0not_on_whatsapp—
Termina em 1ambiguous—
Termina em 9unknownerror: "whatsapp_session_unavailable".
Termina em 2 a 8validsend_to e variant na forma que seria consultada primeiro: DDD 11 a 28 com o 9; demais DDDs sem o 9; celular 9 0… a 9 5… sempre com o 9; fixo como está.
  • Vale para /v1/validate, /v1/batches (o lote sai marcado "sandbox": true, é processado na hora, numa fila própria, e também dispara o webhook) e /v1/feedback (os eventos são validados, mas não têm efeito: action: "unchanged").
  • Lotes e Idempotency-Key de teste e de produção são separados: uma chave de teste não enxerga lotes de produção, e vice-versa.
  • Os limites de números por chamada, por lote e de requisições por segundo são os mesmos de produção. As chamadas aparecem no histórico de requisições marcadas como teste.
  • Os resultados simulados não dizem nada sobre o número real. Nunca use a resposta de uma chave de teste para decidir um envio.
chave de testeilustrativo
curl -X POST https://<seu-host>/v1/validate \
  -H "X-API-Key: wav_test_…" -H "Content-Type: application/json" \
  -d '{"numbers": ["(31) 98888-7772", "(31) 98888-7770", "(31) 98888-7779"]}'

{
  "request_id": "…",
  "sandbox": true,
  "results": [
    { "input": "(31) 98888-7772", "normalized": "5531988887772", "kind": "mobile", "status": "valid",
      "has_whatsapp": true, "send_to": "553188887772", "variant": "without_9" },
    { "input": "(31) 98888-7770", "status": "not_on_whatsapp", "has_whatsapp": false, … },
    { "input": "(31) 98888-7779", "status": "unknown", "error": "whatsapp_session_unavailable", … }
  ]
}

Validar números

Validação síncrona para listas pequenas. A quantidade máxima de números por chamada é definida no servidor; acima dela, a resposta é 413 too_many_numbers. Para listas grandes, use lotes.

POST/v1/validate
requisição
curl -X POST https://<seu-host>/v1/validate \
  -H "X-API-Key: $CHAVE" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["(31) 98888-7777", "11 3333-4444", "123"]}'
200 OKilustrativo
{
  "request_id": "9f2c1a7b3e4d5f60",
  "results": [
    { "input": "(31) 98888-7777", "normalized": "5531988887777", "kind": "mobile",
      "status": "valid", "has_whatsapp": true, "send_to": "553188887777", "variant": "without_9",
      "is_business": false },
    { "input": "11 3333-4444", "normalized": "551133334444", "kind": "landline",
      "status": "not_on_whatsapp", "has_whatsapp": false, "is_business": false },
    { "input": "123", "status": "invalid_number", "is_business": false,
      "error": "formato de número inválido" }
  ]
}

Respostas 200 de clientes com quota trazem os headers X-Quota-Daily-Remaining, X-Quota-Monthly-Remaining e os respectivos -Limit.

Campos da resposta

CampoDescrição
inputO número exatamente como foi enviado.
normalizedNúmero normalizado com DDI, só dígitos.
kindmobile, landline ou international.
statusResultado da validação. Ver Status.
has_whatsappSempre presente. true: o número tem conta (valid; em ambiguous existem duas contas). false: não tem conta ou o número é inválido. null: não foi possível saber agora (unknown) ou o titular pediu para não ser consultado (opted_out). Para enviar, siga o status: só valid, sempre para send_to.
send_toNúmero para onde enviar, no formato em que a conta está registrada. Só presente em valid.
variantVariante que tem conta: with_9, without_9, as_is ou other.
is_businessSe a conta é comercial.
errorMotivo, quando não foi possível validar.

Status

statusSignificadoO que o sistema de envio deve fazer
validTem conta.Enviar para send_to, nunca para o número digitado.
not_on_whatsappNenhuma variante tem conta.Não enviar por este canal; usar outro.
ambiguousCom 9 e sem 9 são contas diferentes.Não enviar; revisão humana.
invalid_numberFormato inválido.Corrigir o cadastro.
unknownNão deu para consultar. error: whatsapp_session_unavailable, timeout, whatsapp_query_failed ou suppression_unavailable.Não enviar; tentar de novo depois.
opted_outO titular do número pediu para não ser consultado (LGPD). O WhatsApp não é consultado e has_whatsapp volta null. Em lotes, itens apagados a pedido do titular voltam com input “[removido a pedido do titular]” e error: data_subject_request.Não enviar por este canal; usar outro canal ou tratar com o titular.

Lotes assíncronos

Para listas grandes. Você cria o lote, acompanha o progresso e busca os resultados paginados. A quota é reservada inteira na criação.

POST/v1/batches
criar · 202 Accepted
curl -X POST https://<seu-host>/v1/batches \
  -H "X-API-Key: $CHAVE" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-2026-11-04-001" \
  -d '{"numbers": ["<DDD+NUMERO>", "<DDD+NUMERO>"]}'
GET/v1/batches/{id}

Status e progresso. Faça polling a cada 5 a 10 segundos.

200 OKilustrativo
{ "batch_id": "…", "status": "processing", "total": 5000, "processed": 1200,
  "counts": { "valid": 900, … },
  "links": { "self": "/v1/batches/…", "results": "/v1/batches/…/results" } }
GET/v1/batches/{id}/results?offset=0&limit=1000

Resultados paginados (limit padrão 500, máximo 1000). Cada item tem position e os mesmos campos de /v1/validate. Itens ainda não processados vêm com status: "pending". Enquanto houver mais páginas, a resposta traz next_offset.

DELETE/v1/batches/{id}

Cancela o lote. O que não foi processado volta para a quota; o que já foi processado continua disponível. Lote já encerrado responde 409 not_cancelable.

status do loteSignificado
queuedNa fila.
processingEm processamento. Se vier note (ex.: aguardando sessão), está pausado esperando o canal voltar.
doneConcluído. Resultados disponíveis até expires_at.
canceledCancelado. O que já foi processado continua disponível.
  • Se o canal ficar indisponível, o lote pausa em vez de devolver unknown. Itens unknown têm 2 novas tentativas.
  • Depois do período de retenção, o lote é apagado (LGPD) e GET responde 404 not_found.
  • Em vez de polling, configure um webhook para ser avisado quando o lote terminar.

Idempotência

Envie Idempotency-Key (até 128 caracteres, por exemplo o ID do pedido no seu sistema) no POST /v1/batches.

  • Mesma chave + mesma lista: 200 com o mesmo lote e header Idempotent-Replayed: true. Não cria outro nem consome quota.
  • Mesma chave + lista diferente: 422 idempotency_key_reused.
  • A chave vale enquanto o lote existir.

Webhooks

Configure no portal, em Webhooks, uma URL https do seu sistema. Quando um lote termina (done ou canceled), enviamos um POST com o evento batch.finished. Na criação (e a cada troca) você recebe um segredo de assinatura (whsec_…), exibido uma única vez.

POST na sua URL · batch.finishedilustrativo
{
  "id": "evt_3f9c0d…",
  "type": "batch.finished",
  "created_at": "2026-11-04T13:05:00Z",
  "data": {
    "batch_id": "9b1d4e…",
    "status": "done",
    "total": 5000,
    "processed": 5000,
    "counts": { "valid": 4100, "not_on_whatsapp": 800, "invalid_number": 60, "unknown": 40 },
    "sandbox": false,
    "created_at": "2026-11-04T12:00:00Z",
    "finished_at": "2026-11-04T13:05:00Z",
    "expires_at": "2026-11-05T13:05:00Z"
  }
}
  • Sem telefones: o evento traz só id, status, totais e contagens. Busque os resultados em GET /v1/batches/{id}/results enquanto o lote existir (até expires_at).
  • Cabeçalhos: Content-Type: application/json, X-Webhook-Id (id do evento, o mesmo em todas as tentativas), X-Webhook-Event e X-Webhook-Signature.
  • O botão Enviar evento de teste do portal manda um evento webhook.test (sem dados de lote). Lotes feitos com chave de teste também disparam batch.finished, com "sandbox": true.
  • Responda 2xx em até 10 segundos. Qualquer outra resposta, erro de conexão ou demora conta como falha. Redirecionamentos (3xx) não são seguidos: cadastre a URL final.
  • Novas tentativas com espera crescente (cerca de 30 s, 1 min, 2 min, 4 min… até 4 h entre tentativas) por até 24 horas depois do evento. Depois disso a entrega fica como "Falhou". O portal mostra as últimas entregas, o código HTTP e o motivo.
  • O mesmo evento pode chegar mais de uma vez e fora de ordem: use id para ignorar repetições.
  • Só aceitamos https em domínio público. URLs que apontam (ou passam a apontar) para endereços privados, locais ou reservados são bloqueadas na hora da conexão. Não coloque usuário e senha na URL: a autenticação é a assinatura.

Verificar a assinatura

O header X-Webhook-Signature tem o formato t=<unix>,v1=<hex>, em que v1 é o HMAC-SHA256, em hexadecimal, de t + "." + corpo, usando como chave o segredo inteiro (com o prefixo whsec_).

  1. Leia o corpo bruto, exatamente como chegou (antes de converter o JSON).
  2. Recalcule o HMAC e compare com v1 em tempo constante. Pode haver mais de um v1: aceite se algum conferir.
  3. Recuse se t estiver a mais de 5 minutos do seu relógio (proteção contra reenvio).
  4. Ao trocar o segredo no portal, o antigo para de valer na hora: atualize o seu sistema em seguida.
Node.js
import crypto from 'node:crypto';

// rawBody: Buffer com o corpo exatamente como chegou
// (ex.: app.post('/webhooks/lotes', express.raw({ type: 'application/json' }), ...)).
export function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
  const t = /(?:^|,)t=(d+)/.exec(header ?? '')?.[1];
  const sigs = [...(header ?? '').matchAll(/(?:^|,)v1=([0-9a-f]+)/g)].map((m) => m[1]);
  if (!t || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex');
  return sigs.some((s) => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
Python
import hashlib, hmac, time

def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), None)
    sigs = [v for k, v in parts if k == "v1"]
    if not t or not t.isdigit() or not sigs or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in sigs)

Créditos

  • Contas pré-pagas têm um saldo de créditos: 1 crédito = 1 número enviado na requisição ou no lote.
  • O saldo é reservado antes de processar. Números que voltam unknown, e números de lote cancelado que não chegaram a ser processados, voltam para o saldo. Inválidos e respostas do cache consomem.
  • Compre créditos e acompanhe o saldo no portal da conta. Respostas 200 trazem o saldo restante no header X-Credits-Remaining.
  • Se o saldo não cobrir a requisição inteira, nada é processado:
402 Payment Required
{ "error": "insufficient_credits", "balance": 12, "requested": 20, "request_id": "…" }

Quotas

  • Cada número enviado consome 1 da quota diária e da mensal do cliente.
  • Números que voltam unknown são devolvidos à quota. Os demais (inclusive invalid_number) consomem.
  • Dia e mês seguem o fuso configurado no serviço.
  • Se a requisição não couber na quota, nada é processado:
429 Too Many Requests
{ "error": "quota_exceeded", "period": "daily", "limit": 1000, "used": 990,
  "requested": 20, "request_id": "…" }

Valores de quota, pacotes e preços são definidos com o time comercial.

Limite de requisições

Cada chave tem um limite de requisições por segundo, somando todas as rotas, com tolerância a picos curtos. Ao passar dele, a resposta é 429 rate_limited com o header Retry-After (em segundos). Respeite esse valor antes de tentar de novo.

Erros

Todo erro tem o mesmo formato. Informe o request_id ao abrir um chamado.

Conta, chaves, pedidos de crédito e chamadas do histórico aparecem no portal com identificadores opacos com prefixo do tipo (ex.: acc_… conta, key_… chave, ord_… pedido, req_… chamada), sem número sequencial. Use esses IDs completos (são texto, não número) ao falar com o suporte ou ao paginar o histórico.

corpo de erro
{ "error": "unauthorized", "message": "API key ausente, inválida ou revogada", "request_id": "…" }
HTTPerrorQuando
400invalid_jsonCorpo diferente de {"numbers": [...]}.
400emptyLista de números vazia.
400invalid_numberItem com mais de 64 caracteres (lotes).
400invalid_idempotency_keyIdempotency-Key com mais de 128 caracteres.
401unauthorizedChave ausente, inválida ou revogada.
402insufficient_creditsO saldo de créditos não cobre a requisição.
403client_disabledCliente desativado.
404not_foundLote inexistente ou já expirado.
409not_cancelableLote já encerrado.
413too_many_numbersMais números do que o permitido na chamada ou no lote.
422idempotency_key_reusedMesma chave de idempotência com outra lista.
429quota_exceededA requisição não cabe na quota.
429rate_limitedLimite de requisições excedido; veja Retry-After.
500internalErro interno. Tente de novo e, se persistir, abra chamado com o request_id.

Saúde do serviço

RotaDescrição
GET /healthzLiveness: o processo está no ar.
GET /readyz200 se ao menos um número validador estiver pronto para consultar; 503 caso contrário.