Send a message
Sends a message. Acceptance is durable and asynchronous: a 202 means
the message is journaled and will be delivered in order; delivery states
arrive on the chat’s event sequence (and your webhooks/streams), never
on this call. The chat is created on first send to a new customer.
The 202 body’s status is accepted — the message exists and is
ours to deliver, and nothing more is claimed by it. Watch
GET /v1/streams/events for message.sent, message.delivered,
message.read and message.failed to learn what became of it. The
stream does not replay, so connect it first; anything you miss is read
back from GET /v1/chats/{chat}/events, where the same message also
carries its latest status.
Every gate runs before anything is written, so a refusal means nothing
was stored and nothing will be sent — always safe to correct and
retry. A request that TIMES OUT is the one case that is not safe to
retry without Idempotency-Key. Supply one unique key per logical
message to make a timed-out acceptance safe to repeat.
An operational safety stop or the absence of an active sending number
refuses with retryable HTTP 503 (sending_paused or
no_sending_number) and a 4xxx error.code; nothing is journaled.
A stop on the number a conversation already uses answers the same way,
with sending_paused — and so does a stop on a number you named with
from. It is ours to lift, and you are never told to move a customer to
another number, or that your request was malformed, over one.
from_ambiguous joins that
class in the rare case where a from you supplied matches more than one
of your numbers: it names no single conversation, so nothing is chosen
for you and an operator resolves it.
If the service cannot verify the safety state, it likewise refuses with
HTTP 503 sending_guard_unavailable (a 3xxx error.code, 3009)
instead of assuming sending is allowed. These operator-dependent states
carry no Retry-After because there is no honest recovery time to
publish.
Which number the message leaves by. Omit from and this service
picks: a customer you have messaged before keeps the number they
already see, and a new customer is placed on the best available one.
The accepted message reports the number as from, and reports how it
was chosen in from_selection.
A conversation NEVER moves to another number. If the number a
conversation uses is no longer a registered sending number of yours, the
request is refused with HTTP 422
pinned_line_unsendable (a 4xxx error.code, 4004) and NOTHING
changes — no message is sent, no
conversation is created, and the next send to that customer resolves the
same way. error.context.pinned_from names the stuck number and
error.context.available_from names the one that could serve instead;
that number is a separate conversation with its own 24-hour window, so
you open it yourself by sending an approved template with from set to
it. This is deliberate: nothing is ever sent on your behalf that you did
not compose, and nothing is billed that you did not request.
Spend limits apply to every send, including templates. A projected
reservation beyond a daily or monthly cap is refused with HTTP 429
spend_limit_exceeded (a 4xxx error.code, 4006); Retry-After names
the exact UTC day/month reset and nothing is journaled. If the rate
catalog has no applicable rate, the service fails closed with HTTP 503
rate_not_configured (a 3xxx error.code, 3020); only operator action
can clear it, so contact support rather than retrying.
If the catalog lookup, cap, or counter is temporarily unavailable, the
retryable refusal is HTTP 503 entitlement_unavailable (a 3xxx
error.code, 3004). Crossing 80% does not change acceptance: the
response remains 202 and carries X-Spend-Warning once for each cap
window crossed by that request. Values are comma-separated
brand_day, brand_month, or number_day; the warning is emitted once
per window, not on every later send. A daily ceiling stops template
traffic while service-window replies remain available; the monthly
account ceiling is hard and stops every send at the ceiling.
Separately from spend, the channel limits how many DISTINCT customers a
new conversation may be started with in a moving 24-hour period. A
template send that would exceed it is refused with HTTP 429
tier_quota_exceeded (a 4xxx error.code, 4008) and nothing is
journaled; replies inside an open
customer conversation are unaffected and never count against it, and a
second template to a customer already counted in the period costs
nothing. Retry-After on this refusal is a MINIMUM REQUESTED WAIT
computed from the period as it stood when the request was refused: retry
no earlier than that, and a retry then may still be refused. It is not
an expiry — the earliest free place can move later as you send to
customers already counted, and can open earlier when messages fail to
deliver. You may not be able to produce this refusal in an integration
test, in sandbox or in live: it is raised only where the channel has
reported a limit for the account concerned, so an environment where no
limit has been reported will never return it. Handle it from the response
code rather than from having seen it.
Body ParametersJSON
The customer to message: the exact opaque customer address this API
reports as the chat’s customer field (read with GET /v1/chats or
GET /v1/chats/{chat}), copied verbatim and never parsed, built or
reformatted. It is
usually the customer’s phone number in the channel’s digits-only form
(country code first, no + and no punctuation, such as 15550101234);
a customer who uses a username and has not shared a phone number is
identified by a username identifier instead (such as
US.13491208655302741918). Treat both the same way: copy the value.
The value is matched literally and never normalized, because the address is the channel’s to define, not ours to rewrite. It is the conversation’s identity, so two spellings of the same number are two different conversations, each with its own window and its own event sequence.
Concretely: the value is compared verbatim to the chat’s customer
field, the customer id exactly as that customer’s inbound messages
arrived. Events identify the chat by chat_id, not by customer, so read
the chat’s customer with GET /v1/chats/{chat}. A leading +
makes it a different value — +12059367930 does not find the
conversation delivered as 12059367930 — and such a send is refused
with outside_customer_window (2036) as if that customer had never
written. When this account does hold an open window under the value
without the +, the refusal’s details[0].message says so and names
both values, so the fix is to resend with the delivered value or on
POST /v1/chats/{chat}/messages, which needs no address at all.
Numbers NOT to send from, in E.164 — a filter on a choice this service is making for you.
It applies only when from is omitted; supplying both is refused. It
affects this request alone: nothing is stored, and no operator setting
changes.
It cannot move a conversation. If it names the number an existing
conversation already uses, or removes every number that could have
served, the send is refused with exclude_leaves_no_sender rather than
quietly sent from a number you asked to avoid.
The number to send FROM, in E.164 (e.g. +15550001111). Together with
to it names the exact conversation: one customer, one of your numbers.
Omit it and this service chooses, exactly as it always has — a conversation you have already had continues on its own number, and a new customer is placed on the best available one. Omitting the field and sending an empty string are DIFFERENT requests: an empty string is refused rather than treated as “choose for me”, because a client that computed nothing did not mean to hand the choice back.
Supply it to reply on a specific number — in particular the second conversation a customer starts by messaging another of your numbers, which is its own chat with its own window.
The number must be one of your registered sending numbers:
from_not_available (422, a 1xxx error.code, 1013) if it is not one
of your numbers at all, and from_not_sendable (422, a 4xxx
error.code, 4003) if it is yours but is no longer registered to
send. A number that is only PAUSED is neither of those — it answers
retryable HTTP 503 sending_paused (a 4xxx error.code, 4005), the
same as every other paused send, because that one is ours to lift and
worth retrying shortly. It is
matched on its digits, so +15550001111 and 1 (555) 000-1111 are the
same number — unlike to, which is the channel’s own address form and
is matched literally.
Naming a number never moves an existing conversation onto it. It does
decide where a BRAND-NEW one lives: if you have never exchanged a
message with this customer, the number you name becomes the one they
keep, and a later omitted-from send to them continues on it. That is
the same rule as ever — a customer’s number is fixed by the first
contact, whoever chose it — and it is why naming a number for a customer
you have already messaged changes nothing about their existing
conversation.
Our opaque Message.id for an earlier message in this same chat. When
present, this send is displayed as a visual reply to that message.
Unknown ids return reply_target_not_found; an id from another chat
returns reply_target_wrong_chat; and an outbound message whose channel
acknowledgement has not arrived returns reply_target_not_ready.
Nothing is accepted when any of those refusals is returned.
The accepted message reads back with the same reply_to_message_id,
and an inbound message that quotes one of yours carries the quoted
message’s id in the same field.
RESERVED — scheduled sends are not yet available. The field is declared
so its later arrival is purely additive; until then ANY value here,
including a past timestamp, is rejected loudly with HTTP 422
send_at_not_available (a 1xxx error.code, 1039). Omit the field
entirely.
Send a message
curl https://whatsapp.messages.api.linqapp.com/v1/messages \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \
-d '{
"parts": [
{
"body": "body",
"type": "text"
}
],
"to": "to"
}'{
"id": "id",
"chat_id": "chat_id",
"created_at": "2019-12-27T18:11:19.117Z",
"direction": "inbound",
"parts": [
{
"body": "body",
"type": "text",
"preview_url": true
}
],
"status": "status",
"channel_message_id": "channel_message_id",
"failure": {
"code": "code",
"message": "message"
},
"from": "from",
"from_selection": {
"reason": "reason"
},
"reply_to_message_id": "reply_to_message_id",
"zero_retention": true
}Returns Examples
{
"id": "id",
"chat_id": "chat_id",
"created_at": "2019-12-27T18:11:19.117Z",
"direction": "inbound",
"parts": [
{
"body": "body",
"type": "text",
"preview_url": true
}
],
"status": "status",
"channel_message_id": "channel_message_id",
"failure": {
"code": "code",
"message": "message"
},
"from": "from",
"from_selection": {
"reason": "reason"
},
"reply_to_message_id": "reply_to_message_id",
"zero_retention": true
}