Sending messages¶
POST /agent/v1/messages
An agent can send text, image, audio, video, document and sticker. Reactions are receive-only.
result = client.send_text("Hello! How can I help you?")
result.message_id # "wamid.HBg..." — record this; it identifies the message in receipts
result.wa_id # "user:50972923564215"
Every send returns a SendResult.
The recipient¶
An agent has exactly one recipient — its creator — so to is optional on every send:
client.send_text("Hello") # to the creator
client.send_text(message.sender, "Hello") # explicit, same thing
client.send_image(file="cat.jpg") # every send_* takes to=None
When you leave it out, the client uses client.recipient — the identifier it last saw on a poll or a send. A client that has never polled finds it by itself before the first send, with one GET /updates?offset=0&timeout=0 (auto_discover=True). A client that has polled never does that, since a second poll would replace a running loop; it raises ValidationError if nothing has revealed the recipient yet. client.discover_recipient() does the lookup explicitly.
Passing to is optional. When you do, it must be user:<id> — a WhatsApp user, and the agent's creator. An agent:<id>, a bare phone number or any other shape is rejected (HTTP 400, code 131009); the library catches these before the request leaves. Take it from a recent inbound message and pass it back unchanged — never hardcode it.
Text¶
| Argument | |
|---|---|
body |
up to 4096 characters |
preview_url |
render a preview for the first URL in the body; it must begin with http:// or https:// |
reply_to |
wamid of a message to quote |
Media¶
Each media send takes either a media_id from an earlier upload, or a file that the library uploads first:
# upload once, send many times
media_id = client.upload_media("logo.png")
client.send_image(media_id=media_id, caption="our logo")
# or in one call
client.send_image(file="cat.jpg", caption="look at this")
client.send_image(file=open("cat.jpg", "rb"))
client.send_image(file=photo_bytes, mime_type="image/jpeg")
| Method | Caption | Filename |
|---|---|---|
send_image |
✅ ≤1024 chars | — |
send_video |
✅ ≤1024 chars | — |
send_document |
✅ ≤1024 chars | ✅ |
send_audio |
— | — |
send_sticker |
— | — |
Documents keep a filename, including its extension:
client.send_document(file="invoice.pdf") # filename: invoice.pdf
client.send_document(media_id=mid, filename="invoice.pdf") # set it yourself
Formats, size caps and codec constraints are in Limits and formats. Uploading is covered in Files and media.
Quoting a message¶
client.send_text("On it", reply_to=message.id)
client.reply(message, "On it") # same thing, to the message's sender
ctx.reply("On it", quote=True) # from an Agent handler
Use a wamid from this conversation — an id from GET /updates, or one you got back from a send. A wamid the platform never issued is a 400 (code 131009). A wamid from another agent's chat is accepted, not rejected, so don't count on the API to catch a mix-up.
Sending a type the library doesn't know¶
send_message takes any payload, so a type the platform adds later needs no library upgrade:
client.send_message(None, "text", {"body": "Hello", "preview_url": True}) # None: the known recipient
Ordering¶
Do not issue concurrent sends to the same recipient. The order in which they arrive is not guaranteed. Send sequentially, or serialize per recipient — see Recipes.
What can go wrong¶
| Situation | Result |
|---|---|
| Body over 4096, or caption over 1024 | ValidationError locally; 400 + code 100 from the API |
| Payload object missing for the declared type | 400 |
| Media id missing, unknown or expired | 400 + code 131009 |
to is malformed, or an agent: id |
400 + code 131009 |
type is reaction |
ValidationError locally; 400 from the API |
| Recipient is not the agent's creator | 403 + code 131005 |
to is user:<id> with an id that is not a WhatsApp user (e.g. an agent's id) |
500 + code 2 — not retried by default |
| More than 12 sends per minute | 429 — paced for you by default |
| Not accepted for delivery | 503 + code 131016 — retried automatically |
Fields in payload objects other than the one being sent are still validated, so a stray over-long caption in an unused object can fail the request.
Retrying a send¶
This is the one place where "just retry" is wrong. What to do depends on what came back:
| Response | Meaning | Action |
|---|---|---|
2xx |
Sent. | Record message_id. Never retry. |
4xx |
Not sent. | Fix the request or the token. A repeat fails the same way. |
429 |
Not sent. | Back off and retry — the library does this. |
503 code 131016 |
Not accepted for delivery, so not sent. | Back off and retry — the library does this. |
500, connection reset, read timeout |
Unknown whether it was sent. | Decide in advance. A retry may deliver twice. |
The library therefore does not retry a send after a 500 or a dropped connection. Opt in when a duplicate is cheaper than a loss:
A long read timeout is the cheaper protection — it keeps an ordinary delay from turning into an unknown outcome. Client(timeout=...) sets it.