Documentação da API
Base: https://…/api/v1 · JSON in/out ·
Authorization: Bearer <api_key>
Quickstart
Três passos: crie a sessão, mostre o código, espere o resultado.
# 1. chave demo (sem cadastro, 10 chamadas) curl -X POST https://…/api/v1/keys/demo # 2. criar a sessão de login curl -X POST https://…/api/v1/auth/start \ -H "Authorization: Bearer demo_..." \ -H "Content-Type: application/json" \ -d '{"mode":"login"}' # 3. esperar (long-poll de até 30s) curl "https://…/api/v1/auth/ses_abc/wait?timeout=25" \ -H "Authorization: Bearer demo_..."
Autenticação
Dois tipos de chave. demo_… é anônima, criada sem conta, limitada a
10 chamadas de /auth/start e sem webhooks nem callback_url.
sk_live_… pertence a uma conta (criada no
dashboard) e tem os scopes
auth:create auth:read user:read webhook:write.
Guardamos apenas o SHA-256 da chave e os 12 primeiros caracteres para exibição. A chave é mostrada uma única vez.
Erros e limites
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": {} } }
| Código | HTTP | Quando |
|---|---|---|
| UNAUTHORIZED | 401 | Chave ausente, desconhecida ou revogada |
| FORBIDDEN | 403 | Scope insuficiente |
| NOT_FOUND | 404 | Sessão/usuário/endpoint inexistente ou de outra conta |
| VALIDATION_ERROR | 400 | Corpo inválido |
| RATE_LIMITED | 429 | 60 req/min por chave; 5 sessões/10min por telefone |
| QUOTA_EXCEEDED | 402 | Cota da chave demo esgotada |
| NO_NUMBER_AVAILABLE | 503 | Nenhum número do pool online e com vaga |
| SESSION_EXPIRED | 410 | Sessão expirada |
| INTERNAL | 500 | Erro inesperado |
Toda resposta traz X-RateLimit-Limit e X-RateLimit-Remaining;
429 traz Retry-After. Chaves demo trazem X-Quota-Limit e
X-Quota-Used.
API pública
POST /api/v1/keys/demo
Cria uma chave anônima. Sem auth, limitada por IP.
curl -X POST https://…/api/v1/keys/demo
201 { "api_key": "demo_...", "quota": { "limit": 10, "used": 0 }, "created_at": "..." }
GET /api/v1/me
{ "key": { "id", "prefix", "type": "demo"|"live", "scopes": [] },
"developer": { "id", "phone" } | null,
"quota": { "limit", "used" },
"usage": { "sessions_today", "sessions_total" } }
POST /api/v1/auth/start auth:create
| Campo | Tipo | Descrição |
|---|---|---|
| mode | string | verify (exige o telefone) ou login (quem mandar vira a identidade) |
| phone | string | E.164, obrigatório em verify, proibido em login |
| collect | string[] | Opcional. Hoje só ["email"]: após validar o número, o bot pergunta o e-mail e pede confirmação (SIM/NÃO) |
| callback_url | string | Opcional, https. Indisponível para chaves demo (não há segredo de assinatura sem conta) |
| metadata | object | JSON livre ≤ 2KB, devolvido no webhook |
| locale | string | pt-BR (padrão) ou en — idioma das respostas no WhatsApp |
curl -X POST https://…/api/v1/auth/start \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"mode":"verify","phone":"+5511999998888","metadata":{"order":"123"}}'
201 {
"session_id": "ses_...", "status": "pending", "mode": "verify", "code": "4827",
"message": "AUTH 4827",
"number": { "id": "num_...", "phone": "+5511999998888",
"display": "+55 11 99999-8888",
"wa_link": "https://wa.me/5511999998888?text=AUTH%204827" },
"expires_at": "...", "created_at": "..."
}
O código vale 5 minutos, é único naquele número enquanto estiver ativo e só pode ser usado uma vez.
GET /api/v1/auth/:session_id auth:read
{ "session_id": "ses_...",
"status": "pending|collecting|confirming|authenticated|expired|failed",
"mode": "login", "expires_at": "...", "created_at": "...", "completed_at": null,
"user": null | { "id": "usr_...", "phone": "+55...",
"whatsapp": { "jid": "...", "push_name": "...", "profile": <contato> | null },
"attributes": { "email": { "value", "verified", "collected_at" } } },
"metadata": {},
"failure_reason": null | "WRONG_PHONE" | "TOO_MANY_ATTEMPTS" | "EXPIRED" }
code e number não são devolvidos aqui — só na criação.
user.whatsapp.profile é o que o dispositivo espelhou sobre quem
enviou: todos os nomes, o recado, o perfil comercial e a foto (mesmo formato de
<contato> do espelho do WhatsApp). Vem null quando o número do
pool não tem captura — e pode vir null por um ou dois segundos logo após o
authenticated, porque o contato chega por outro evento.
GET /api/v1/auth/:session_id/wait?timeout=25 auth:read
Mesmo corpo do anterior. Retorna assim que o status deixa pending, ou no fim do timeout (máx. 30s). Reabra a chamada em loop.
GET /api/v1/auth/:session_id/photo auth:read
A foto de perfil de quem autenticou, em bytes (image/jpeg). É para onde
aponta profile.profile_picture.image_url: qualquer chave que enxergue a
sessão busca a imagem, sem precisar de whatsapp:read. 404 quando
não há foto espelhada.
GET /api/v1/users/:user_id user:read
{ "id": "usr_...", "phone": "+55...",
"whatsapp": { "jid", "push_name", "name" },
"attributes": { "email": { "value", "verified", "collected_at" } },
"first_seen_at", "last_seen_at", "sessions_count" }
O user_id é estável por (conta, telefone): o mesmo usuário volta com o mesmo id.
GET /api/v1/numbers
{ "available": 3,
"numbers": [ { "id", "display", "country", "status" } ] }
Para chaves demo o telefone vem mascarado (+55 11 9****-8888).
Webhooks
Somente chaves live. Eventos: authentication.success, authentication.failed, authentication.expired.
curl -X POST https://…/api/v1/webhooks \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"url":"https://api.seusite.com/hooks/wa",
"events":["authentication.success"],
"data_level":"standard"}'
201 { "id": "whe_...", "secret": "whsec_..." } # secret exibido uma única vez
Outros: GET /webhooks, DELETE /webhooks/:id,
GET /webhooks/deliveries?endpoint_id=&status=,
POST /webhooks/deliveries/:id/retry.
Payload de authentication.success
{
"event": "authentication.success",
"event_id": "evt_...",
"session_id": "ses_...",
"mode": "login",
"id": "usr_8f92a1",
"phone": "+5521988887777",
"whatsapp": { "jid": "...", "push_name": "João" },
"attributes": { "email": "joao@gmail.com" },
"metadata": { },
"authenticated_at": "2026-09-12T21:05:00Z"
}
data_level: "basic" envia só id, phone e o jid;
"standard" acrescenta push_name, name e attributes.
Verificar a assinatura
Headers: X-Event-ID, X-Timestamp (unix seconds) e
X-Signature: v1=<hex hmac_sha256(secret, timestamp + "." + rawBody)>.
Rejeite se |agora − timestamp| > 300s. Assine sobre o corpo bruto, antes de qualquer parse.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/hooks/wa', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = Number(req.get('X-Timestamp'));
const signature = req.get('X-Signature') ?? '';
const rawBody = req.body.toString('utf8');
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send('stale');
}
const expected =
'v1=' +
crypto
.createHmac('sha256', process.env.WA_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(rawBody);
console.log(event.event, event.phone);
res.sendStatus(200); // qualquer 2xx conclui a entrega
});
Python (Flask)
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WA_WEBHOOK_SECRET"].encode()
@app.post("/hooks/wa")
def hook():
timestamp = request.headers.get("X-Timestamp", "")
signature = request.headers.get("X-Signature", "")
raw = request.get_data() # bytes, sem parse
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(400)
expected = "v1=" + hmac.new(
SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = json.loads(raw)
print(event["event"], event.get("phone"))
return "", 200
Retries
Qualquer resposta fora de 2xx (ou timeout de 10s) agenda nova tentativa:
1min → 5min → 30min → 2h → 12h. Depois da 5ª a entrega vira
dead. O histórico fica em GET /webhooks/deliveries e pode ser
reenviado manualmente. Trate os webhooks como at-least-once: deduplique por
X-Event-ID.
Protocolo do gateway
O gateway open source é burro de propósito: recebe mensagens, encaminha e envia as respostas que a nuvem mandar. Toda decisão de autenticação é da nuvem.
POST /gateway/v1/register # informa os números da sessão POST /gateway/v1/heartbeat # a cada 30s; >90s sem heartbeat = DISCONNECTED POST /gateway/v1/events # lote de 1..50 eventos, idempotente por event.id (24h) GET /gateway/v1/commands?wait=25 # long-poll de comandos POST /gateway/v1/commands/:id/ack # confirma a execução
Resposta de /events: um outcome por evento
(MATCHED, ALREADY_USED, EXPIRED, WRONG_PHONE, NO_MATCH, COLLECTED, CONFIRMED,
REJECTED, IGNORED, DUPLICATE) e um reply opcional que o gateway
envia ao usuário. Mensagens de grupo são sempre IGNORED, sem resposta.
Especificação completa: open-source/protocol/PROTOCOL.md.
Admin
Header X-Admin-Token. Criação de gateways, gestão do pool e pareamento por QR.
curl -X POST https://…/admin/v1/gateways \
-H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"casa-sp"}' # token exibido uma única vez