Sending
Send a message
An approved template to one person, or a file to somebody already talking to you. The call returns as soon as we have accepted it, and the delivery happens behind it.
The request
curl -X POST https://sendrixbackend.exebee.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4471-shipped" \
-d '{
"to": "919833663235",
"template": {
"name": "order_shipped",
"language": "en",
"variables": { "1": "Asha", "2": "BK-4471" }
}
}'HTTP/1.1 202 Accepted
{
"id": "msg_c16cead6-8385-481f-9e2e-e146d51f2738",
"object": "message",
"to": "919833663235",
"status": "queued",
"template_id": "tpl_cb9182c4-6e22-4095-bd26-a61c9f8d7fba",
"wa_message_id": null,
"error": null,
"created_at": "2026-08-28T15:03:25.424Z",
"sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null
}Requires messages:send. A 202means we have taken it and accounted for it: one credit, or one message off your plan’s allowance. It does not mean WhatsApp has it yet.
Sending a file
There are two ways, and which one you need depends entirely on whether the person has messaged you recently.
On a template, to anybody
A template whose header is an image, video or document carries the file with it, and a template reaches anybody. This is what an order confirmation with the product photo, or a shipping note with the label attached, actually is.
curl -X POST https://sendrixbackend.exebee.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4471-invoice" \
-d '{
"to": "919833663235",
"template": {
"name": "order_invoice",
"variables": { "1": "Asha", "2": "BK-4471" },
"header_media": {
"link": "https://yourshop.example/invoices/4471.pdf",
"filename": "invoice-4471.pdf"
}
}
}'requires_media_header on the template listing tells you which of your templates need this. Send one without it and you get 400 header_media_required rather than a message that fails at WhatsApp after we have already accepted it.
On its own, inside the 24 hour window
A bare file, with no template around it. It only reaches somebody who has messaged you in the last 24 hours. That is WhatsApp’s rule, not ours, and it makes this a reply, not a way to start a conversation.
curl -X POST https://sendrixbackend.exebee.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "919833663235",
"media": {
"type": "image",
"link": "https://yourshop.example/receipts/4471.jpg",
"caption": "Your receipt"
}
}'| Field | Required | Notes |
|---|---|---|
media.type | yes | image, video, audio or document. |
media.link | one of these two | A public URL. WhatsApp fetches it at send time, so it has to be reachable from their network: not behind a login, and not a signed URL that expires in seconds. |
media.id | one of these two | An id from POST /v1/media. Use this when the file is not public, or when the same file goes to many people. |
media.caption | no | A line under the file. Not available on audio, which WhatsApp gives no caption. |
media.filename | no | Documents only. What the file is called when it arrives. |
A bare file costs nothing. WhatsApp stopped charging for service messages in July 2025, so this is free, exactly like the same attachment sent from your inbox. A template always costs a credit, media header or not.
Templates with a WhatsApp Flow
A template can carry a WhatsApp Flowbutton: a form that opens inside WhatsApp, for a booking, a lead, a photo upload. These send like any other template. The button’s one parameter, a flow_token, is filled in for you, and flow_buttons on the template tells you a template has one.
Pass template.flowwhen you want to choose that token, or fill the form’s first screen:
curl -X POST https://sendrixbackend.exebee.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-8812-form" \
-d '{
"to": "919833663235",
"template": {
"name": "book_a_visit",
"flow": {
"token": "lead-8812",
"data": { "name": "Asha", "city": "Pune" }
}
}
}'| Field | Notes |
|---|---|
template.flow.token | Comes back with the submission, so use an id of your own (a lead, an order) to match it. Printable ASCII, up to 200 characters. Omit it and we make one that names this message. |
template.flow.data | Values for the first screen, for a flow whose first screen takes data. A JSON object, under 10,000 characters. |
When somebody submits the form you get a flow.completed webhook, and the answers are readable at Form submissions. Passing template.flow to a template with no flow button is refused with 400 flow_not_on_template.
Why it does not wait
Sending inline would mean your checkout holding a connection open across a call to Meta that can take thirty seconds, with nothing to retry it when that fails. Queuing means your message gets the same retries, pacing and rate-limit backoff that campaigns get, and your request comes back in milliseconds.
In practice it leaves for Meta well under a second later. You learn the outcome from webhooks, or by reading the message back.
Fields
| Field | Required | Notes |
|---|---|---|
to | yes | The recipient in international form. Spaces, dashes and a leading + are all fine; we normalise it. An impossible number is rejected before it costs anything. |
template.name | yes | Must be approved and sendable. See Templates. |
template.language | no | Needed only when the same template name exists in several languages. |
template.variables | if the template has any | Exactly the keys in that template’s required_keys. No more, no fewer. |
template.header_media | if the template has a media header | The image, video or document that fills the header. { "link": "https://…" } for a public URL we hand to WhatsApp, or { "id": "…" } for something from POST /v1/media. Exactly one. Add filename on a document. |
template.flow | no | For a template with a WhatsApp Flow button: token and data. See Templates with a WhatsApp Flow above. |
sender_id | no | Which number to send from. Omit it and your default is used. |
contact_name | no | Saved against the contact if we have not seen this number before. Shows up in your inbox. |
Retrying safely
Send an Idempotency-Key header on every send. It is the difference between a timeout costing you nothing and a customer getting two one-time codes.
- Same key, same body: you get the original response back, with an
Idempotent-Replay: trueheader. Nothing is sent twice and nothing is charged twice. - Same key, different body:
409 idempotency_key_reused. Your key is not as unique as your code thinks. - Same key while the first is still running:
409 idempotency_in_progress. Retry in a moment.
Keys are remembered for 24 hours and are scoped to your workspace. Use something naturally unique to the event, like order-4471-shipped, rather than a random value your retry cannot reproduce.
Reading a message back
curl https://sendrixbackend.exebee.com/v1/messages/msg_c16cead6-8385-481f-9e2e-e146d51f2738 \
-H "Authorization: Bearer sk_live_..."Requires messages:read. Useful while you are building, before your webhook exists. Once it does, prefer the webhook: polling every message costs you rate limit for information we would have pushed.
A message that carried a WhatsApp Flow also lists the forms submitted from it, as flow_responses: ids and times. Read the answers with GET /v1/flow_responses/{id}.
Statuses
| Status | Means |
|---|---|
queued | Accepted by us, not yet handed to Meta. |
sending | On its way to Meta right now. |
sent | Meta accepted it. |
delivered | It reached the handset. |
read | The recipient opened it. |
failed | It will not arrive. error.code is Meta’s own code. |
Treat this list as able to grow. A status you do not recognise should be handled as “still in flight” rather than crashing.
What a send costs
On credits: one, reserved when we accept the message and settled when Meta says what happened. A message that never arrives has its credit returned automatically, the same as a campaign send. If your balance cannot cover it you get 402 insufficient_credits and nothing is queued.
On the all-inclusive plan: nothing is charged, and the send comes off the plan’s allowance for the period instead. Credits are never used, so once the allowance is gone you get 402 plan_allowance_used_up rather than a fallback to the balance.
With no plan at all: 402 plan_required. Reads keep working, so an integration can still list its senders and templates and work out what happened. See Plans and credits.