POST
/
api
/
v1
/
chat
/
conversations
/
:id
/
messages
Send a message
curl --request POST \
  --url https://api.example.com/api/v1/chat/conversations/:id/messages
import requests

url = "https://api.example.com/api/v1/chat/conversations/:id/messages"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/chat/conversations/:id/messages', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/chat/conversations/:id/messages",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/chat/conversations/:id/messages"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/chat/conversations/:id/messages")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/chat/conversations/:id/messages")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

Inserts a new message into the conversation and bumps the per-participant unread counters. The request must include a client-generated clientMessageId so retries (e.g. on a flaky network) are idempotent: the server’s unique index on (senderId, clientMessageId) collapses duplicates into a single row. This endpoint is the REST mirror of the chat:message:send socket event. Both transports converge on the same messageService.sendMessage, so validation, dedup, badging, and (Phase 5) push fan-out are identical regardless of which one the client picks. See Socket events for the WebSocket variant and the REST-vs-WS parity discussion. The recipient’s UI is updated either by the socket broadcast (chat:message:new) or by the recipient calling List messages — push notifications are deferred to Phase 5.

Authentication

Bearer <accessToken> required. requireOnboarded middleware applied. Additional service-level gates (checked in this order):
  1. Conversation exists → otherwise 404 NOT_FOUND.
  2. Caller is a participant → otherwise 403 FORBIDDEN.
  3. Conversation status === "ACTIVE" → otherwise 403 CONVERSATION_BLOCKED.
  4. No block exists between sender and the other participant (in either direction) → otherwise 403 CONVERSATION_BLOCKED.

Path parameters

NameTypeRequiredNotesExample
idstringyes24-char hex ObjectId of the conversation.66400a8f1c2b4d5e6f7a9000

Query parameters

None.

Request body

FieldTypeRequiredAllowed valuesExample
clientMessageIdstringyes1..64 chars. Any opaque token the client uses to dedup retries — a ULID or UUID is recommended. Must be unique per (sender, message); reusing it for a different message returns the original message row with deduped: true.cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2
messageTypeenumnotext (default), image, video, file. Determines whether text or media is required.image
textstringconditionalFor text messages: required, 1..4000 chars (whitespace-only rejected). For media messages: optional caption, 0..4000 chars.Hi! Loved your kitchen.
mediaobjectconditionalRequired when messageType is image/video/file. See Media object. Must be null/absent for text messages.

Media object

Upload the file first via the presign flow with fileType: "CHAT_MEDIA", PUT the bytes to the returned URL, then post the resulting CDN url here.
FieldTypeRequiredNotes
urlstring (URL)yesPublic CDN url of the uploaded file.
thumbnailUrlstring (URL) | nullnoPoster/thumbnail (image variant or video frame).
fileNamestring | nullnoOriginal filename — shown on file bubbles.
mimeTypestring | nullnoe.g. image/jpeg, video/mp4, application/pdf.
sizeBytesinteger | nullnoFile size — shown on file bubbles.
width / heightinteger | nullnoPixel dimensions for image/video (lets the client size the bubble before load).
durationSecnumber | nullnoVideo duration in seconds.

Example payload — text

{
  "clientMessageId": "cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
  "text": "Hi! Loved your kitchen."
}

Example payload — image (with optional caption)

{
  "clientMessageId": "cm_01HZQ7K3M4N5P6Q7R8S9T0V1W3",
  "messageType": "image",
  "text": "Here's the living room",
  "media": {
    "url": "https://cdn.swappr.co.uk/chat-media/<userId>/<uuid>.jpg",
    "thumbnailUrl": "https://cdn.swappr.co.uk/chat-media/<userId>/<uuid>_t.jpg",
    "fileName": "living-room.jpg",
    "mimeType": "image/jpeg",
    "sizeBytes": 482311,
    "width": 1600,
    "height": 1200
  }
}
A captionless media message (text: "") shows a typed placeholder in the inbox preview — 📷 Photo, 🎥 Video, or 📎 File. The chat:message:new socket broadcast carries the full message (including media) so recipients render the attachment live.

