Messages
4 endpoints in this family.
List messages
GET/api/v1/messages
Every message of the client you act on, across conversations, newest first. Narrow it by number, contact, direction or status. To read one thread in order, use GET /conversations/{id}/messages.
Query parameters
channelIdstringOnly the messages of this number.
contactIdstringOnly the messages with this contact.
directionstringReceived or sent.
inboundoutboundstatusstringOnly messages in this state.
scheduledqueuedsendingsentdeliveredreadfailedlimitintegerHow many to return, 50 by default, 200 at most.
cursorstringThe
nextCursorof the previous page.
Response
A page of messages.
data.itemsMessage[]The messages, newest first.
data.nextCursorstringPass it as
cursorfor older messages;nullat the end.data.hasMorebooleanWhether older messages exist.
Refusals
- 422
invalid_requestA filter is not a valid id or value.
curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY"{ "data": { "items": [ {} ], "nextCursor": "…", "hasMore": true }}Send a message
POST/api/v1/messages
Sends a message from one of your WhatsApp numbers to one person. You name the number (channelId) and the person (to, or recipient); Nodium finds their conversation, or opens it, and sends. Everything goes through this one call, chosen by type: free text, an interactive form (quick-reply buttons, a list, a location request, a WhatsApp Flow), a location pin, a contact card (contacts), an approved template, a file (image, video, audio, document), or a reaction. Fields that do not belong to the type are refused.
The person. to is a phone number in international format (E.164, for example +33612345678). A contact who does not expose a number is written to by recipient, their WhatsApp user id (wa:FR.13491208655302741918, as found in GET /contacts). Give one of the two, not both. Someone Nodium has never seen becomes a contact and gets a conversation on the spot.
The 24-hour window is enforced here. On WhatsApp you may only send free-form messages (every type except template and reaction) within 24 hours of the person's last message. Outside it, and to anyone who has never written to you, you get window_closed (409) and must send a template instead. Interactive forms count as free text for Meta.
Files. Upload the file first with POST /media, which returns a mediaId valid for 7 days, then send it here with type set to its kind. The same mediaId can be sent to many people.
Idempotency. An idempotency key is required on every call: the idempotencyKey field, or the Idempotency-Key request header (same value rules: 8 to 200 characters). Give it in one place, or in both with the same value; two different values are refused with 422 invalid_request. Sending twice with the same key sends once and returns the first message with duplicate: true, even if the window has closed since. A retry must carry the same request (number, recipient, type and content): the same key with a different content is refused with 409 idempotency_key_conflict and nothing is sent. queue and scheduledAt are not part of that comparison. A message written before this check existed is returned as a duplicate without being compared.
Rate limit. Free-form messages are limited to 600 per minute per key and per client (429 with Retry-After); templates are not, they are governed by your plan's allowance.
The message is attributed to your key: the conversation shows the key's label, never a person who did nothing. With queue: true the call returns at once with the message queued (HTTP 202) and it leaves within a minute; follow it with the message.sent / message.failed webhooks. With scheduledAt the message is stored now with status scheduled (HTTP 202) and sent at that time instead; queue wins if you set both. A reaction is neither stored as a message nor queued: message is null in the answer.
A refused send is not an HTTP error. When the provider refuses the message, the call still answers 200 or 201 with message.status: "failed", message.error (the provider's reason) and message.errorInfo (what happened, what to do, who acts). Always test message.status, not only the HTTP code. The message.failed webhook carries the same.
Body
channelIdstringrequiredWhich number to speak from. See
GET /channels.tostringThe person's phone number in E.164 form. This or
recipientis required.recipientstringInstead of
to: the person's WhatsApp user id,wa:followed by the id, for a contact who does not expose a number.idempotencyKeystringYour own key, 8 to 200 characters, unique per message. Sending twice with the same key sends once. Required unless you send it as the
Idempotency-Keyheader instead.typestringrequiredWhat to send. Each type takes its own fields, below.
textinteractivelocationcontactstemplateimagevideoaudiodocumentreactiontextstringFor
text(required, 4,096 characters at most): what to say. Forinteractive: the body of the form whenform.bodyis absent.formobjectFor
interactive(required): up to 3 buttons, a list of up to 10 rows, a location request, one button that opens a web address (kind: "link"), or a WhatsApp Flow that opens on a button (kind: "flow").Show fields
kindstringrequiredWhich form.
buttonslistlocation_requestlinkflowbodystringThe text above the form, 1024 characters at most. Defaults to
text.headerstringA line above the body, 60 characters at most.
footerstringA line below the body, 60 characters at most.
buttonsobject[]For
buttons: 1 to 3. The reply you receive carries the chosenid.Show fields
idstringYour identifier, returned when tapped.
titlestringrequired20 characters at most.
buttonLabelstringFor
list,linkandflow: the label of the button, 20 characters at most (required forlinkandflow).urlstringFor
link(required): the address the button opens,https://…, 2,000 characters at most.flowIdstringFor
flow(required): the id of the Flow at Meta (metaFlowIdinGET /flows), 100 characters at most.flowTokenstringFor
flow: your own token, 256 characters at most, returned as is with the answer (GET /flow-responses?flowToken=) to tie it to this send.screenstringFor
flow: the screen to open on, when it is not the first.dataobjectFor
flow: values passed to the first screen.actionstringFor a dynamic
flow: open it with anINITcall to its data endpoint, which returns the first screen and its data. Not withscreenordata.data_exchangemodestringFor
flow:draftto try a Flow that is not published yet. Defaultpublished.publisheddraftsectionsobject[]For
list: sections of rows, 10 rows in all.Show fields
titlestringThe section title.
rowsobject[]Its rows.
Show fields
idstringYour identifier, returned when chosen.
titlestringrequired24 characters at most.
descriptionstring72 characters at most.
locationobjectFor
location(required): a location pin.Show fields
latitudenumberrequiredlongitudenumberrequirednamestringaddressstring
contactsobject[]For
contacts(required): contact cards. Meta limits to 1 per message.Show fields
nameobjectrequiredphonesarrayemailsarray
templateNamestringFor
template: the template, by its name. This ortemplateIdis required.templateLanguagestringFor
template, withtemplateName: its language code. Defaultfr.templateIdstringFor
template: the template, by its id, instead oftemplateName.variablesobjectFor
template: the values filling its placeholders: by number for{{1}}({ "1": "Karim" }), by name for{{client_name}}({ "client_name": "Karim" }).headerMediaIdstringFor a
templatewith an image, video or document header: the file to show in it, amediaIdfromPOST /mediaof the same kind. Omitted, the template's own header sample is sent.buttonsobject[]For
template: the values of the template's dynamic buttons:{ index, type, … }—indexis the button's position in the template (from 0),typeisurl(withtext, the link suffix filling{{1}}),quick_reply(withpayload),copy_code(withcode, 15 characters at most) orflow(optionalflowToken, returned with the answer, anddata, the values of the first screen). A link button with{{1}}and a copy-code button must be given. A template with a Flow button opens the form outside the 24-hour window.mediaIdstringFor
image,video,audio,document(required): the id returned byPOST /media. Its kind must matchtype.captionstringFor a file: a caption shown under it, 1,024 characters at most. WhatsApp shows none under audio.
voicebooleanFor
audio: true to send it as a voice note, with its waveform (the file must beaudio/ogg).messageIdstringFor
reaction(required): the Nodium id of the message to react to, which must belong to this person's conversation on this channel.emojistringFor
reaction(required): the emoji, or""to remove the last reaction you sent.replyToIdstringQuote an earlier message of this person's conversation (its Nodium id). Not for a
reaction.queuebooleanReturn at once and send within a minute (HTTP 202). Default false.
scheduledAtstringISO 8601 date-time. The message is stored now and sent at that time. Must be at least 1 minute in the future.
Response
The message as stored, with its delivery status, and the id of the conversation it belongs to — new or reused.
data.conversationIdstringThe conversation the message belongs to — opened by this call, or reused.
data.messageMessagedata.duplicatebooleantruewhen thisidempotencyKeywas already used: the message returned is the first one, nothing was sent again.
Refusals
- 404
unknown_channel`channelId` is not one of this client's numbers. - 409
channel_inactiveThis number is switched off: switch it on before sending from it. - 409
window_closedMore than 24 hours since the person last wrote, or they never did. Send an approved `template` instead. - 409
contact_blockedThis contact is blocked: nothing is sent to them. - 409
consent_missingA marketing template to a contact who opted out. - 409
template_not_approvedThe template is not approved by Meta at this client yet; `data.status` says where it stands there (`not_deployed`, `submitted`, `rejected`…). - 402
quota_exceededYour plan's monthly allowance is used up; `data.resetsAt` says when sends resume. - 404
media_not_found`mediaId` is unknown, belongs to another client, or has expired (7 days): upload the file again. - 404
message_not_foundThe message to react to, or to quote, is not in this person's conversation. - 422
invalid_type`type` is missing or not one of the listed values. - 422
invalid_fieldA field was given that does not belong to this `type`. - 422
recipient_requiredNeither `to` nor `recipient` was given. - 422
recipient_invalid`to` is not a number in international format, `recipient` is not a `wa:…` id, or both were given. - 422
no_recipientThis contact has no address to write to: neither a phone number nor a WhatsApp user id (`wa:…`). - 422
text_too_longThe text is over 4,096 characters, the WhatsApp limit. Nothing is stored; `limit` says the maximum. - 422
caption_too_longThe caption is over 1,024 characters, the WhatsApp limit. - 422
caption_unsupportedWhatsApp shows no caption under audio — send the text separately. - 422
media_type_mismatchThe file behind `mediaId` is not of the kind named by `type`. - 422
idempotency_key_requiredThe idempotency key (`idempotencyKey` field or `Idempotency-Key` header) is missing or outside 8-200 characters. - 422
invalid_requestAmong others: the `Idempotency-Key` header and the `idempotencyKey` field are both given and differ; `detail` says which. - 409
idempotency_key_conflictThat key already served a request with different content (number, recipient, type, text, template values…). Retry with the exact same request, or use a new key; nothing is sent. - 422
invalid_formThe form breaks a WhatsApp limit; `detail` says which. - 422
invalid_locationThe location is missing or its coordinates are out of range. - 422
invalid_contactsThe contact cards are missing, empty, or over the limit of 1. - 422
template_holes_unfilledA placeholder has no value; `data.issues` lists them. - 422
template_header_invalid`headerMediaId` was given for a template without a media header, or the template's header sample is missing. - 422
template_buttons_invalidA button value does not match the template's buttons; `detail` says which. - 422
scheduled_at_invalid`scheduledAt` is not a valid ISO date, or is less than a minute away. - 422
queue_not_supportedA reaction cannot be quoted, queued or scheduled. - 429
rate_limitedMore than 30 free-form messages in a minute for this key and client; wait `Retry-After` seconds.
curl -X POST https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "…", "to": "+33612345678", "recipient": "wa:FR.13491208655302741918", "type": "text", "templateLanguage": "fr" }'{ "data": { "conversationId": "…", "message": {}, "duplicate": true }}Get one message
GET/api/v1/messages/{id}
Returns one message, sent or received, as it stands now. Use it to follow a message after sending it without listing its conversation: status moves from queued or sending to sent, delivered, read, or failed (the send call itself can answer 200 or 201 with status: "failed"). When it failed with a provider code, errorInfo says what happened, what to do and who acts. The webhooks (message.sent, message.delivered, message.read, message.failed) tell you the same without polling.
Path parameters
idstringThe message id, as returned when you sent it or listed its conversation.
Response
The message, with its current delivery status and, when it failed, `errorInfo`.
data.idstringThe message id.
data.conversationIdstringIts conversation.
data.channelTypestringThe kind of channel.
whatsappdata.directionstringinboundfrom the contact,outboundto them.inboundoutbounddata.senderstringWho speaks: the contact, your software (
api, through a key), a person in the console (user), or Nodium (system).contactapiusersystemdata.senderNamestringThe person's name, or your key's label.
data.apiKeyIdstringThe key that sent it, when it went through the API.
data.typestringtext,template,interactive,image,audio,document…data.textContentstringIts text. For a template, the rendered text.
data.mediaUrlstringWhere to download its file (
GET /messages/{id}/media), ornull.data.statusstringDelivery status.
queued: waiting to leave (queue: true).queuedsendingsentdeliveredreadfaileddata.providerMessageIdstringThe id at the messaging provider (WhatsApp
wamid), ornull.data.errorstringWhy it failed, as the provider said, or
null.data.errorInfoobjectThe failure explained, when
statusisfailedand the provider gave a code; otherwisenull. A send can answer HTTP 200 or 201 withstatus: "failed": teststatus, then read this.Show fields
codeintegerThe provider's error code (Meta's, for example
131047).titlestringWhat happened, in a sentence.
actionstringWhat to do about it. When
actorisclient, written to be passed on to your client as is.actorstringWho acts:
you(your code or console),client(the owner of the number),nodium, orwait(the refusal is temporary: nobody needs to act).youclientnodiumwait
data.formobjectThe buttons or list sent with it, or
null.data.replyobjectFor a tapped button or chosen list row:
{ id, title }. Otherwisenull.data.locationobjectA location the contact sent:
{ latitude, longitude, name, address }, ornull.data.originstringphonewhen the message was written from the WhatsApp Business app of a coexistence number, otherwisenull.data.importedbooleanTrue for a message taken from the phone's chat history (coexistence), not received live.
data.createdAtstringISO 8601.
Refusals
- 404
message_not_foundThe id is unknown, malformed, or belongs to another client or account.
curl https://nodium.io/api/v1/messages/<id> \ -H "Authorization: Bearer $NODIUM_KEY"{ "data": { "id": "…", "conversationId": "…", "channelType": "whatsapp", "direction": "inbound", "sender": "contact", "senderName": "…", "apiKeyId": "…", "type": "…", "textContent": "…", "mediaUrl": "…", "status": "queued", "providerMessageId": "…", "error": "…", "errorInfo": { "code": 0, "title": "…", "action": "…", "actor": "you" }, "form": {}, "reply": {}, "location": {}, "origin": "…", "imported": true, "createdAt": "…" }}Download the file attached to a message
GET/api/v1/messages/{id}/media
Returns the file itself. Nodium keeps its own copy: Meta deletes received media after seven days, so this stays available afterwards.
Path parameters
idstringThe message id.
Response
The file, with its content type.
curl https://nodium.io/api/v1/messages/<id>/media \ -H "Authorization: Bearer $NODIUM_KEY"Returns the file itself, not wrapped: its type is in Content-Type.