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 — which makes it 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.
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. |
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.
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.