Response

201 Created — fresh insert

A new messages row was persisted. The conversation’s lastMessage, lastMessageAt, and the recipient’s unreadCount were bumped. If a WebSocket client is subscribed to conversation:<id>, a chat:message:new event has been broadcast.
{
  "message": {
    "id": "66400a8f1c2b4d5e6f7aa000",
    "conversationId": "66400a8f1c2b4d5e6f7a9000",
    "senderId": "66400a8f1c2b4d5e6f7a8b01",
    "clientMessageId": "cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
    "text": "Hi! Loved your kitchen.",
    "deliveredTo": [],
    "readBy": [],
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-05-22T14:35:12.001Z",
    "updatedAt": "2026-05-22T14:35:12.001Z"
  },
  "deduped": false
}

200 OK — dedup (idempotent retry)

The same clientMessageId was previously used. The server returns the original message row unchanged. No new row was inserted, no broadcast was emitted, no unread counter was incremented a second time.
{
  "message": {
    "id": "66400a8f1c2b4d5e6f7aa000",
    "conversationId": "66400a8f1c2b4d5e6f7a9000",
    "senderId": "66400a8f1c2b4d5e6f7a8b01",
    "clientMessageId": "cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
    "text": "Hi! Loved your kitchen.",
    "deliveredTo": [],
    "readBy": [],
    "editedAt": null,
    "deletedAt": null,
    "createdAt": "2026-05-22T14:35:12.001Z",
    "updatedAt": "2026-05-22T14:35:12.001Z"
  },
  "deduped": true
}
Pick the clientMessageId before the first attempt and reuse it on every retry. If you regenerate the id on each retry you defeat the dedup and produce duplicate messages.

Message object

See the Message object schema.

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDclientMessageId missing or wrong length, text empty/whitespace-only/over 4000 chars, or id not a 24-char hex ObjectId.
401UNAUTHENTICATEDMissing, malformed, or expired access token.
403ONBOARDING_INCOMPLETECaller has not finished onboarding.
403FORBIDDENCaller is not a participant of this conversation.
403CONVERSATION_BLOCKEDEither conversation status !== "ACTIVE", or a block exists in either direction between the participants.
404NOT_FOUNDNo conversation with that id.

Example error — 403 CONVERSATION_BLOCKED

{
  "type": "https://api.swappr.co.uk/errors/conversation-blocked",
  "title": "Conversation is blocked",
  "status": 403,
  "code": "CONVERSATION_BLOCKED",
  "detail": "a block exists between you and this user",
  "instance": "/api/v1/chat/conversations/66400a8f1c2b4d5e6f7a9000/messages",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Example error — 400 VALIDATION_FAILED

{
  "type": "https://api.swappr.co.uk/errors/validation-failed",
  "title": "Validation failed",
  "status": 400,
  "code": "VALIDATION_FAILED",
  "detail": "Request body failed validation",
  "instance": "/api/v1/chat/conversations/66400a8f1c2b4d5e6f7a9000/messages",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2",
  "errors": [
    { "path": "text", "message": "Too small: expected string to have >=1 characters", "code": "too_small" }
  ]
}

Side effects

On a fresh insert (201):
  • One messages row inserted.
  • The conversation’s lastMessage, lastMessageAt updated to the new message.
  • Every recipient (every participant except the sender) gets unreadCount += 1.
  • If a WebSocket socket is subscribed to conversation:<id> it receives a chat:message:new broadcast (Socket events page).
  • (Phase 5) A notification.push BullMQ job is enqueued for each muted-off recipient.
On dedup (200): no side effects at all.

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/chat/conversations/66400a8f1c2b4d5e6f7a9000/messages \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "clientMessageId": "cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
    "text": "Hi! Loved your kitchen."
  }'