Skip to main content

Sending messages

There is one sending endpoint: POST /messages. What changes between a text and a template is the type field in the body — not the URL.

typeWhat for
textFree text. Only inside the 24-hour window
templateApproved template. Opens a conversation at any time
testSame as template, defaulting to hello_world with clearer errors

They all take the same base fields:

FieldRequiredWhat it does
typeyestext, template or test.
toyesRecipient number in international format, digits only.
fromnoSender phone_number_id. Omitted, uses the oldest active number.
contextMessageIdnowamid of a received message — yours shows up quoting it.
sendAtnoISO 8601 instant to send later. See Scheduling.
delaySecondsnoSame thing, in seconds from now.

The 201 response carries the WAMID, the identifier that matches the message with the status events arriving later by webhook.

{
"data": {
"messages": [{ "id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF" }],
"from": "106540352242922",
"to": "5541987111527"
}
}
Free text needs the window open

Only templates work outside the 24 hours. See The 24-hour window.

Text​

curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Your order #1042 is out for delivery.",
"previewUrl": true
}'

Up to 4096 characters. previewUrl: true builds the preview card for the first link — Meta has to be able to reach the URL publicly to render it.

Template​

The only way to write outside the 24-hour window.

curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "template",
"to": "5541987111527",
"name": "order_confirmation",
"language": "en_US",
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "Mary" },
{ "type": "text", "text": "1042" }
]}
]
}'

The order of parameters follows the variable numbering ({{1}}, {{2}}). A template with no variables needs no components. Details in Templates.

Test​

To answer "is my number connected and sending?" without composing anything:

curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "type": "test", "to": "5541987111527" }'

The default is hello_world, which goes out with no variables.

hello_world only sends from a test number

From a production number Meta refuses with (#131058). It is a rule about the number's origin — no token or payload fixes it. In that case pass name, language and components of an approved template of yours; the API translates that error (type: "test_number_only") saying exactly this.

Scheduling​

The same sends take sendAt (an absolute instant) or delaySeconds (relative). Both exist because both cases exist: "Friday at 9am" is a date, "five minutes after the abandoned cart" is a delay — and converting one into the other is where timezone bugs come from.

In two hours
curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Your cart is still set aside 🙂",
"delaySeconds": 7200
}'

The answer is 202, not 201 — we accepted the request, but nothing exists at Meta yet:

{
"data": {
"id": "clz3k1p9x0000",
"status": "pending",
"from": "106540352242922",
"to": "5541987111527",
"sendAt": "2026-08-15T21:00:00.000Z",
"messageId": null
}
}

Listing, reading and cancelling:

curl https://api.joinotify.com/messages/scheduled?status=pending \
-H 'Authorization: Bearer sk_live_xxx'

curl -X DELETE https://api.joinotify.com/messages/scheduled/clz3k1p9x0000 \
-H 'Authorization: Bearer sk_live_xxx'

Cancelling holds even with seconds to spare: whoever delivers re-reads the record before sending. An already sent message answers 404 — there is nothing to cancel.

What gets re-checked at send time

Three things, none of them at scheduling time: the entitlement (a subscription that lapsed in between stops the send), the number (you may have disconnected the account) and the cancellation. A billing block is not retried: the message fails with the reason in failureReason and stays in the history.

Retrying without duplicating​

The connection dropped after Meta accepted the message, and your integration never saw the answer. Repeating the call, with nothing else, is a second WhatsApp message to the same person — a code sent twice, a promotion delivered twice.

The Idempotency-Key header solves that. Generate one value per operation — the order number and the event, for instance — and send the same value on every attempt:

curl -X POST https://api.joinotify.com/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-1042-confirmation' \
-d '{
"type": "text",
"to": "5541987111527",
"body": "Order 1042 confirmed ✅"
}'
SituationAnswer
First callThe normal answer
Same key, same bodyThe first answer, with Idempotent-Replayed: true — nothing is sent
Same key, different body422 idempotency_key_reused
Same key, first one still running409 idempotency_in_progress — wait a few seconds and repeat

What is worth knowing before relying on it:

  • The key is 8 to 128 characters: letters, digits, _, -, . and :.
  • It lasts 24 hours and belongs to the account, not to the API key: two integrations on the same account using the same value will meet.
  • Every final answer is stored, errors included. A 502 because Meta did not answer may have been delivered anyway, and the repeat returns the same 502 instead of risking a second send. To really try again, use a new key — knowing it may duplicate.
  • The body is compared as it arrived: always serialize the JSON the same way, or the same message with its fields in another order becomes "another body".
  • It covers scheduling too: repeating a sendAt with the same key does not create a second scheduled message.

The other message types​

Image, video, audio, document, sticker, location, contact, reaction, buttons, list, CTA and carousel have no high-level shortcut — and do not need one. The mirror, POST /v1/{phone_number_id}/messages, takes the full Cloud API payload, with any type Meta supports, using your sk_live_:

Image by URL
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "image",
"image": { "link": "https://example.com/catalogue.jpg", "caption": "July catalogue" }
}'
Quick reply buttons
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"to": "5541987111527",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Can we deliver tomorrow between 9am and midday?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "confirm", "title": "Yes please" } },
{ "type": "reply", "reply": { "id": "reschedule", "title": "Reschedule" } }
]}
}
}'
Mark as read
curl -X POST https://api.joinotify.com/v1/106540352242922/messages \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF"
}'

The shape of each type is the one in Meta's documentation, with no translation of ours in between — that is the point of the mirror: what holds there holds here.

When the same file goes to many people, upload it once with POST /v1/{phone_number_id}/media and reuse the id. With link, Meta downloads the file from your server on every send. See Media.

When the customer taps a button, a messages webhook arrives with type: "interactive":

{
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirm", "title": "Yes please" }
}
}

Handle the id, not the title. The title is interface text and can change; the id is the contract.

Following delivery​

No send confirms delivery — the 201 only says WhatsApp accepted the message. What happens next arrives by messages webhook, in the statuses array:

statusMeaning
sentLeft Meta's infrastructure
deliveredReached the device
readWas opened (if the customer left read receipts on)
failedWas not delivered — the reason comes in errors[]

Keep the WAMID from the 201 and match it against the status id. See Webhooks.

If you would rather not keep state on your side — or you are debugging a single send — read the last known status of a WAMID:

curl https://api.joinotify.com/messages/status/wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF \
-H 'Authorization: Bearer sk_live_xxx'
{
"data": {
"messageId": "wamid.HBgNNTU0MTk4NzExMTUyNxUCABEYEjhF",
"status": "failed",
"recipientId": "5541987111527",
"errors": [{ "code": 131047, "title": "Re-engagement message" }],
"updatedAt": "2026-07-29T01:51:43.030Z"
}
}

The answer comes from the same mirror that feeds Received events — it costs no Meta call and no send quota. status: null means no status has arrived yet (usually a matter of seconds) or that the account's webhooks are not being delivered.

Accepted is not delivered

Free text outside the 24-hour window is accepted (you get a 201 and a WAMID) and rejected seconds later, with code 131047 in the failed status. If your integration treats the 201 as final success, it will swear it sent a message that never arrived. See The 24-hour window.