WPPAPI
Blog

API de WhatsApp em Python: enviar mensagens e receber webhooks com Flask

·Equipe WPPAPIpythontutorialwebhooks

Quase todo tutorial de API de WhatsApp no Brasil vem em Node.js. Se seu backend é Django, FastAPI ou um script cron em Python, você acaba traduzindo fetch para requests na mão e descobrindo os detalhes chatos — validação de assinatura, retry em 429, base64 de mídia — pelo caminho.

Este post é o caminho já traduzido: um cliente Python enxuto, envio de texto e arquivo, e um receptor de webhook em Flask que valida a assinatura antes de confiar no payload. Sem aprovação da Meta, sem template pré-aprovado: o número conecta por QR Code.

O que você precisa

Uma instância criada no painel, com instanceId e token em mãos. Toda rota do gateway tem o mesmo prefixo:

https://api.wpp-api.com/instances/{instanceId}/token/{token}/...

Se preferir não carregar o token na URL (logs de proxy, por exemplo), mande no header Client-Token ou Instance-Token — o efeito é o mesmo.

pip install requests flask

O cliente: 30 linhas com retry

O ponto que a maioria dos wrappers caseiros esquece é o 429. O gateway limita 120 requisições por minuto por instância e o envio segue o uso justo de aproximadamente 1 mensagem por segundo. Se você disparar uma lista de 500 contatos num for sem pausa, vai tomar 429 — e é melhor tratar isso no cliente do que descobrir em produção.

import os, time, requests

BASE = "https://api.wpp-api.com"
PREFIX = f"{BASE}/instances/{os.environ['WPP_INSTANCE']}/token/{os.environ['WPP_TOKEN']}"

class WPPAPI:
    def __init__(self):
        self.http = requests.Session()

    def _call(self, path, json=None, method="POST", params=None, tries=4):
        for attempt in range(1, tries + 1):
            r = self.http.request(
                method, f"{PREFIX}/{path}", json=json, params=params, timeout=20
            )
            if r.status_code == 429 or r.status_code >= 500:
                if attempt == tries:
                    r.raise_for_status()
                time.sleep(2 ** attempt)   # 2s, 4s, 8s
                continue
            r.raise_for_status()
            return r.json()

    def send_text(self, phone, message):
        return self._call("send-text", {"phone": phone, "message": message})

    def send_image(self, phone, url, caption=None):
        return self._call("send-image", {"phone": phone, "image": url, "caption": caption})

    def send_pdf(self, phone, data_b64, filename):
        return self._call("send-file", {
            "phone": phone,
            "file": {"data": data_b64, "mimetype": "application/pdf", "filename": filename},
        })

    def check_number(self, phone):
        return self._call("check-number", method="GET", params={"phone": phone})

    def seen(self, phone):
        return self._call("send-seen", {"phone": phone})

    def typing(self, phone, on=True):
        return self._call(f"typing/{'start' if on else 'stop'}", {"phone": phone})

Uso:

wpp = WPPAPI()
print(wpp.send_text("5511999999999", "Seu pedido #1042 saiu para entrega 🚚"))
# {'success': True, 'result': {...}}

O telefone vai só com dígitos, no formato internacional (55 + DDD + número). Toda resposta de envio volta como {"success": true, "result": {...}}, com o id da mensagem dentro de result — guarde-o se quiser correlacionar com o webhook de entrega depois.

Para mídia você escolhe entre URL pública ("image": "https://...", "document": "https://...") ou base64 no objeto file, útil quando o arquivo é gerado na hora:

import base64
with open("boleto.pdf", "rb") as f:
    wpp.send_pdf("5511999999999", base64.b64encode(f.read()).decode(), "boleto-julho.pdf")

Limite de 100 MB por arquivo. Os endpoints de vídeo (send-video), áudio/PTT (send-voice), localização (send-location) e contato (send-contact) seguem o mesmo formato — a referência completa está nos docs.

Recebendo mensagens: valide a assinatura primeiro

Configure a URL do webhook no painel e cada evento chega como POST JSON. Uma mensagem recebida vem assim:

{
  "event": "received",
  "wahaEvent": "message",
  "instanceId": "inst_abc123",
  "timestamp": "2026-07-30T13:22:05.118Z",
  "data": {
    "from": "5511999999999",
    "chatId": "5511999999999",
    "body": "quero a segunda via do boleto",
    "type": "chat",
    "fromMe": false,
    "notifyName": "Maicon"
  }
}

Antes de processar, confira a assinatura. O header X-WPPAPI-Signature traz o HMAC-SHA256 em hexadecimal do corpo cru da requisição, usando o segredo da instância — e “cru” é literal: se você validar sobre o JSON re-serializado por Python, a ordem das chaves e os espaços mudam e a comparação falha.

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WPP_WEBHOOK_SECRET"].encode()
wpp = WPPAPI()

@app.post("/webhook")
def webhook():
    raw = request.get_data()  # bytes, antes de qualquer parse
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-WPPAPI-Signature", "")):
        abort(401)

    evt = request.get_json()
    if evt.get("event") != "received" or evt["data"].get("fromMe"):
        return "", 200

    phone = evt["data"]["from"]
    texto = (evt["data"].get("body") or "").lower()

    wpp.seen(phone)
    if "boleto" in texto:
        wpp.typing(phone)
        wpp.send_text(phone, "Já te mando a segunda via. Confirma o CPF do titular?")
        wpp.typing(phone, on=False)

    return "", 200

Use hmac.compare_digest, não ==: a comparação de tempo constante evita vazar o segredo por timing. E responda 200 rápido — o trabalho pesado vai para uma fila (Celery, RQ, o que você já usa). Se seu endpoint falhar, a plataforma tenta 4 vezes com backoff exponencial e, esgotadas as tentativas, guarda o evento na dead-letter queue do painel, com payload e último erro, para reprocessar depois. Nada é perdido silenciosamente, mas um handler lento vira timeout.

Além de received, você recebe delivery (confirmações de entrega/leitura), connected e disconnected — este último é o que dispara seu alerta quando alguém desconecta o aparelho.

Vindo da Z-API ou da Evolution API?

Se você já tem código Python apontando para outro gateway, a troca fica quase toda no _call acima. O que muda nos campos:

Rota de texto Campos
Z-API /send-text phone, message
Evolution API /message/sendText/{instance} number, text
WPPAPI .../send-text phone, message

A convenção de URL com instanceId + token é a mesma da Z-API, então quem vem de lá troca o host e o prefixo e segue. Da Evolution muda o par de campos e a autenticação sai do header apikey; o roteiro completo de migração tem o mapa de rotas inteiro.

O aviso honesto

API não oficial não tem fila de aprovação, mas também não tem rede de proteção: número novo disparando centenas de mensagens frias é banido, e nenhum provedor muda isso. Aqueça o número, respeite opt-in e mantenha o ritmo humano — o que vale a leitura das boas práticas anti-ban antes de subir volume.

Com o cliente e o receptor acima você tem o ciclo completo em Python: enviar, receber, responder e validar. Para testar de ponta a ponta com um número real, o trial de 3 dias não pede cartão — cole o código, aponte o webhook e mande a primeira mensagem em uns 10 minutos.

Coloque em prática

3 dias grátis, sem cartão — API, webhooks e Chatwoot inclusos.

Testar 3 dias grátis