WhatsApp pela zap-api¶
O zap-api é o gateway de WhatsApp da casa. O SDK ships o cliente inteiro
dele — 28 schemas e 27 operações — gerado da especificação OpenAPI e
commitado, então você importa e usa, sem rodar codegen no seu serviço.
import asyncio
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
SendTextRequest,
ZapClient,
)
async def main() -> None:
"""Enviar uma mensagem de texto."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.send_text(
body=SendTextRequest(
to="5511999999999", text="seu pedido saiu para entrega"
),
idempotency_key="4f1c9a2e-...",
)
print(accepted.id, accepted.status, accepted.deduped)
asyncio.run(main())
A autenticação é o header x-api-key, e só ele. Coloque-o uma vez como
default do HTTPClient — não há fallback por query string, e o
idempotency_key não autentica nada.
Enviar não é entregar¶
Esta é a parte que muda como você escreve o serviço. Um envio responde
202 com a linha que foi enfileirada, não com a confirmação de que o
WhatsApp recebeu:
import asyncio
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
AcceptedResponseStatus,
SendTextRequest,
ZapClient,
)
async def main() -> None:
"""Ler o status que o 202 devolve."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.send_text(
body=SendTextRequest(to="5511999999999", text="oi"),
)
assert accepted.status == AcceptedResponseStatus.QUEUED
asyncio.run(main())
O status caminha queued → sending → sent → delivered → read, ou
failed. Essas transições chegam no webhook de status do gateway.
id e status são nuláveis, e só num caso
Um gateway rodando sem persistência (DATABASE_URL não setada)
envia inline em vez de enfileirar, e responde o mesmo 202 com id e
status nulos — não existe linha de outbox para nomear nem para
reportar estado. É a única situação em que eles vêm nulos.
O webhook é modelado à mão
A especificação descreve o webhook em prosa e não declara um bloco
webhooks nem callbacks, então não há o que gerar dela — medido em
2026-09-06 contra o documento que este pacote gerou: 27 operações, zero
callbacks, webhooks ausente. O receiver é portado do código do
gateway: veja Receber mensagens do WhatsApp (zap-api).
Retry precisa da chave de idempotência¶
Como o envio é assíncrono, um 202 perdido deixa você sem saber se a
mensagem foi enfileirada. Reenviar às cegas manda duas.
Passe um idempotency_key novo por mensagem, e reutilize o mesmo ao
repetir aquela mensagem:
import asyncio
from uuid import uuid4
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
SendTextRequest,
ZapClient,
)
async def main() -> None:
"""Repetir um envio sem mandar a mensagem duas vezes."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
async with http:
client: ZapClient = ZapClient(http)
key: str = str(uuid4())
accepted = await client.send_text(
body=SendTextRequest(to="5511999999999", text="oi"),
idempotency_key=key,
)
again = await client.send_text(
body=SendTextRequest(to="5511999999999", text="oi"),
idempotency_key=key,
)
assert again.deduped is True
assert again.id == accepted.id
asyncio.run(main())
A segunda chamada é o retry: a rede caiu e você não viu a resposta.
Repetindo com a mesma chave, o gateway devolve a linha original
(deduped: true) e não envia nada de novo.
A chave fica presa à mensagem, não à tentativa
Ela é escopada ao seu consumidor e continua reivindicada enquanto a
linha existir. Reutilizar uma chave antiga para uma mensagem
nova responde deduped: true e não envia nada — o silêncio
parece sucesso. Gere uma chave por mensagem.
O que mais dá para fazer¶
| Método | O que faz |
|---|---|
send_text, send_image, send_video, send_audio, send_document |
Enfileiram uma mensagem. Todos aceitam reply_to |
upload_image, upload_video, upload_audio, upload_document |
Enviam o arquivo no corpo, em multipart/form-data |
send_image_base64, send_video_base64, send_audio_base64, send_document_base64 |
Enviam o arquivo embutido em base64, em JSON |
react |
Enfileira uma reação de emoji a uma mensagem |
set_typing, mark_read |
Sinais de presença. Respondem 204 |
check_number |
Se o número existe no WhatsApp, e o JID dele |
get_history |
Últimas mensagens de um chat (limit de 1 a 200) |
get_message_media |
Os bytes da mídia de uma mensagem armazenada |
start_session, get_session_status, get_session_qr, get_session_qr_image, disconnect_session |
Ciclo de vida da conexão |
health, ready, metrics |
Sondas e exposição Prometheus |
O to aceita duas grafias, e a diferença é real: os send_* exigem
dígitos (^\d{10,15}$), enquanto react, mark_read e set_typing
aceitam também o JID completo que o webhook de entrada entrega
(^\d{10,15}(@s\.whatsapp\.net)?$).
Três formas de mandar mídia¶
A mesma imagem chega ao WhatsApp por três caminhos, e a escolha é sobre onde o arquivo está:
| Rota | Quando usar |
|---|---|
send_image(body=...) |
O arquivo já tem URL pública; o gateway baixa |
upload_image(file=...) |
O arquivo só existe no disco de quem chama |
send_image_base64(body=...) |
Você já tem os bytes e quer um corpo JSON só |
As de upload_* são multipart/form-data, então o cliente as expõe com
os campos do formulário achatados — o arquivo em files, os escalares em
data:
import asyncio
from pathlib import Path
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import ZapClient
async def main() -> None:
"""Subir uma imagem do disco."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.upload_image(
file=Path("nota-fiscal.png").read_bytes(),
to="5511999999999",
caption="sua nota fiscal",
)
print(accepted.id, accepted.status)
asyncio.run(main())
O arquivo é bytes, e isso é por causa do retry
O HTTPClient repete a requisição em erro de rede e em 5xx, e um
retry reenvia os mesmos argumentos. Um objeto de arquivo já foi
consumido na primeira tentativa, então a segunda subiria um corpo
truncado — que o servidor não tem como distinguir de um arquivo curto.
Por isso o part é bytes.
Passando um stream direto ao HTTPClient, ele não repete: uma
tentativa só, e a resposta dela é o que você recebe. É a troca certa
entre não repetir e repetir errado.
O limite é MEDIA_MAX_BYTES
16MB por default, aplicado enquanto o corpo trafega — um upload grande
demais é cortado no meio do voo, não bufferizado inteiro para ser
recusado depois. O cliente vê 413.
Corpo que não é JSON chega como bytes¶
Três operações respondem um corpo de sucesso que não é JSON:
get_message_media (application/octet-stream), get_session_qr_image
(image/png) e metrics (text/plain). O cliente as tipa -> bytes e
entrega o corpo sem decodificar — a especificação não carrega charset,
e adivinhar um corrompe em silêncio.
import asyncio
from pathlib import Path
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import ZapClient
async def main() -> None:
"""Salvar a mídia de uma mensagem recebida."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
async with http:
client: ZapClient = ZapClient(http)
media: bytes = await client.get_message_media("3EB0C767D26B8E3C1A2B")
Path("recebido.bin").write_bytes(media)
scrape: bytes = await client.metrics()
print(scrape.decode("utf-8"))
asyncio.run(main())
A mídia não é rebaixável
O gateway baixa a mídia enquanto a mensagem ainda está em memória, e o
WhatsApp não a serve de novo. Uma mensagem cujo download falhou ou
estourou MEDIA_MAX_BYTES fica armazenada com tipo e sem arquivo, e
responde 404 aqui. Este endpoint é o único caminho para esses bytes —
busque-os quando o webhook de entrada avisar, não depois.
health e ready continuam -> None
Estas duas não são o caso acima: a especificação as declara sem content, então não há corpo declarado para tipar. Só que elas respondem JSON de verdade — medido em 2026-09-06 contra um gateway rodando:
$ curl -s http://127.0.0.1:3000/health
{"status":"ok","session":"disconnected","ready":false,
"reconnectAttempts":0,"queue":{"queued":0,"oldestQueuedSeconds":null}}
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ready
503
O /ready devolve o mesmo objeto sem o status. Nada no repositório
reproduz isto: o gateway é externo, e não há fixture nem cassette
aqui — o comando acima é a evidência, e ele precisa de um gateway no
ar. Declarar o schema no gateway faz make zap-regen passar a expô-lo,
e aí passa a ser verificável offline como o resto.
Regenerar¶
O cliente é gerado de vendor/zap-openapi.yaml e commitado. Editar
schemas.py ou client.py à mão é revertido na próxima regeneração — e
tests/integrations/messaging/zap/test_generated_drift.py falha antes
disso.
make zap-fetch # relê o documento do gateway (precisa dele no ar)
make zap-regen # regenera a partir do vendorizado, offline
O zap-fetch lê de ZAP_OPENAPI_URL, ou de http://127.0.0.1:3000 por
default. Diferente do OpenPix e do Mercado Pago, não existe URL pública
canônica para esta especificação — ela é nossa, e é servida de onde o
gateway estiver. Por isso o arquivo vendorizado é a autoridade, e o
SPEC_SHA256 registra de quais bytes este checkout gerou.
Recapitulando¶
ZapClientsobreHTTPClient, comx-api-keycomo header default.- Envio responde
202e uma linha enfileirada — não uma entrega. - Uma
idempotency_keypor mensagem, reutilizada só em retry da mesma. - Corpo não-JSON chega como
bytes; a mídia só existe uma vez. - O webhook (entrada e status) se recebe com
make_zap_webhook_dependency: Receber mensagens do WhatsApp (zap-api). make zap-regené quem edita o código gerado, nunca você.