Skip to content
Linq Copy agent prompt

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.

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.

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.

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 customer into to exactly as delivered, or send on POST /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.

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.

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.

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.

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.

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.

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.

Terminal window
# 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 1
trap 'rm -f "$file"' EXIT
status=$(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.pdf
else
printf 'Attachment download returned HTTP %s\n' "$status" >&2
exit 1
fi

This 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.