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ódigoHTTPQuando
UNAUTHORIZED401Chave ausente, desconhecida ou revogada
FORBIDDEN403Scope insuficiente
NOT_FOUND404Sessão/usuário/endpoint inexistente ou de outra conta
VALIDATION_ERROR400Corpo inválido
RATE_LIMITED42960 req/min por chave; 5 sessões/10min por telefone
QUOTA_EXCEEDED402Cota da chave demo esgotada
NO_NUMBER_AVAILABLE503Nenhum número do pool online e com vaga
SESSION_EXPIRED410Sessão expirada
INTERNAL500Erro 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

CampoTipoDescrição
modestringverify (exige o telefone) ou login (quem mandar vira a identidade)
phonestringE.164, obrigatório em verify, proibido em login
collectstring[]Opcional. Hoje só ["email"]: após validar o número, o bot pergunta o e-mail e pede confirmação (SIM/NÃO)
callback_urlstringOpcional, https. Indisponível para chaves demo (não há segredo de assinatura sem conta)
metadataobjectJSON livre ≤ 2KB, devolvido no webhook
localestringpt-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

Abrir o painel admin →