Appearance
Send a message
Send a manual message from the authenticated user into a conversation. The message is persisted, delivered through the conversation's connected channel (Meta / Waha / Unipile), and broadcast to any subscribed socket clients.
You can send text, attachments, or both — at least one is required.
POST/api/user/{conversation}/messages
Required scope:
messages:sendPath parameters
integer
Request body (JSON)
curl -X POST "https://loopx-console.test/api/user/89328/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"message":"Hi Ali, following up on your pricing question — happy to jump on a quick call this week."}'<?php
use GuzzleHttp\Client;
$client = new Client();
$response = $client->request('POST', 'https://loopx-console.test/api/user/89328/messages', [
'headers' => [
'Authorization' => 'Bearer $TOKEN',
'Accept' => 'application/json',
],
'json' => [
'message' => 'Hi Ali, following up on your pricing question — happy to jump on a quick call this week.'
]
]);
$data = json_decode($response->getBody(), true);const res = await fetch('https://loopx-console.test/api/user/89328/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer $TOKEN',
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"message": "Hi Ali, following up on your pricing question — happy to jump on a quick call this week."
})
});
const data = await res.json();Example response
{
"status": true,
"code": 200,
"validation": [],
"message": "Message Sent",
"data": {
"_id": "68e0f4a3c2b1a9f0d3e5a7b2",
"role": "assistant",
"status": "sent_to_user",
"message": "Hi Ali, following up on your pricing question — happy to jump on a quick call this week.",
"cost_for_user": 0,
"external_chat_id": "120248819452900116",
"conversation_id": 89328,
"opportunity_id": 98314,
"contact_id": 44016,
"channel_id": 512,
"stage_id": 189,
"pipeline_id": 17,
"call_type": "chat",
"sender_id": 731,
"files": [],
"created_at": "2026-07-15T10:22:41.000000Z",
"updated_at": "2026-07-15T10:22:43.000000Z"
}
}The runnable widget above sends JSON only
Attachments require a multipart/form-data request — use the curl / PHP / Node examples in the Sending attachments section below.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
conversation | integer | yes | Conversation id — must belong to your workspace (or a workspace you manage) |
Request body
Provide message, files, or both. Sending neither returns a 422.
| Field | Type | Description |
|---|---|---|
message | string | null | Message text. Max 10,000 characters. |
files | file[] | null | Up to 10 files, each ≤ 25 MB. Requires multipart/form-data. |
Sending attachments
Attachments must be uploaded as multipart/form-data — one form field per file, repeating the files[] key. message becomes a regular form field alongside them (and stays optional as long as at least one file is present).
Accepted content: any file type. The server infers a bucket from the MIME type — image/* → image, video/* → video, audio/* → audio, application/* → document, everything else → file. Uploaded files are stored on private S3 and served through signed URLs; you never need to host them yourself.
curl
bash
curl -X POST "https://api.genudo.ai/api/user/89328/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-F "message=Here are the specs you asked for" \
-F "files[]=@./pricing.pdf" \
-F "files[]=@./product-shot.png"PHP (Guzzle)
php
<?php
use GuzzleHttp\Client;
$client = new Client();
$response = $client->request('POST', 'https://api.genudo.ai/api/user/89328/messages', [
'headers' => [
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
],
'multipart' => [
['name' => 'message', 'contents' => 'Here are the specs you asked for'],
['name' => 'files[]', 'contents' => fopen('./pricing.pdf', 'r'), 'filename' => 'pricing.pdf'],
['name' => 'files[]', 'contents' => fopen('./product-shot.png', 'r'), 'filename' => 'product-shot.png'],
],
]);Node.js
js
import fs from 'node:fs'
const form = new FormData()
form.append('message', 'Here are the specs you asked for')
form.append('files[]', new Blob([fs.readFileSync('./pricing.pdf')]), 'pricing.pdf')
form.append('files[]', new Blob([fs.readFileSync('./product-shot.png')]), 'product-shot.png')
const res = await fetch('https://api.genudo.ai/api/user/89328/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
// Do NOT set Content-Type — fetch sets the multipart boundary for you.
},
body: form,
})Response shape with attachments
Every uploaded file becomes an entry under data.files:
json
{
"status": true,
"code": 200,
"message": "Message Sent",
"data": {
"_id": "68e0f4a3c2b1a9f0d3e5a7b2",
"message": "Here are the specs you asked for",
"status": "sent_to_user",
"files": [
{
"path": "media/512/68e0f4a3c2b1a9f0d3e5a7b2.pdf",
"mimetype": "application/pdf",
"type": "document",
"size": 184320,
"name": "pricing.pdf"
},
{
"path": "media/512/68e0f4a3c2b1a9f0d3e5a7b2_1.png",
"mimetype": "image/png",
"type": "image",
"size": 42117,
"name": "product-shot.png"
}
]
}
}Response
The full persisted message record (Mongo document) is returned. Notable fields:
| Field | Type | Description |
|---|---|---|
_id | string | Mongo message id |
role | string | Always assistant for messages sent through this endpoint |
status | enum | sent_to_user on success, failed if delivery through the channel raised (see error field), created_manually briefly during processing |
message | string | null | The text you sent |
files | object[] | Uploaded attachments — path (private S3 key), mimetype, type (image / video / audio / document / file), size, name |
external_chat_id | string | The thread id on the origin channel (Meta thread id, WhatsApp phone, etc.) |
conversation_id, opportunity_id, contact_id, channel_id, stage_id, pipeline_id | integer | Relational context resolved from the conversation |
sender_id | integer | The authenticated user id (or the acted-as user when using super-user impersonation) |
Side effects
conversations.last_message_atandconversations.last_message_contentare updated (text falls back to[Attachment]when only files are sent).- A
new_messageevent is fired on theconversation.{id}socket channel — subscribed socket clients receive the new message in real time. - Delivery to the underlying provider is synchronous — the request only returns after the message has been handed off to Meta / Waha / Unipile.
Errors
| Code | When |
|---|---|
403 | Token missing messages:send scope, or the conversation belongs to a different workspace |
404 | Conversation not found |
422 | Neither message nor files provided, message over 10k chars, a file over 25 MB, or more than 10 files |
500 | The channel provider (Meta / Waha / Unipile) rejected the message — the persisted record's status becomes failed and its error field contains the provider error |