Sending messages
Choose a send route, respect the customer window, and retry safely.
There are two ways to send. POST /v1/messages takes a
destination and lets Linq select the eligible line and existing chat.
POST /v1/chats/{chat}/messages sends within a chat you already
know. Both return the same accepted-message shape and support idempotency.
WhatsApp separates service messages, sent during the customer window, from template messages in marketing, utility, and authentication categories. See service messages and marketing, utility, and authentication templates for the channel rules. The Linq part schemas remain the authority for shapes accepted by this API.
Always make sends idempotent
Section titled “Always make sends idempotent”Set Idempotency-Key to a stable value for the logical send. If a timeout
leaves the response unknown, retry the same route with the same key and body.
Do not generate a new key for a transport retry, and do not reuse a key for
different content.
Choose content from the customer window
Section titled “Choose content from the customer window”The chat read exposes customer_window. While it is open, you may send
free-form parts such as text or media. Outside it, send one approved template
part. An out-of-window free-form request is refused with HTTP 422 and
error.code 2036; Linq does not
replace your content.
{ "to": "+15551234567", "parts": [{ "type": "text", "body": "How can we help?" }]}{ "to": "+15551234567", "parts": [ { "type": "template", "name": "order_update", "language": "en_US", "parameters": { "order_number": "A-1042" } } ]}The values above are illustrative. Read GET /v1/templates
and validate each send against the selected template’s send_schema. The part
schema served by GET /v1/parts/{type} defines
the common template fields. Validation rejects invalid content and returns JSON
Pointers to the most specific identifiable field.
Customers who use a username
Section titled “Customers who use a username”A customer can use a WhatsApp username and keep their phone number hidden.
For that customer, the chat’s customer is a username id rather than a phone
number. Events such as message.received identify the chat by chat_id; read
the chat with GET /v1/chats/{chat} to get its customer:
{ "to": "US.13491208655302741918", "parts": [{ "type": "text", "body": "Thanks, we're on it." }]}The id is a two-letter country code, a period, and up to 128 letters and
digits; a longer form such as US.ENT.… also exists. Treat every customer id
as opaque:
- store it as a string, and never parse it, validate it, or format it as a phone number;
- reply by copying
customerintotoexactly as delivered, or send onPOST /v1/chats/{chat}/messages, which needs no address.
Every message type works with a username id except one-time-code
authentication templates (copy code, one-tap, or zero-tap autofill), which can
be delivered only to a phone number. Such a send is refused with HTTP 422
and error.code 4011, and nothing
is sent; deliver the code another way. If the refusal can only be known after
acceptance, the message fails with recipient_phone_number_required instead.
Follow the outcome
Section titled “Follow the outcome”HTTP 202 means accepted, not delivered. Consume event signals, then read
GET /v1/chats/{chat}/events for the message’s current state.
Terminal outcomes are delivered, read, or failed. If no terminal evidence
arrives, keep the outcome unresolved rather than blindly resending.
Why a message failed
Section titled “Why a message failed”A message whose status is failed may carry a failure object beside it: a
code you can branch on and a message written for a person reading it. It
is present only when the channel’s refusal has a reason in the published
vocabulary, so a message that failed on the wire may carry no failure at all.
Its absence means “no published reason”, never “no reason”.
status stays the thing to branch on. Codes are added over time, so treat a
code you do not recognise exactly as you treat a message with no failure: the
message failed, terminally, and status already told you that. Never branch on
message — the wording is written for a person and is not stable.
The reason lives on the message itself, not on the event that announces it.
Read it from the message rows of GET /v1/chats/{chat}/events,
including the message a message.failed webhook
or stream event points you to; the event
carries only the chat id and its sequence range.
{ "id": "msg_example", "chat_id": "chat_example", "direction": "outbound", "status": "failed", "failure": { "code": "recipient_country_restricted", "message": "the business behind the sending number is not currently permitted to message customers in this recipient's country, so the message was not delivered and sending it again cannot succeed" }, "parts": [{ "type": "text", "body": "How can we help?" }], "created_at": "2026-09-16T14:02:11Z"}The ids and message above are illustrative. The published codes today:
| Code | Meaning | What to do |
|---|---|---|
sender_deregistered |
The number this message was going out from is no longer a registered sending number, and the message never left. Terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. | Reach this customer by starting a new conversation on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed. GET /v1/chats shows which number each conversation uses. |
quality_hold_expired |
This marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. Terminal: retrying the same message cannot succeed on that conversation. | Send marketing from another of your numbers with a template, or contact support about the sending line. |
recipient_country_restricted |
The business behind the sending number is not currently permitted to message customers in this recipient’s country, so the message was not delivered. Terminal for the message: sending it again cannot succeed while the restriction stands. It can arrive as an immediate failed or after the message was first reported sent; both mean the same thing. |
Most often the business has not yet completed business verification: complete it, and until then message customers in the sending number’s own country. If the business is already verified, review the messaging policy for the business’s category. There is no way to ask about the restriction ahead of a send. |
payment_method_required |
The business behind the sending number has no working payment method on its messaging account, so the message was not sent. Terminal for the message: sending it again cannot succeed until a payment method is added. It can arrive as an immediate failed or after the message was first reported sent; both mean the same thing. |
Add or update the payment method in the messaging account’s billing settings, then send again as a new message. Replies inside an open 24-hour customer window are not affected and keep being delivered, so a working free-form reply beside a failed template is expected, not a contradiction. |
recipient_phone_number_required |
This customer is known only by a username id, not a phone number, and this message can be delivered only to a phone number, so it was not sent. Terminal for the message: sending it again to this customer cannot succeed. It can arrive as an immediate failed or after the message was first reported sent; both mean the same thing. |
One-time-code authentication templates are the usual cause; deliver the code another way. Other messages, including templates without a one-time-code button, can still be sent to this customer. See customers who use a username. |
You are not billed for a message that failed with any of these codes.
Send media
Section titled “Send media”A media part names its bytes either by an upload_ref from
POST /v1/media or by a public https address in
source_url, which the API fetches once before accepting the send. The
attachments guide covers both
routes, the address policy, and the refusal codes.
React to a message
Section titled “React to a message”A reaction part carries an emoji and the message it belongs to. Address the
target with message_id — this API’s own Message.id, the value returned when
the message was accepted and shown in the transcript — not the channel’s
channel_message_id, which is inbound-only and refused on a send with
error.code 1006.
{ "parts": [{ "type": "reaction", "message_id": "msg_example", "emoji": "👍" }]}Send the same target with an empty emoji to remove your earlier reaction. An
absent emoji is rejected rather than read as a removal.
A reaction is a free-form part, so it follows the customer window like text. The target must also be a message this account can see in the chat you are sending to, and one the channel has acknowledged if this API sent it:
| Refusal | Meaning |
|---|---|
| 2049 | No message with that id is visible to this account. |
| 1040 | The message belongs to a different chat than the one you are sending to. |
| 2050 | This API accepted the message but the channel has not acknowledged it yet. |
All three are HTTP 422 pointered at /parts/0/message_id.
Two behaviours differ from other sends and are easy to get wrong.
A reaction ends at sent. The channel reports no delivered or read receipt
for one, so do not wait for a terminal outcome beyond sent the way you would
for a text. It can still end failed if the channel refuses it, and a test key
simulates delivered as it does for every send.
For 2050, poll the target’s
status until it reads sent — the same moment its channel_message_id
appears — and then resend. Do not wait on the target’s message.sent event:
that fires when this API accepts a send, not when the channel acknowledges it,
so it has usually already arrived when the refusal happens.
The channel will not deliver a reaction whose target is more than 30 days old,
deleted, missing from the thread, or itself a reaction. That is refused on the
wire after your send was accepted, so it surfaces as a failed message rather
than a 422.
Mark a chat read
Section titled “Mark a chat read”POST /v1/chats/{chat}/read marks the customer’s messages read, and with
{"typing": true} also shows a typing indicator. It targets the newest unread
inbound message the channel can mark read. HTTP 202 means the command was
accepted; the chat.read event names the message it targeted.
Unsupported messages are never targeted. Some things a customer can send,
such as a poll, reach you as an unsupported part, and the channel refuses to
mark those read. The command skips them and targets the newest supported
message instead. An unsupported message stays unread on the customer’s phone
even after a later message is marked read.
| Refusal | Meaning |
|---|---|
| 2035 | nothing_unread: the chat has no unread inbound message. |
| 2056 | nothing_readable: every unread inbound message is unsupported. |
Both are HTTP 409, and neither changes anything. If you mark a chat read
after every inbound message, treat both as “nothing to do” rather than a
failure.
Download incoming attachments
Section titled “Download incoming attachments”A fresh message.received webhook includes
data.message, so you can read its parts without fetching the message again.
Look for a media part with a url. For example, an incoming document:
{ "type": "media", "kind": "document", "filename": "invoice.pdf", "media_id": "example-inbound-media-id", "url": "https://whatsapp.messages.api.linqapp.com/v1/attachments/media_example/content"}Use the returned url with GET /v1/attachments/{attachment}/content.
It requires your account’s Bearer API key on every download; it is not a public
file link. Download from your server and keep the key out of browser code and
logs. Only attach credentials to the trusted WhatsApp API origin shown below,
and do not follow redirects.
# Use the document part's returned URL, not its media_id or upload_ref.ATTACHMENT_URL='https://whatsapp.messages.api.linqapp.com/v1/attachments/media_example/content'
file=$(mktemp) || exit 1trap 'rm -f "$file"' EXITstatus=$(curl --silent --show-error \ --proto '=https' --max-time 30 \ --header "Authorization: Bearer ${LINQ_WHATSAPP_API_KEY}" \ --output "$file" --write-out '%{http_code}' \ "$ATTACHMENT_URL") || exit 1
if [ "$status" = 200 ]; then mv "$file" invoice.pdfelse printf 'Attachment download returned HTTP %s\n' "$status" >&2 exit 1fiThis example makes one request and saves the original bytes on HTTP 200.
It intentionally omits --location, so credentials are not forwarded through a
redirect. In your application, accept only 200 as a successful download and
choose the local output filename yourself. See the
attachment reference for the
response contract and error handling.
| Response | Action |
|---|---|
409, code 2007 |
Capture is still pending. Wait at least Retry-After before retrying. Use a bounded policy, such as at most three attempts within 30 seconds; if the requested wait exceeds the remaining budget, stop and schedule a later attempt. |
410, code 2043 |
The attachment failed or expired. Stop retrying this download. |
404, code 2003 |
No downloadable attachment is visible to this key, including missing or pruned files. Check the URL and account credentials; do not retry blindly. |
Older events may contain only a chat and sequence range. Read
GET /v1/chats/{chat}/events when data.message is absent, and
download only parts that actually include a url. The URL is response-only; do
not include it when sending a message.