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/validatecom os números como estão no seu cadastro. - Envie a mensagem somente quando
statusforvalid, e sempre para o número emsend_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:
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 responde403 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 enviado | status simulado | Detalhe |
|---|---|---|
| Formato inválido | invalid_number | Mesma validação de produção. |
Termina em 0 | not_on_whatsapp | — |
Termina em 1 | ambiguous | — |
Termina em 9 | unknown | error: "whatsapp_session_unavailable". |
Termina em 2 a 8 | valid | send_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-Keyde 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.
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.
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"]}'
{
"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
| Campo | Descrição |
|---|---|
input | O número exatamente como foi enviado. |
normalized | Número normalizado com DDI, só dígitos. |
kind | mobile, landline ou international. |
status | Resultado da validação. Ver Status. |
has_whatsapp | Sempre 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_to | Número para onde enviar, no formato em que a conta está registrada. Só presente em valid. |
variant | Variante que tem conta: with_9, without_9, as_is ou other. |
is_business | Se a conta é comercial. |
error | Motivo, quando não foi possível validar. |
Status
| status | Significado | O que o sistema de envio deve fazer |
|---|---|---|
| valid | Tem conta. | Enviar para send_to, nunca para o número digitado. |
| not_on_whatsapp | Nenhuma variante tem conta. | Não enviar por este canal; usar outro. |
| ambiguous | Com 9 e sem 9 são contas diferentes. | Não enviar; revisão humana. |
| invalid_number | Formato inválido. | Corrigir o cadastro. |
| unknown | Não deu para consultar. error: whatsapp_session_unavailable, timeout, whatsapp_query_failed ou suppression_unavailable. | Não enviar; tentar de novo depois. |
| opted_out | O 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.
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>"]}'
Status e progresso. Faça polling a cada 5 a 10 segundos.
{ "batch_id": "…", "status": "processing", "total": 5000, "processed": 1200,
"counts": { "valid": 900, … },
"links": { "self": "/v1/batches/…", "results": "/v1/batches/…/results" } }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.
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 lote | Significado |
|---|---|
queued | Na fila. |
processing | Em processamento. Se vier note (ex.: aguardando sessão), está pausado esperando o canal voltar. |
done | Concluído. Resultados disponíveis até expires_at. |
canceled | Cancelado. O que já foi processado continua disponível. |
- Se o canal ficar indisponível, o lote pausa em vez de devolver
unknown. Itensunknowntêm 2 novas tentativas. - Depois do período de retenção, o lote é apagado (LGPD) e
GETresponde404 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:
200com o mesmo lote e headerIdempotent-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.
{
"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}/resultsenquanto 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-EventeX-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 disparambatch.finished, com"sandbox": true. - Responda
2xxem 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
idpara ignorar repetições. - Só aceitamos
httpsem 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_).
- Leia o corpo bruto, exatamente como chegou (antes de converter o JSON).
- Recalcule o HMAC e compare com
v1em tempo constante. Pode haver mais de umv1: aceite se algum conferir. - Recuse se
testiver a mais de 5 minutos do seu relógio (proteção contra reenvio). - Ao trocar o segredo no portal, o antigo para de valer na hora: atualize o seu sistema em seguida.
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)));
}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
200trazem o saldo restante no headerX-Credits-Remaining. - Se o saldo não cobrir a requisição inteira, nada é processado:
{ "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
unknownsão devolvidos à quota. Os demais (inclusiveinvalid_number) consomem. - Dia e mês seguem o fuso configurado no serviço.
- Se a requisição não couber na quota, nada é processado:
{ "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.
{ "error": "unauthorized", "message": "API key ausente, inválida ou revogada", "request_id": "…" }| HTTP | error | Quando |
|---|---|---|
| 400 | invalid_json | Corpo diferente de {"numbers": [...]}. |
| 400 | empty | Lista de números vazia. |
| 400 | invalid_number | Item com mais de 64 caracteres (lotes). |
| 400 | invalid_idempotency_key | Idempotency-Key com mais de 128 caracteres. |
| 401 | unauthorized | Chave ausente, inválida ou revogada. |
| 402 | insufficient_credits | O saldo de créditos não cobre a requisição. |
| 403 | client_disabled | Cliente desativado. |
| 404 | not_found | Lote inexistente ou já expirado. |
| 409 | not_cancelable | Lote já encerrado. |
| 413 | too_many_numbers | Mais números do que o permitido na chamada ou no lote. |
| 422 | idempotency_key_reused | Mesma chave de idempotência com outra lista. |
| 429 | quota_exceeded | A requisição não cabe na quota. |
| 429 | rate_limited | Limite de requisições excedido; veja Retry-After. |
| 500 | internal | Erro interno. Tente de novo e, se persistir, abra chamado com o request_id. |
Saúde do serviço
| Rota | Descrição |
|---|---|
GET /healthz | Liveness: o processo está no ar. |
GET /readyz | 200 se ao menos um número validador estiver pronto para consultar; 503 caso contrário. |