Messages
Read the inbound messages your numbers receive, and send messages of your own. Answer a conversation a contact started, or message a phone number directly and Sendveo starts the conversation for you.
The message object
{
"id": "msg_7Qk2",
"channelId": "chn_sales01",
"direction": "INBOUND",
"chatId": "chat_aa11",
"from": "+212670111222",
"to": null,
"senderName": "Khadija Alaoui",
"text": "Here is the model we agreed on.",
"type": "image",
"status": "RECEIVED",
"statusAt": null,
"failureReason": null,
"timestamp": "2026-09-09T14:02:11Z",
"attachments": [
{
"id": "att_5Hq1",
"kind": "IMAGE",
"mimeType": "image/jpeg",
"fileName": "modele.jpg",
"size": 184320,
"durationSeconds": null,
"width": 1080,
"height": 1440,
"voiceNote": false,
"url": "https://api.sendveo.com/v1/messages/msg_7Qk2/attachments/att_5Hq1"
}
],
"replyTo": { "id": "msg_7Qk1", "providerMessageId": "wamid.HB1" },
"location": null,
"contacts": null,
"linkPreview": null,
"reactions": [{ "emoji": "👍", "by": "+212670111222", "at": "2026-09-09T14:03:00Z" }],
"deletedAt": null,
"editedAt": null
}direction is INBOUND or OUTBOUND. from is the sender on inbound messages and null on outbound ones; to is the mirror image, carrying the recipient number on a message you sent to a phone number and null otherwise. status is SENT or FAILED on outbound messages; inbound messages are always RECEIVED.
Send a message
Every send names the number to send from in channelId (numberId is accepted as the same field), the message in text, and exactly one recipient: chatId to answer an existing conversation, or to for a phone number. The number must be CONNECTED, otherwise the request returns 409.
curl -X POST https://api.sendveo.com/v1/messages \
-H "X-API-KEY: sv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "channelId": "chn_sales01", "chatId": "chat_aa11", "text": "Yes - shipping tomorrow." }'{
"message": {
"id": "msg_8Rm5",
"channelId": "chn_sales01",
"direction": "OUTBOUND",
"chatId": "chat_aa11",
"to": null,
"text": "Yes - shipping tomorrow.",
"status": "SENT",
"timestamp": "2026-09-09T14:05:40Z"
}
}Send to a phone number
Use to with a phone number in E.164 format to message someone who has never written to you: Sendveo starts the conversation and returns its chatId, so replies come back in the same thread. +212661234567, 212661234567 and 00212661234567 are all accepted, with or without spaces, dashes or parentheses.
curl -X POST https://api.sendveo.com/v1/messages \
-H "X-API-KEY: sv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "channelId": "chn_sales01", "to": "+212661234567", "text": "Your order is ready." }'{
"message": {
"id": "msg_9Tn7",
"channelId": "chn_sales01",
"direction": "OUTBOUND",
"chatId": "chat_9Fp2",
"to": "+212661234567",
"text": "Your order is ready.",
"status": "SENT",
"timestamp": "2026-09-15T09:12:03Z"
}
}Sending to a number that already has a conversation continues that conversation instead of starting a second one, whether the contact wrote first or you did. You never end up with one person split across two threads.
A to that is not a usable phone number, both chatId and to together, or neither of them, all return 400 VALIDATION_FAILED naming the field in details, and nothing is sent. A national form with a leading zero such as 0661234567 is rejected: without a country code it is a guess.
Keep first-contact messaging modest: WhatsApp blocks numbers that message many people who never wrote first, and it is your own business line that gets blocked. Sendveo allows 20 brand-new conversations per number per hour and answers 429 OUTBOUND_LIMIT with a Retry-After past that. Replies are never counted.
Attachments
A message can carry up to 10 files. Send the bytes as multipart/form-data, or pass a public url in JSON and Sendveo fetches the file for you. Files and URLs cannot be mixed in one message. text is optional when the message carries a file: a photo with no caption is a real message.
In a multipart request the files go on attachments (repeat the field for several), and an audio file you want rendered as an inline waveform goes on voice_note instead. Any other file field name is 400.
curl -X POST https://api.sendveo.com/v1/messages \
-H "X-API-KEY: sv_live_your_key_here" \
-F channelId=chn_sales01 \
-F to=+212661234567 \
-F "text=Voici le devis" \
-F "attachments=@devis.pdf;type=application/pdf"{
"channelId": "chn_sales01",
"chatId": "chat_aa11",
"attachments": [
{
"url": "https://cdn.example.com/devis.pdf",
"mimeType": "application/pdf",
"filename": "devis.pdf"
}
]
}Limits
These are WhatsApp’s own limits, not ours: a file over them is refused by WhatsApp after the upload, so Sendveo refuses it at the edge and names the limit. A type outside the list is refused by name. An attachment error is 400 VALIDATION_FAILED with details[0].path = "attachments.N.…", so a caller sending five files learns which one was refused, and nothing reaches the messaging service.
| Kind | Media types | Cap |
|---|---|---|
| image | image/jpeg image/png image/gif image/webp | 5 MB |
| video | video/mp4 video/3gpp video/quicktime | 16 MB |
| audio | audio/mpeg audio/mp4 audio/aac audio/amr audio/ogg audio/wav | 16 MB |
| voice note | as audio, plus audio/opus audio/webm | 16 MB |
| document | application/pdf, the Office types, application/zip, application/json, text/plain, text/csv, text/vcard | 100 MB |
| sticker | image/webp | 500 KB |
Downloading an attachment
The endpoint returns the bytes, authenticated exactly like the rest of the API. It is a proxy, never a redirect. Sendveo stores no bytes: the file is fetched on demand, streamed and forgotten, so every download is a fresh call and an attachment is downloadable only while the messaging service keeps it. A file you sent cannot be downloaded back (409) - its metadata is on the message so your own interface can render what you sent.
Replies and reactions
Quote a message with replyTo: a Sendveo msg_… id, or the provider id of a message older than the connection, which is accepted and passed through. The message you get back carries replyTo with id: null in that second case.
A reaction is one native emoji. Prose is 400. Reacting again replaces your reaction, it never adds a second, and { "emoji": null } (or DELETE …/reactions, the same operation) withdraws it. reactions on a message are the reactions standing right now, not a log; the connected number’s own reaction appears with by: null.
curl -X POST https://api.sendveo.com/v1/messages/msg_8Rm5/reactions \
-H "X-API-KEY: sv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "emoji": "👍" }'Forward and delete
A forward answers 201 with the copy stored in the destination conversation, so your own inbox shows what you sent. { to: "+212661234567" } forwards into the conversation you already have with that number; a forward to a number with no conversation yet is 409, not a new conversation - turning a forward into a cold first contact would spend the number’s reputation without you asking.
curl -X POST https://api.sendveo.com/v1/messages/msg_8Rm5/forward \
-H "X-API-KEY: sv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "chatId": "chat_aa11" }'Deleting withdraws the message at WhatsApp. On Sendveo’s side the row is kept: text is emptied, attachments becomes [] and deletedAt is set, so a history still shows that something was said and withdrawn. Deleting an already-deleted message is 409.
React, forward, delete for everyone and mark-read need the messages:manage scope, which is deliberately NOT part of messages:send: acting on a message that already exists is a different authority from writing a new one. Keys issued before this scope existed do not carry it and answer 403 - issue a new key rather than editing the old one. POST /v1/chats/:chatId/typing needs only messages:send.
Locations and contacts
A location is stored structurally and returned as numbers in both directions. Out of range is 400 naming location.latitude or location.longitude. Your text is kept above it.
Every phone on a contact card is normalised by the same rule a recipient is, and a national form with a leading zero is refused. Inbound contact cards are parsed back into names and E.164 numbers for you. A location and contacts cannot ride on the same message.
{
"channelId": "chn_sales01",
"chatId": "chat_aa11",
"text": "On est ici",
"location": {
"latitude": 33.5731,
"longitude": -7.5898,
"name": "Casablanca",
"address": "Boulevard Zerktouni"
}
}
{
"channelId": "chn_sales01",
"chatId": "chat_aa11",
"contacts": [
{ "name": "Nadia Benali", "phones": ["+212661778899"], "organisation": "Atlas" }
]
}Link previews
linkPreview defaults to true: the first http(s) link in the text comes back as linkPreview: { url } so you do not have to re-parse the body. Sendveo never fetches the link - no title, no image, no outbound request per send.
What the flag does not do: the preview card a recipient sees is rendered by their WhatsApp client from the URL in the text, and no primitive exists to suppress it. linkPreview: false only stops Sendveo extracting the link; it does not suppress WhatsApp’s card, and the text is never rewritten to try.
Mark read and typing
Marking a conversation read and showing a typing indicator are both one call. The typing indicator is best effort by contract: it answers 200 { "sent": false } when the messaging service refuses it rather than failing your request - a courtesy should not fail a caller. What is still an error is your own mistake: an unknown conversation (404), a number that is not connected (409), a duration outside 1-25000 ms (400).
Delivery status
status on an outbound message walks SENT → DELIVERED → READ, and only ever moves forward: receipts arrive out of order, and a late DELIVERED never un-reads a message. FAILED wins over everything and nothing wins over it. statusAt is when it last changed, and failureReason is set only with FAILED.
List messages
Filter by number, direction, and time. Results are newest-first and capped at 200 per page.
curl "https://api.sendveo.com/v1/messages?channelId=chn_sales01&direction=INBOUND&limit=50" \
-H "X-API-KEY: sv_live_your_key_here"