Sending messages
Choosing a channel
Every send names a channel, and the choice changes what is allowed rather than just how it is routed. These are WhatsApp's rules, not ours.
| Capability | whatsapp_cloud | whatsapp_web |
|---|---|---|
| Service window | 24 hours | None |
| Templates | Yes | No |
| Billing | Per template message | Per session |
| Max text length | 4,096 characters | 65,536 characters |
| Max media size | 16 MB | 64 MB |
| Throughput | 80/second | 1/second |
- Cloud API — Meta's official channel. Free-form messages only within 24 hours of the customer's last message; an approved template is the only way through a closed window.
- WhatsApp Web — a real account paired by QR code. No window and no templates, but one message a second, and sends come back
queuedrather thansentbecause a pacing queue decides when they actually go.
Read the live values for your own numbers from GET /api/channels — a Cloud number on a lower Meta tier reports a lower throughput than the table above.
The endpoint
/api/messages/sendAPI key (messages:send) or sessionBlocked while a subscription is lapsed. No plan feature gate — sending is not an add-on.
curl -X POST https://api.wpai.co.in/api/messages/send \
-H "X-API-Key: $WPAI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4471-shipped" \
-d '{ "to": "919876543210", "type": "text", "text": "Your order has shipped." }'Request body
| Field | Type | Rules | Description |
|---|---|---|---|
torequired | string | min 5 chars | Destination number. Normalised to digits, so +91 98765 43210 is fine. |
channel | 'whatsapp_cloud' | 'whatsapp_web' | default 'whatsapp_cloud' | Which channel carries it. Decides which of the fields below apply. Omit on a key bound to a channel and its own is used. |
type | 'text' | 'template' | 'image' | 'video' | 'document' | 'audio' | default 'text' | What kind of message. `template` is Cloud API only. |
wabaAccountId | string | Cloud API | Which connected number sends it. Optional when your key is bound to one, or when the account has only one connected — otherwise required. |
phoneNumberId | string | Cloud API | The same choice by Meta’s own id for the number, which you already hold. An alternative to `wabaAccountId`, not an addition. |
channelConnectionId | string | any channel | Which connection sends it, by the id shown on the Channels page. Same rule as above. |
text | string | max 4096 | The body. Required for `type: text`. |
mediaUrl | string (URL) | media types | A publicly reachable URL. Takes precedence over `base64`. |
base64 | string | media types, min 16 chars | Raw or data-URL encoded bytes. Stored first, then sent by link. |
filename | string | max 255 | Shown to the recipient for a document. |
caption | string | max 1024 | Text alongside media. Becomes the stored body of the message. |
templateId | string | type: template | An APPROVED template belonging to this organization. |
variableMapping | Record<string, string> | type: template | Fills the template’s {{1}}, {{2}} placeholders. See Template messages. |
headerMediaId | string | type: template | A Meta media_id for an image, video or document header, from `POST /api/campaigns/media`. Reusable for about 30 days. |
headerMediaUrl | string (URL) | type: template | A public http(s) URL Meta fetches for the header. No upload step. Ignored when `headerMediaId` is given. |
Which fields apply depends on the two above them
channel and type decide the rest. A field that does not apply is ignored rather than rejected — sending templateId with type: "text" does not error, it just sends the text. The one exception is header media: headerMediaId or headerMediaUrl on a template whose header is not an image, video or document is a 400, because silently dropping it would send a header the template cannot fill.Three success shapes
The channel and the deduplication path each return a different body. Branch on the presence of a field, not on the status code.
Cloud API — sent
{
"success": true,
"messageId": "68cf1a…", // our id for this message
"metaMessageId": "wamid.HBg…", // Meta's id, echoed by delivery webhooks
"status": "sent"
}WhatsApp Web — queued
{
"ok": true,
"channel": "whatsapp_web",
"status": "queued",
"messageId": "68cf1a…"
}No provider id yet, because it has not gone out. It arrives on the message.status webhook.
Either channel — already sent
{
"ok": true,
"channel": "whatsapp_cloud",
"status": "sent",
"messageId": "68cf1a…",
"deduped": true
}Note the 200 rather than 201 — nothing was created.
Retrying safely
Pass an Idempotency-Key header with a value that identifies the message in your own system. A repeat with the same key returns the original result instead of sending again.
- The key is scoped to your organization, so it only has to be unique to you.
- It is stored only on a successful send, so retrying after a network failure or a 5xx does the right thing.
- It is honoured on this endpoint and no other. Every other call in these docs will act on a retry, so guard those on your own side.
Quota and wallet
Two separate meters, and a send may hit either.
- Plan allowance — one message is reserved before dispatch, on both channels. Exhausted returns
402naming what is left and offering a message pack. Cloud API plans usually report an unlimited allowance, because they are metered by money instead. - Wallet — debited only on Cloud API template sends. Never on text, never on media, never on WhatsApp Web. Checked before, debited after.
A failed send costs nothing
When a send is refused
Errors are JSON with an error string, and sometimes a code to branch on. The message is written for a person and may be reworded; the code will not be.
{ "error": "Template is not APPROVED by Meta" }| Status | Means |
|---|---|
400 | The request is wrong, or the channel will not carry it — a closed 24-hour window is the common one. Also returned when your account has several channels connected and the request names none (`channel_ambiguous`); the message lists the ids to choose from. |
401 | No key, a malformed key, or a revoked one. |
402 | Out of message allowance, or too little wallet balance for a template send. |
403 | Your key is bound to a different channel than the one requested (`channel_binding_mismatch`). Omit the channel to use the key’s own. |
404 | The number, template or session you named is not on your account. |
409 | That channel cannot send right now — paused after failing deliveries (`channel_degraded`) or no longer connected (`channel_unavailable`). A send is never moved to another number on its own. |
429 | Rate limited. |
502 | Meta or the WhatsApp Web service rejected it. The message carries their words. |
503 | The WhatsApp Web service is unreachable. Always transient — retry. |
Validation errors here name no field
details array with the offending path. This one collapses every schema failure into a flat 400 reading Invalid request body parameters. If a send is rejected and you cannot see why, check the field table above.