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.
type | What for |
|---|---|
text | Free text. Only inside the 24-hour window |
template | Approved template. Opens a conversation at any time |
test | Same as template, defaulting to hello_world with clearer errors |
They all take the same base fields:
| Field | Required | What it does |
|---|---|---|
type | yes | text, template or test. |
to | yes | Recipient number in international format, digits only. |
from | no | Sender phone_number_id. Omitted, uses the oldest active number. |
contextMessageId | no | wamid of a received message — yours shows up quoting it. |
sendAt | no | ISO 8601 instant to send later. See Scheduling. |
delaySeconds | no | Same 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"
}
}
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 numberFrom 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.
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.
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 ✅"
}'
| Situation | Answer |
|---|---|
| First call | The normal answer |
| Same key, same body | The first answer, with Idempotent-Replayed: true — nothing is sent |
| Same key, different body | 422 idempotency_key_reused |
| Same key, first one still running | 409 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
502because Meta did not answer may have been delivered anyway, and the repeat returns the same502instead 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
sendAtwith 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_:
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" }
}'
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" } }
]}
}
}'
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:
status | Meaning |
|---|---|
sent | Left Meta's infrastructure |
delivered | Reached the device |
read | Was opened (if the customer left read receipts on) |
failed | Was 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.
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.