WhatsApp through zap-api¶
zap-api is the in-house WhatsApp gateway. The SDK ships its whole client
— 28 schemas and 27 operations — generated from the OpenAPI specification
and checked in, so you import it and use it without running codegen in your
own service.
import asyncio
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
SendTextRequest,
ZapClient,
)
async def main() -> None:
"""Send one text message."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<your key>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.send_text(
body=SendTextRequest(
to="5511999999999", text="your order is out for delivery"
),
idempotency_key="4f1c9a2e-...",
)
print(accepted.id, accepted.status, accepted.deduped)
asyncio.run(main())
Authentication is the x-api-key header, and only that. Set it once as an
HTTPClient default — there is no query-string fallback, and
idempotency_key authenticates nothing.
Sending is not delivering¶
This is the part that changes how you write the service. A send answers
202 with the row that was enqueued, not with a confirmation that
WhatsApp got it:
import asyncio
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
AcceptedResponseStatus,
SendTextRequest,
ZapClient,
)
async def main() -> None:
"""Read the status the 202 carries."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<your key>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.send_text(
body=SendTextRequest(to="5511999999999", text="hi"),
)
assert accepted.status == AcceptedResponseStatus.QUEUED
asyncio.run(main())
status walks queued → sending → sent → delivered → read, or failed.
Those transitions arrive on the gateway's status webhook.
id and status are nullable, in exactly one case
A gateway running without persistence (DATABASE_URL unset) sends
inline instead of enqueueing, and answers the same 202 with id and
status null — there is no outbox row to name or to report a status
for. It is the only case where they come back null.
The webhook is modelled by hand
The specification describes the webhook in prose and declares neither a
webhooks block nor callbacks, so there is nothing to generate from
it — measured on 2026-09-06 against the document this package generated
from: 27 operations, zero callbacks, no webhooks. The receiver is
ported from the gateway's code instead: see
Receiving WhatsApp messages (zap-api).
Retry needs the idempotency key¶
Because sending is asynchronous, a lost 202 leaves you unsure whether the
message was enqueued. Blind resending sends two.
Pass a fresh idempotency_key per message, and reuse the same one when
you repeat that message:
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:
"""Repeat a send without sending the message twice."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<your key>"},
)
async with http:
client: ZapClient = ZapClient(http)
key: str = str(uuid4())
accepted = await client.send_text(
body=SendTextRequest(to="5511999999999", text="hi"),
idempotency_key=key,
)
again = await client.send_text(
body=SendTextRequest(to="5511999999999", text="hi"),
idempotency_key=key,
)
assert again.deduped is True
assert again.id == accepted.id
asyncio.run(main())
The second call is the retry: the network dropped and you never saw the
response. Repeating with the same key returns the original row
(deduped: true) and sends nothing new.
The key belongs to the message, not to the attempt
It is scoped to your consumer and stays claimed for as long as the
row exists. Reusing an old key for a new message answers
deduped: true and sends nothing — the silence looks like success.
Generate one key per message.
What else you can do¶
| Method | What it does |
|---|---|
send_text, send_image, send_video, send_audio, send_document |
Enqueue a message. All accept reply_to |
upload_image, upload_video, upload_audio, upload_document |
Send the file in the body, as multipart/form-data |
send_image_base64, send_video_base64, send_audio_base64, send_document_base64 |
Send the file inline as base64, in JSON |
react |
Enqueues an emoji reaction to a message |
set_typing, mark_read |
Presence signals. They answer 204 |
check_number |
Whether the number exists on WhatsApp, and its JID |
get_history |
A chat's latest messages (limit from 1 to 200) |
get_message_media |
The media bytes of a stored message |
start_session, get_session_status, get_session_qr, get_session_qr_image, disconnect_session |
Connection lifecycle |
health, ready, metrics |
Probes and the Prometheus exposition |
to accepts two spellings, and the difference is real: the send_*
operations require digits (^\d{10,15}$), while react, mark_read and
set_typing also accept the full JID the inbound webhook delivers
(^\d{10,15}(@s\.whatsapp\.net)?$).
Three ways to send media¶
The same image reaches WhatsApp by three routes, and the choice is about where the file lives:
| Route | When to use it |
|---|---|
send_image(body=...) |
The file already has a public URL; the gateway fetches it |
upload_image(file=...) |
The file exists only on the caller's disk |
send_image_base64(body=...) |
You already hold the bytes and want a single JSON body |
The upload_* routes are multipart/form-data, so the client exposes
them with the form's fields flattened — the file on files, the scalars
on 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:
"""Upload an image from disk."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<your key>"},
)
async with http:
client: ZapClient = ZapClient(http)
accepted = await client.upload_image(
file=Path("invoice.png").read_bytes(),
to="5511999999999",
caption="your invoice",
)
print(accepted.id, accepted.status)
asyncio.run(main())
The file is bytes, and the retry is why
HTTPClient repeats the request on network errors and on 5xx, and a
retry re-sends the same arguments. A file object is already consumed by
the first attempt, so the second would upload a truncated body — which
the server cannot tell from a short file. That is why the part is
bytes.
Hand HTTPClient a stream directly and it does not retry: one
attempt, and its response is what you get. That is the right trade
between not retrying and retrying wrong.
The cap is MEDIA_MAX_BYTES
16MB by default, enforced while the body streams — an oversized upload
is cut off mid-flight rather than buffered in full and refused
afterwards. The client sees 413.
A body that is not JSON arrives as bytes¶
Three operations answer a success body that is not JSON:
get_message_media (application/octet-stream), get_session_qr_image
(image/png) and metrics (text/plain). The client types them
-> bytes and hands the body over undecoded — the specification
carries no charset, and guessing one corrupts silently.
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:
"""Save the media of an inbound message."""
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<your key>"},
)
async with http:
client: ZapClient = ZapClient(http)
media: bytes = await client.get_message_media("3EB0C767D26B8E3C1A2B")
Path("received.bin").write_bytes(media)
scrape: bytes = await client.metrics()
print(scrape.decode("utf-8"))
asyncio.run(main())
The media cannot be fetched again
The gateway downloads media while the message is still in memory, and
WhatsApp will not serve it again. A message whose download failed
or exceeded MEDIA_MAX_BYTES is stored with a media type and no file,
and answers 404 here. This endpoint is the only route to those bytes
— fetch them when the inbound webhook tells you, not later.
health and ready are still -> None
These two are not the case above: the specification declares both with no content, so there is no declared body to type. They do answer real JSON, though — measured on 2026-09-06 against a running gateway:
$ 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
/ready answers the same object without status. Nothing in this
repository reproduces it: the gateway is external, and there is no
fixture or cassette here — the command above is the evidence, and it
needs a gateway running. Declaring the schema on the gateway makes
make zap-regen expose it, and it becomes verifiable offline like the
rest.
Regenerating¶
The client is generated from vendor/zap-openapi.yaml and checked in.
Hand-editing schemas.py or client.py is reverted by the next
regeneration — and
tests/integrations/messaging/zap/test_generated_drift.py fails before
that.
make zap-fetch # re-reads the document from the gateway (needs it up)
make zap-regen # regenerates from the vendored file, offline
zap-fetch reads ZAP_OPENAPI_URL, or http://127.0.0.1:3000 by default.
Unlike OpenPix and Mercado Pago, there is no canonical public URL for
this specification — it is ours, and it is served from wherever the gateway
runs. That is why the vendored file is the authority, and why
SPEC_SHA256 records which bytes this checkout generated from.
Recap¶
ZapClientoverHTTPClient, withx-api-keyas a default header.- A send answers
202and an enqueued row — not a delivery. - One
idempotency_keyper message, reused only to retry that message. - A non-JSON body arrives as
bytes; media exists exactly once. - The webhook (inbound and status) is received with
make_zap_webhook_dependency: Receiving WhatsApp messages (zap-api). make zap-regenis what edits the generated code, never you.