Messaging & Chat API
Direct 1:1 messaging (BRD §11) between any two authenticated users (typically mentor â mentee), plus the in-chat appointment booking flow (share availability â request slot â accept â pay).
- Base URL:
/api/v1 - Auth: every endpoint requires
Authorization: Bearer {token}(Sanctum). - Content type:
application/jsonâ file uploads usemultipart/form-data.
Response envelope (all endpoints):
// Success // Error
{ {
"status": "success", "status": "error",
"message": "âĶ", "message": "âĶ",
"data": { } "data": { }
} }
The flow at a glance
â POST /chat/users/{user}/messages User A sends the FIRST message â thread created (Pending)
â
âââââââââââââââââââââââââââââââžââââââââââââââââââââââââââââââ
âž âž âž
âĄa POST /chat/threads/{id}/accept âĄb POST /chat/threads/{id}/messages âĄc POST /chat/threads/{id}/decline
User B accepts explicitly User B just replies User B declines
â thread Accepted â auto-accepts thread â thread + messages DELETED
âââââââââââââââŽââââââââââââââââ
âž
âĒ Both chat freely: POST /chat/threads/{id}/messages
Read the inbox: GET /chat/threads · GET /chat/threads/{id}
Read history: GET /chat/threads/{id}/messages
Clear unread: POST /chat/threads/{id}/read
Shared media: GET /chat/threads/{id}/media · GET /chat/threads/{id}/links
ââ Managing the inbox (per-viewer; the other side never sees these) âââââââââ
Archive: POST/DELETE /chat/threads/{id}/archive (a new message auto-unarchives)
Mute: POST/DELETE /chat/threads/{id}/mute (silences notifications only)
Pin: POST/DELETE /chat/threads/{id}/pin (floats to the top of the inbox)
Block: POST/DELETE /users/{user}/block (platform-wide â see user-block-api)
ââ In-chat appointment booking (inside an accepted thread) ââââââââââââââââââ
âĢ GET /chat/shareable-slots Mentor previews their own open slots
âĪ POST /chat/threads/{id}/share-availability Mentor drops slots into the chat
(message carries the resolved `shared_slots`)
âĨ POST /chat/threads/{id}/appointment-request Mentee picks a slot â booking REQUESTED
â
âââââââââââââââīââââââââââââââ
âž âž
âĶa POST /chat/appointments/{b}/accept âĶb POST /chat/appointments/{b}/decline
Free â CONFIRMED + meeting link Booking cancelled, slot released
Paid â CONFIRMED + payment link
(mentee pays within 24h â meeting link posted)
The acceptance gate (§11.1): the first message creates the thread in Pending status.
While pending, the initiator cannot send a second message (422). The recipient either
accepts (explicitly via âĄa, or implicitly by replying âĄb) or declines âĄc, which deletes the
thread and all its messages.
Step â â Send the first message to a user
Opens (or reuses) the direct thread with {user} and posts the message. Use this same endpoint
any time you only know the user id and not the thread id â if a thread already exists it is reused.
POST /api/v1/chat/users/{user}/messages
| Field | Type | Rules |
|---|---|---|
body |
string | required without attachments; max 10 000 chars |
attachments[] |
file | optional; max 10 files, each âĪ 10 MB (send as multipart/form-data) |
Request
{ "body": "Hi! I'd love some guidance on system design." }
Response 201
{
"status": "success",
"message": "Message sent.",
"data": {
"thread": {
"id": "9c3fâĶ",
"status": 1,
"status_label": "Pending",
"is_accepted": false,
"is_group": false,
"initiated_by_me": true,
"last_message_at": "2026-07-23T09:00:00+00:00",
"last_message_preview": "Hi! I'd love some guidance on system design.",
"message_count": 1,
"unread_count": 0,
"participant": {
"id": "9c41âĶ",
"name": "Jane Doe",
"username": "jane",
"avatar_url": null,
"connection_status": "connected",
"profession": "Product Designer",
"reviews": { "average_rating": "4.50", "count": 12 },
"completed_sessions": 34,
"social_links": {
"facebook": null,
"instagram": null,
"x": "https://x.com/jane",
"linkedin": "https://linkedin.com/in/jane"
}
},
"created_at": "2026-07-23T09:00:00+00:00"
},
"message": {
"id": "9c3fâĶ",
"thread_id": "9c3fâĶ",
"type": 1,
"type_label": "Text",
"body": "Hi! I'd love some guidance on system design.",
"metadata": null,
"is_mine": true,
"sender": { "id": "9c40âĶ", "name": "John Smith", "avatar_url": null },
"attachments": [],
"created_at": "2026-07-23T09:00:00+00:00"
}
}
}
Errors
| Code | When |
|---|---|
| 404 | {user} does not exist |
| 422 | Messaging yourself · you already sent the opener and the thread is still pending |
The recipient gets a chat.message_request notification for the opener
(chat.new_message for later messages).
Step ⥠â Recipient responds to the message request
The recipient (the user who did not start the thread) has three options.
âĄa Accept explicitly
POST /api/v1/chat/threads/{thread}/accept
No body. Idempotent â accepting an already-accepted thread just returns it.
Response 200
{
"status": "success",
"message": "Thread accepted.",
"data": {
"thread": { "id": "9c3fâĶ", "status": 2, "status_label": "Accepted", "is_accepted": true, "âĶ": "âĶ" }
}
}
| Code | When |
|---|---|
| 404 | Not a participant / thread not found |
| 422 | You started the thread (initiator cannot accept) |
âĄb Accept implicitly by replying
Simply send a message to the thread (see step âĒ). A reply from the recipient while the thread is pending automatically flips it to Accepted.
âĄc Decline
POST /api/v1/chat/threads/{thread}/decline
No body. Deletes the thread and all its messages â this is not reversible.
Response 200 â { "status": "success", "message": "Thread declined.", "data": {} }
| Code | When |
|---|---|
| 404 | Not a participant / thread not found |
| 422 | You started the thread (initiator cannot decline) |
Step âĒ â Ongoing conversation
Send a message to an existing thread
POST /api/v1/chat/threads/{thread}/messages
Same body rules as step â (body / attachments[]).
Request
{ "body": "Sure, happy to help!" }
Response 201
{
"status": "success",
"message": "Message sent.",
"data": {
"message": {
"id": "9c42âĶ",
"thread_id": "9c3fâĶ",
"type": 1,
"type_label": "Text",
"body": "Sure, happy to help!",
"metadata": null,
"is_mine": true,
"sender": { "id": "9c41âĶ", "name": "Jane Doe", "avatar_url": null },
"attachments": [],
"created_at": "2026-07-23T09:05:00+00:00"
}
}
}
With attachments (multipart/form-data, fields attachments[0], attachments[1], âĶ), the
message type becomes 4 (File) and attachments is populated:
"attachments": [{ "url": "https://âĶ/brief.pdf", "name": "brief.pdf", "size": 204800 }]
Everything sent this way also lands in the thread's gallery â see Shared media, files & links.
| Code | When |
|---|---|
| 404 | Thread not found, or you are not a participant |
| 422 | You are the initiator and the thread is still pending |
Send a voice message
Same endpoint as above (and the same one in step â for a new conversation) with is_voice=1.
Send it as multipart/form-data.
POST /api/v1/chat/threads/{thread}/messages
POST /api/v1/chat/users/{user}/messages
| Field | Type | Rules |
|---|---|---|
is_voice |
bool | required to make this a voice note â without it an audio upload is an ordinary file message |
attachments[] |
file | required; exactly one; âĪ 10 MB; audio/mp4 · audio/x-m4a · audio/aac · audio/mpeg · audio/ogg · audio/webm |
body |
â | prohibited â a voice note carries no caption |
duration_ms |
int | optional; 1 â 300 000 (five minutes). Rejected without is_voice |
waveform[] |
float | optional; up to 512 peaks, each 0â1. Rejected without is_voice |
reply_to_id |
uuid | optional; a voice note can quote a message like any other |
is_voice is the client's call, not the server's. An mp3 someone picked from their files is a
file message; only the sending UI knows the mic was the source. Uploading audio without the flag
keeps the existing type: 4 behaviour.
Nothing on the server reads the audio â there is no ffmpeg or getID3 in the stack â so
duration_ms and waveform[] come from the recorder and are treated as display data:
clamped on the way in, echoed on the way out. Omitting them is legal; the bubble then shows a
flat bar and counts up as it plays.
Encode to m4a or mp3. The file one participant uploads is the file the other must play, and
nothing transcodes in between. Chrome's MediaRecorder defaults to WebM/Opus, which iOS will
not play â encode before posting rather than sending the recorder's default.
Waveform peaks are downsampled to 64 on the way in, averaged rather than thinned, and returned at exactly that length for a recording long enough to need it. Send as many peaks as your recorder produces; render what comes back.
Response 201
{
"status": "success",
"message": "Voice message sent.",
"data": {
"message": {
"id": "9c44âĶ",
"thread_id": "9c3fâĶ",
"type": 6,
"type_label": "Voice Message",
"body": null,
"voice": {
"url": "https://âĶ/note.m4a",
"mime_type": "audio/mp4",
"size": 122880,
"duration_ms": 4200,
"waveform": [0.1, 0.55, 0.9]
},
"can_edit": false,
"can_reply": true,
"can_react": true,
"can_forward": true,
"attachments": [{ "url": "https://âĶ/note.m4a", "name": "note.m4a", "size": 122880 }],
"created_at": "2026-07-23T09:07:00+00:00"
}
}
}
voiceis present only on a voice bubble (type: 6) â switch on its absence rather than sniffing mime types.duration_msmay benullif the client sent none.- The recording is also in
attachments, so a client that only knows about files still renders something the recipient can play. - A voice message cannot be edited (
can_editis alwaysfalse) â the recording is the message. It can be replied to, reacted to, forwarded, pinned and unsent like any other. - It lands in the gallery's audio bucket, flagged
is_voice: trueâ see ChatMedia object. - Unsending blanks
voicetonullalongsidebodyandattachments.
| Code | When |
|---|---|
| 404 | Thread not found, or you are not a participant |
| 422 | Missing/unplayable recording, more than one file, a body, over five minutes, or the pending-thread gate |
List my threads (inbox)
GET /api/v1/chat/threads?per_page=15&search=tom&filter=unread
Pinned threads first, then recency-ordered; paginated. Each thread carries the viewer's
unread_count, their own archive/mute/pin state, and the other
participant â including their profession, reviews, completed sessions and
social links, batched so a full page costs no extra queries.
Archived threads are excluded unless you ask for them with filter=archived. Threads with a
blocked counterpart never appear at all.
| Query param | Notes |
|---|---|
search |
Optional, max 100 chars. Keeps only threads whose other participant matches by name or username (substring, case-insensitive). Your own name never matches. |
filter |
Optional: unread â only threads with unread messages · read â only fully-read threads · archived â only archived threads. Anything else is a 422. |
per_page |
Optional, 1â50, default 15. |
Response 200
{
"status": "success",
"message": "Threads retrieved successfully.",
"data": {
"threads": {
"data": [ { "id": "9c3fâĶ", "status": 2, "status_label": "Accepted", "unread_count": 2, "âĶ": "âĶ" } ],
"links": { "first": "âĶ", "last": "âĶ", "prev": null, "next": null },
"meta": { "current_page": 1, "per_page": 15, "total": 3, "âĶ": "âĶ" }
}
}
}
Thread detail
GET /api/v1/chat/threads/{thread}
Response 200 â data.thread = one ChatThread object. 404 if not
a participant.
This is the call a chat screen makes to draw its header, so data.thread.participant carries the
counterpart's profession, reviews, completed_sessions and social_links alongside their
identity â see Participant object. No follow-up call to the profile endpoint
is needed for the header.
{
"status": "success",
"message": "Thread retrieved successfully.",
"data": {
"thread": {
"id": "9c3fâĶ",
"status": 2,
"status_label": "Accepted",
"is_accepted": true,
"is_group": false,
"initiated_by_me": false,
"last_message_at": "2026-07-19T05:30:00+00:00",
"last_message_preview": "See you then!",
"message_count": 12,
"unread_count": 2,
"is_archived": false,
"is_muted": false,
"muted_until": null,
"is_pinned": false,
"participant": {
"id": "9c41âĶ",
"name": "Jane Doe",
"username": "jane",
"avatar_url": "https://âĶ/avatar.jpg",
"connection_status": "connected",
"profession": "Product Designer",
"reviews": { "average_rating": "4.50", "count": 12 },
"completed_sessions": 34,
"social_links": {
"facebook": null,
"instagram": null,
"x": "https://x.com/jane",
"linkedin": "https://linkedin.com/in/jane"
}
},
"created_at": "2026-07-19T05:00:00+00:00"
}
}
}
Message history
GET /api/v1/chat/threads/{thread}/messages?per_page=30
Oldest-first (chat rendering order), paginated.
Response 200
{
"status": "success",
"message": "Messages retrieved successfully.",
"data": {
"messages": {
"data": [ { "id": "9c3fâĶ", "type": 1, "body": "Hi!", "is_mine": false, "âĶ": "âĶ" } ],
"links": { "âĶ": "âĶ" },
"meta": { "current_page": 1, "per_page": 30, "total": 12, "âĶ": "âĶ" }
}
}
}
calendar_share (type 2) and appointment_request (type 3) messages in this list come back with
their availability already resolved â shared_slots / slot / session_type alongside metadata
(see the ChatMessage object). The whole page is hydrated in two queries, so
paging costs the same regardless of how many appointment bubbles it holds.
Shared media, files & links
The conversation's media drawer: every attachment ever sent in the thread, bucketed by kind, plus
the links posted in it. Both endpoints are participant-only (404 otherwise) and read straight from
the attachment index â no paging back through the message history to rebuild a gallery.
GET /api/v1/chat/threads/{thread}/media?type=image,video&per_page=30
| Query param | Notes |
|---|---|
type |
Optional. One bucket or a comma-separated set: image · video · audio · document. Omit for everything. Anything else is a 422. |
per_page |
Optional, 1â100, default 30. |
Newest first, paginated. type=image,video is the combined Media tab; type=document is the
Files tab.
Response 200
{
"status": "success",
"message": "Thread media retrieved successfully.",
"data": {
"media": {
"data": [ { "id": "9c50âĶ", "type_key": "image", "name": "shot.png", "url": "https://âĶ", "âĶ": "âĶ" } ],
"links": { "âĶ": "âĶ" },
"meta": { "current_page": 1, "per_page": 30, "total": 19, "âĶ": "âĶ" }
},
"counts": { "image": 12, "video": 2, "audio": 0, "document": 5, "total": 19 },
"types": { "1": "Image", "2": "Video", "3": "Audio", "4": "Document" }
}
}
Each item is a ChatMedia object (shape below). counts ships with every page and always
carries all four buckets, zeros included â the tab bar renders its badges from the same request that
fills the grid, so there is no second call and no tallying of a partial page client-side. The counts
are for the whole thread, not the current page.
Bucketing is derived from the stored mime type â image/*, video/*, audio/*, and
Document as the catch-all for everything else, including attachments stored without a mime type.
Nothing ever falls out of the gallery.
Visibility matches the message list exactly. Attachments on messages unsent for everyone are gone for both sides; attachments on a message you removed for yourself leave your gallery while the other participant keeps theirs.
There are no thumbnail conversions â url is the original file. Size-constrained grids should
render from it and let the CDN/browser scale.
Links
GET /api/v1/chat/threads/{thread}/links?per_page=30
Newest first, paginated (per_page default 30), one entry per message carrying at least one
http(s) URL. Each entry is a ChatLink object.
URLs are extracted from the message body at read time â trailing sentence punctuation trimmed, duplicates dropped, original order kept â so links posted long before this endpoint existed are listed too. No link previews are fetched: no title, image or favicon, just the URLs and the body they sat in.
Thread actions: read, archive, mute, pin
Everything in this block is per-viewer. Archiving, muting or pinning a thread changes your inbox alone â the other participant's inbox is untouched and they are never told.
| Action | Endpoint |
|---|---|
| Mark all messages read | POST /api/v1/chat/threads/{thread}/read |
| Archive | POST /api/v1/chat/threads/{thread}/archive |
| Unarchive | DELETE /api/v1/chat/threads/{thread}/archive |
| Mute | POST /api/v1/chat/threads/{thread}/mute |
| Unmute | DELETE /api/v1/chat/threads/{thread}/mute |
| Pin | POST /api/v1/chat/threads/{thread}/pin |
| Unpin | DELETE /api/v1/chat/threads/{thread}/pin |
All of them 404 when you are not a participant of the thread.
Mark all messages in the thread as read
POST /api/v1/chat/threads/{thread}/read
No body. Moves your read cursor to now â every message in the thread counts as read and
unread_count drops to 0.
Response 200 â { "status": "success", "message": "Thread marked as read.", "data": {} }
Also broadcasts a chat.message.read receipt to the other participant â see
Realtime.
Archive / unarchive
POST /api/v1/chat/threads/{thread}/archive
DELETE /api/v1/chat/threads/{thread}/archive
No body. An archived thread drops out of GET /chat/threads and is reachable via
GET /chat/threads?filter=archived. It stays fully readable and writable â archiving only tidies
the inbox.
A new message auto-unarchives the thread, so archive is "not now", not "never again". Don't model it client-side as a permanent state.
Response 200 â data.thread = the updated ChatThread object.
Mute / unmute
POST /api/v1/chat/threads/{thread}/mute { "duration": 1 }
DELETE /api/v1/chat/threads/{thread}/mute
| Field | Rules |
|---|---|
duration |
Required on mute. One of the values below. |
duration |
Meaning |
|---|---|
1 |
8 hours |
2 |
1 day |
3 |
1 week |
4 |
Forever |
A mute suppresses notifications only. Messages still arrive, still broadcast over the socket,
and still raise unread_count â the recipient just isn't pinged. The mute lifts on its own at
muted_until; Forever is stored as a date ~100 years out, so a single field answers both "muted?"
and "until when".
Response 200 â data.thread with is_muted and muted_until updated.
422 if duration is missing or not one of the four values.
Pin / unpin
POST /api/v1/chat/threads/{thread}/pin
DELETE /api/v1/chat/threads/{thread}/pin
No body. Pinned threads head GET /chat/threads, most-recently-pinned first, ahead of every
unpinned thread regardless of recency. There is no pin limit.
Response 200 â data.thread with is_pinned updated.
Message reactions
Unlike the thread actions above, a reaction is shared â the other participant sees it.
POST /api/v1/chat/messages/{message}/reactions { "type": "love" }
DELETE /api/v1/chat/messages/{message}/reactions
GET /api/v1/chat/messages/{message}/reactions ?type=love
One reaction per person per message. Posting a second emoji replaces the first rather than
adding to it, so the count under a bubble never exceeds the number of people in the thread. Both
mutations are idempotent and both return the whole updated message in data.message, so replace
the bubble rather than patching a count.
Every message carries reactions (the per-emoji tally, biggest first), reaction_count,
my_reaction (yours, or null) and can_react. A retracted message reports no reactions and
can_react: false, the same way its body goes null.
422 when the message is unsent, is a system line (type: 5), was removed from your own copy of
the thread, or a block is in force. 404 when you are not a participant.
The GET is the "who reacted" sheet â not paginated, newest first, and empty for a retracted
message. Live changes arrive as a chat.message.reaction frame carrying the resulting tally.
Full detail: messaging-reactions-response.md.
Forwarding a message
POST /api/v1/chat/messages/{message}/forward
| Field | Rules |
|---|---|
thread_ids |
required_without:user_ids · array of thread uuids you are in |
user_ids |
required_without:thread_ids · array of user uuids â the thread is opened for you |
Send either or both, at most 5 ids across the two lists combined.
A forward is a new message, not a pointer. Each target gets its own id, its own delivery and read state, and its own copies of any attachments â so unsending or editing the original leaves the copies untouched. You are the sender. A reply's quoted line is dropped, since the quote belongs to the conversation it was written in.
The origin is never disclosed. The bubble reports only is_forwarded: true â no source
thread, no original author, no attribution line. The origin thread belongs to people the recipient
may have no relationship with.
Response 201 â data.messages, one full ChatMessage per target. The array is not ordered
to match the request; map each entry to its destination by thread_id. Each target thread also
emits its ordinary chat.message.sent frame.
Every message carries is_forwarded and can_forward. can_forward is false for a retracted
message, for system lines (type: 5), and for the appointment cards (type: 2 and 3) â those
are live booking controls bound to the thread they were raised in. Text and file messages travel.
A user_ids target you have never messaged gets a thread opened in Pending, with the
forward as its opener: the §11.1 gate applies, so a second forward to them before they accept is a
422. The response returns the message, not the thread â refetch GET /chat/threads for the new
inbox row.
All-or-nothing. Every target is vetted before anything is written, so a 422 means nothing was
sent anywhere. 422 when the message is unsent, is a non-forwardable type, was removed from your
own copy of the thread, a block is in force, a target is a pending thread you opened, or the
ceiling is exceeded. 403 when a user_ids target restricted who may message them. 404 â
"Message or conversation not found." â when the message or a chosen target is not yours to see;
deliberately vague so a caller in neither cannot learn which exists.
Full detail: messaging-forward-response.md.
Blocking a user
Blocking is not a chat action â it is platform-wide and lives at
POST /api/v1/users/{user}/block. A block severs the conversation in both directions: sends
return 422, and the thread disappears from both inboxes. See the
User Blocking doc.
Typing indicator
POST /api/v1/chat/threads/{thread}/typing
| Field | Rules | Default |
|---|---|---|
is_typing |
sometimes|boolean |
true |
Persists nothing: it only relays a chat.typing frame to the other participants. Rate limited
to 120/min, so debounce it â once when composing starts, then at most every ~2s, and once with
is_typing: false when the field empties.
Response 200 â { "status": "success", "message": "Typing state broadcast.", "data": {} }
Realtime: live messages, receipts, and typing
Every endpoint above works standalone over plain REST. On top of that, an accepted thread has a presence channel that pushes the same data as it happens, so an open conversation never polls:
| Wire event | Fired by | Payload |
|---|---|---|
chat.message.sent |
any message landing in the thread | the full ChatMessage object, minus is_mine |
chat.message.read |
POST /chat/threads/{thread}/read |
{ thread_id, user_id, read_at } |
chat.typing |
POST /chat/threads/{thread}/typing |
{ thread_id, user, is_typing } |
echo.join(`chat.threads.${threadId}`) // join(), NOT private()
.listen('.chat.message.sent', appendMessage) // the leading dot is required
.listen('.chat.message.read', markSeen)
.listen('.chat.typing', showTyping);
Three things that decide whether the integration works:
join(), notprivate()â the events broadcast on the presence channel; a private subscription authorizes and then receives nothing.- Send
X-Socket-IDon your POSTs â messages broadcast withtoOthers(), so this is what stops your own message coming back as a duplicate bubble on top of the201response. - Derive
is_mineyourself fromsender.id; it is omitted from broadcast frames because a broadcast has many viewers.
Full setup, notification channel, reconnection rules, and deployment: Realtime & WebSockets.
Step âĢ â Mentor previews shareable slots
Before sharing, the mentor can fetch their own upcoming available slots for one of their session types. Mentor-only (the session type must belong to the caller's mentor profile).
GET /api/v1/chat/shareable-slots?session_type_id={id}&days=14
| Param | Rules |
|---|---|
session_type_id |
required; one of the mentor's own session types |
days |
optional; 1â90, default 14 (look-ahead window from now) |
Response 200
{
"status": "success",
"message": "Shareable slots retrieved successfully.",
"data": {
"slots": [
{
"id": "9c50âĶ",
"mentor_id": "9c10âĶ",
"session_type_id": "9c20âĶ",
"availability_rule_id": "9c30âĶ",
"starts_at": "2026-07-25T10:00:00+00:00",
"ends_at": "2026-07-25T10:30:00+00:00",
"buffer_ends_at": "2026-07-25T10:40:00+00:00",
"held_until": null,
"status": 1,
"status_label": "Available",
"is_selectable": true,
"max_capacity": 1,
"current_capacity": 0,
"available_seats": 1
}
]
}
}
| Code | When |
|---|---|
| 422 | Caller is not a mentor, or the session type does not belong to them |
Step âĪ â Mentor shares availability into the thread
Posts a calendar_share message (type 2) into the thread and notifies the mentee
(chat.calendar_share). Mentor-only; the mentor must be a participant of the thread.
POST /api/v1/chat/threads/{thread}/share-availability
Request
{ "session_type_id": "9c20âĶ", "days": 14 }
| Field | Rules |
|---|---|
session_type_id |
required; one of the mentor's own session types |
days |
optional; 1â90, default 14 |
Response 201
{
"status": "success",
"message": "Availability shared.",
"data": {
"message": {
"id": "9c60âĶ",
"thread_id": "9c3fâĶ",
"type": 2,
"type_label": "Calendar Share",
"body": null,
"metadata": {
"available_slot_ids": ["9c50âĶ", "9c51âĶ", "9c52âĶ"],
"session_type_id": "9c20âĶ"
},
"shared_slots": [
{
"id": "9c50âĶ",
"mentor_id": "9c10âĶ",
"session_type_id": "9c20âĶ",
"availability_rule_id": "9c30âĶ",
"starts_at": "2026-07-25T10:00:00+00:00",
"ends_at": "2026-07-25T10:30:00+00:00",
"buffer_ends_at": "2026-07-25T10:40:00+00:00",
"held_until": null,
"status": 1,
"status_label": "Available",
"is_selectable": true,
"max_capacity": 1,
"current_capacity": 0,
"available_seats": 1
}
],
"session_type": {
"id": "9c20âĶ",
"title": "Career Guidance 1:1",
"duration_minutes": 30,
"is_free": false,
"price": "50.00",
"currency": "USD",
"âĶ": "âĶ"
},
"is_mine": true,
"sender": { "id": "9c41âĶ", "name": "Jane Doe", "avatar_url": null },
"attachments": [],
"created_at": "2026-07-23T09:10:00+00:00"
}
}
}
metadata.available_slot_ids is the stored pointer; shared_slots is the rendered payload â
the API resolves those ids for you (here and in the message-history list), so the client draws the
picker directly from shared_slots and posts the chosen id to step âĨ. No extra slot lookup is
needed.
Slots are resolved live on every read, never snapshotted at share time: a slot that runs out of
seats after the share comes back with is_selectable: false, so a stale chat bubble can never offer
a gone slot. Grey out any entry where is_selectable is false.
is_selectable is seat-aware, not status-aware. On a group slot (max_capacity > 1) it stays
true while seats remain, even though other mentees are holding or occupying seats â so read it
rather than inferring availability from status. Show available_seats alongside any slot where
max_capacity > 1.
| Code | When |
|---|---|
| 404 | Thread not found / not a participant |
| 422 | Not a mentor · session type not owned · pending-thread gate |
Step âĨ â Mentee requests an appointment from a shared slot
Creates a REQUESTED SessionBooking (reuses the standard booking service â the slot is
held), posts an appointment_request message (type 3), and notifies the mentor
(chat.appointment_request).
POST /api/v1/chat/threads/{thread}/appointment-request
Request
{ "slot_id": "9c50âĶ", "follow_up_slot_ids": [] }
| Field | Rules |
|---|---|
slot_id |
required; an id from the calendar_share message's shared_slots |
follow_up_slot_ids |
optional array of slot ids (multi-slot sessions) |
Response 201
{
"status": "success",
"message": "Appointment requested.",
"data": {
"message": {
"id": "9c70âĶ",
"thread_id": "9c3fâĶ",
"type": 3,
"type_label": "Appointment Request",
"body": null,
"metadata": {
"booking_id": "9c80âĶ",
"slot_id": "9c50âĶ",
"session_type_id": "9c20âĶ",
"proposed_starts_at": "2026-07-25T10:00:00+00:00",
"title": "Career Guidance 1:1",
"is_free": false,
"price": "50.00"
},
"slot": {
"id": "9c50âĶ",
"starts_at": "2026-07-25T10:00:00+00:00",
"ends_at": "2026-07-25T10:30:00+00:00",
"status": 3,
"status_label": "Held",
"is_selectable": false,
"âĶ": "âĶ"
},
"session_type": { "id": "9c20âĶ", "title": "Career Guidance 1:1", "duration_minutes": 30, "âĶ": "âĶ" },
"is_mine": true,
"sender": { "id": "9c40âĶ", "name": "John Smith", "avatar_url": null },
"attachments": [],
"created_at": "2026-07-23T09:15:00+00:00"
},
"booking_id": "9c80âĶ"
}
}
Keep booking_id â it is the route parameter for step âĶ.
| Code | When |
|---|---|
| 404 | Thread not found / not a participant |
| 409 | Slot has no free seat left (all seats taken or in checkout) |
| 422 | Invalid slot / booking-side validation failure |
Step âĶ â Mentor accepts or declines the appointment
Both endpoints are mentor-only: the booking must belong to the caller's mentor profile.
{booking} is the booking_id from step âĨ.
âĶa Accept
POST /api/v1/chat/appointments/{booking}/accept
Request (body optional)
{ "price": 25 }
| Field | Rules |
|---|---|
price |
optional; numeric âĨ 0. A price > 0 converts a free/un-priced appointment to paid at that price (§11.3 step 34). Omit to keep the booking's existing price. |
What happens (delegated to the standard booking confirm flow):
- Free booking â status
confirmed, one seat claimed on each slot, a meeting link is generated and posted into the thread as a system message. - Paid booking â status stays
requestedwithawaiting_payment: true, this booking's seats held to the payment deadline (BOOKING_PAYMENT_DEADLINE_HOURS); a checkout link is generated, posted into the thread as a system message, and returned aspayment_url. Once the mentee pays, the booking goes straight toconfirmedand the meeting link is generated and posted into the thread.
Why
requestedand notconfirmed. Bookings are otherwise pay-first: the mentee is charged up front andconfirmednow means live and paid for. In-chat appointments are the exception â the mentor sets the price here, so there is nothing to charge for until they accept. The acceptance is recorded on the booking (mentorApprovedAt), which is what sends the payment straight toconfirmedinstead of back into the mentor's queue. Readawaiting_payment, notstatus, to tell that the mentor agreed.
The meeting link belongs to the slot, so on a group session (
max_capacity > 1) every accepted mentee is posted the same room URL.
Response 200 (paid)
{
"status": "success",
"message": "Appointment accepted.",
"data": {
"booking_id": "9c80âĶ",
"status": "requested",
"awaiting_payment": true,
"payment_url": "https://meetyy.test/payments/checkout/9c80âĶ?expires=âĶ&signature=âĶ"
}
}
Response 200 (free) â "status": "confirmed", without awaiting_payment or payment_url.
| Code | When |
|---|---|
| 404 | Booking not found |
| 409 | The session filled up while the request was pending â no seat left to claim |
| 422 | Booking does not belong to your mentor profile · invalid state transition |
Handle the 409. A request can sit unanswered far longer than its seats are held, so another mentee can take the last seat first. The booking stays
requested; show the mentor that the session is full so they can decline it (âĶb).
âĶb Decline
POST /api/v1/chat/appointments/{booking}/decline
No body. Cancels the booking and releases the held slot(s).
Response 200 â { "status": "success", "message": "Appointment declined.", "data": {} }
| Code | When |
|---|---|
| 404 | Booking not found |
| 422 | Booking does not belong to your mentor profile |
Reference
Endpoint summary (in flow order)
| Step | Method | Route | Purpose |
|---|---|---|---|
| â | POST | /chat/users/{user}/messages |
Open/reuse the direct thread and send a message |
| âĄa | POST | /chat/threads/{thread}/accept |
Recipient accepts a pending request |
| âĄc | POST | /chat/threads/{thread}/decline |
Recipient declines â thread deleted |
| âĒ | POST | /chat/threads/{thread}/messages |
Send a message in an existing thread |
| âĒ | POST | /chat/threads/{thread}/messages |
Same endpoint with is_voice=1 â send a voice note |
| âĒ | GET | /chat/threads |
List my threads (inbox) |
| âĒ | GET | /chat/threads/{thread} |
Thread detail |
| âĒ | GET | /chat/threads/{thread}/messages |
Paginated message history (oldest first) |
| âĒ | POST | /chat/threads/{thread}/read |
Reset my unread count (+ chat.message.read receipt) |
| âĒ | GET | /chat/threads/{thread}/media |
Shared-media gallery, bucketed + per-bucket counts |
| âĒ | GET | /chat/threads/{thread}/links |
Links posted in the thread |
| âĒ | POST | /chat/threads/{thread}/typing |
Relay a typing indicator (120/min) |
| âĒ | POST | /chat/messages/{message}/reactions |
Set my reaction on a message |
| âĒ | DELETE | /chat/messages/{message}/reactions |
Clear my reaction |
| âĒ | GET | /chat/messages/{message}/reactions |
Who reacted (?type= optional) |
| âĒ | POST | /chat/messages/{message}/forward |
Forward a message to up to 5 conversations |
| âĢ | GET | /chat/shareable-slots |
Mentor: preview own shareable slots |
| âĪ | POST | /chat/threads/{thread}/share-availability |
Mentor: share slots into the chat |
| âĨ | POST | /chat/threads/{thread}/appointment-request |
Mentee: request a slot â booking |
| âĶa | POST | /chat/appointments/{booking}/accept |
Mentor: accept (returns payment_url if paid) |
| âĶb | POST | /chat/appointments/{booking}/decline |
Mentor: decline â slot released |
Enums
| Enum | Values |
|---|---|
| Thread status | 1 Pending · 2 Accepted |
| Message type | 1 Text · 2 Calendar Share · 3 Appointment Request · 4 File · 5 System · 6 Voice Message |
| Media bucket | 1 Image · 2 Video · 3 Audio · 4 Document â type_key: image · video · audio · document |
| Booking status | requested · confirmed · paid · completed · cancelled · refunded |
| Reaction type | like · love · laugh · wow · sad · angry â same six as the feed picker |
| Notification types | chat.message_request · chat.new_message · chat.calendar_share · chat.appointment_request · chat.message_reacted |
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (lists, detail, accept, decline, read) |
| 201 | Created (message sent, availability shared, appointment requested) |
| 401 | Missing/invalid token |
| 404 | Thread / user / booking not found, or caller is not a participant |
| 409 | No seat left: slot unholdable when requesting, or session full when accepting |
| 422 | Validation error / invalid state (see per-endpoint tables) |
| 429 | Typing indicator rate limit (120/min) |
| 500 | Server error |
ChatThread object
{
"id": "uuid",
"status": 2,
"status_label": "Accepted",
"is_accepted": true,
"is_group": false,
"initiated_by_me": true,
"last_message_at": "2026-07-19T05:30:00+00:00",
"last_message_preview": "See you then!",
"message_count": 12,
"unread_count": 2,
"is_archived": false,
"is_muted": true,
"muted_until": "2026-07-19T13:30:00+00:00",
"is_pinned": true,
"participant": {
"id": "uuid",
"name": "Jane Doe",
"username": "jane",
"avatar_url": null,
"connection_status": "connected",
"profession": "Product Designer",
"reviews": { "average_rating": "4.50", "count": 12 },
"completed_sessions": 34,
"social_links": {
"facebook": null,
"instagram": null,
"x": "https://x.com/jane",
"linkedin": "https://linkedin.com/in/jane"
}
},
"created_at": "2026-07-19T05:00:00+00:00"
}
participantâ the other user in the direct thread (never the viewer). It carries enough of their profile to render a chat header without a second call toGET /users/{username}; see Participant object below.last_message_previewâ text/system messages are truncated to 120 chars; other types render as"Shared availability","Appointment request","Sent a file".is_archived/is_muted/muted_until/is_pinnedâ the viewer's own inbox state, never the other participant's.muted_untilis null unlessis_mutedis true; an indefinite mute reports a date ~100 years out. See Thread actions.
Participant object
The counterpart's identity plus a profile summary, so a chat header can render their
profession, rating and session count without a second round trip. Returned identically by every
endpoint that emits a ChatThread â the inbox, GET /chat/threads/{id}, accept/decline, the
thread actions, and the message-send responses.
| Field | Type | Notes |
|---|---|---|
id |
uuid | The other user, never the viewer. |
name |
string | Their full name. |
username |
string | |
avatar_url |
string|null | null when they have not uploaded one. |
connection_status |
string | The viewer's relationship to them: none · pending_outgoing · pending_incoming · connected. See the Connection status enum in feed-api. |
profession |
string|null | null when they have not set one. |
reviews |
object | Always present â { "average_rating": string|null, "count": int }. |
completed_sessions |
int | 0 when they have completed none. |
social_links |
object | Always all four keys â facebook, instagram, x, linkedin â each a URL or null. |
reviews.average_ratingis a string, not a float â a 2-decimal fixed-point value such as"4.50", matching howaverage_ratingis rendered everywhere else in this API. It isnull(not"0.00") whencountis0, so an unreviewed user is distinguishable from a badly reviewed one.countis always an integer.- What
reviewscounts â the participant's course reviews across their whole catalogue, the same figure the instructor card shows in course-api. Per-session reviews are not recorded by the platform, so they are not what this reports. completed_sessionssums the sessions they finished as a mentor and as a mentee. A user who holds both profiles gets their true total rather than one side of it.social_linksalways carries all four keys,nullincluded, so a client can map straight to icons without key-checking.twitteris accepted on input and always emitted asxâ the same normalisation the profile endpoint applies, so the two never disagree.- These fields are batched server-side across an inbox page, so
GET /chat/threadscosts no more queries atper_page=50than atper_page=1.
ChatMessage object
{
"id": "uuid",
"thread_id": "uuid",
"type": 3,
"type_label": "Appointment Request",
"body": null,
"metadata": { "booking_id": "uuid", "slot_id": "uuid", "session_type_id": "uuid", "is_free": true, "price": null },
"slot": { "id": "uuid", "starts_at": "âĶ", "status": 3, "is_selectable": false, "âĶ": "âĶ" },
"session_type": { "id": "uuid", "title": "Career Guidance 1:1", "âĶ": "âĶ" },
"is_mine": false,
"is_forwarded": false,
"can_forward": false,
"reactions": [{ "type": "like", "label": "Like", "emoji": "ð", "count": 2 }],
"reaction_count": 2,
"my_reaction": "like",
"can_react": true,
"sender": { "id": "uuid", "name": "Jane Doe", "avatar_url": null },
"attachments": [{ "url": "https://âĶ", "name": "brief.pdf", "size": 204800 }],
"created_at": "2026-07-19T05:30:00+00:00"
}
-
metadataisnullfor plain text/file messages; typed messages carry it as shown in steps âĪ/âĨ. -
Resolved availability â the ids inside
metadataare expanded for you, on both the single-message responses andGET .../messages. The keys are omitted entirely when they do not apply:Key Present on Shape shared_slotscalendar_share(type 2)array of AvailabilitySlot, ordered by starts_atslotappointment_request(type 3)one AvailabilitySlot session_typetypes 2 & 3 one SessionType AvailabilitySlot / SessionType use the same shape as the public availability endpoints â see availability-api.md. Values are read live, so
status/is_selectablealways reflect the slot now, not at share time. -
System messages (
type: 5) have nosenderfield and a human-readablebody(e.g. the meeting link or payment link) plus link metadata. -
is_forwardedmarks a message carried in from another conversation. It is the whole story the API tells about the origin â there is no source thread or original author to render.can_forwardsays whether this message may be passed on; see Forwarding a message.
ChatMedia object
Returned by GET /chat/threads/{thread}/media â see
Shared media, files & links.
{
"id": "uuid",
"message_id": "uuid",
"type": 1,
"type_key": "image",
"type_label": "Image",
"name": "shot.png",
"mime_type": "image/png",
"size": 204800,
"url": "https://âĶ/shot.png",
"is_mine": false,
"is_voice": false,
"sender": { "id": "uuid", "name": "Jane Doe", "avatar_url": null },
"caption": "look at this",
"created_at": "2026-07-19T05:30:00+00:00"
}
ididentifies the attachment, not the message.message_idpoints back at the bubble it was sent in, so tapping a tile can jump to that point in the history.type/type_key/type_labelare the bucket, derived frommime_type(never stored).captionis thebodytyped alongside the file, ornullâ useful as gallery alt text.senderis omitted when the attachment came from a system message.is_voicemarks a recording sent from the mic rather than a file someone uploaded. Voice notes stay in the audio bucket â split the tab on this flag if you want them apart.urlis the original file; no thumbnail conversion exists.
ChatLink object
Returned by GET /chat/threads/{thread}/links. One entry per message, not per URL â a message with
three links is one entry carrying three urls.
{
"message_id": "uuid",
"urls": ["https://laravel.com/docs", "http://example.com/a"],
"body": "read https://laravel.com/docs and http://example.com/a.",
"is_mine": true,
"sender": { "id": "uuid", "name": "Jane Doe", "avatar_url": null },
"created_at": "2026-07-19T05:30:00+00:00"
}
bodyis the full message text, so the client can show the sentence the link sat in.- No preview metadata is fetched â render the host from the URL, or resolve previews yourself.
Scope notes (this cut)
- Delivery is in-app notifications + websockets (see Realtime); mobile push is deferred until there is an app to push to.
is_mineis omitted from broadcast frames only â REST responses still carry it.- The schema is group-capable, but only the direct (1:1) surface is wired.
- Blocking/mute is not built.