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
ââ 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"
},
"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 }]
| Code | When |
|---|---|
| 404 | Thread not found, or you are not a participant |
| 422 | You are the initiator and the thread is still pending |
List my threads (inbox)
GET /api/v1/chat/threads?per_page=15&search=tom&filter=unread
Recency-ordered, paginated. Each thread carries the viewer's unread_count and the other
participant.
| 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. 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 (shape below). 404 if not a participant.
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.
Mark thread as read
POST /api/v1/chat/threads/{thread}/read
No body. Moves your read cursor to now â unread_count drops to 0.
Response 200 â { "status": "success", "message": "Thread marked as read.", "data": {} }
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
confirmed, this booking's seats held to a 24 h payment deadline; a signed checkout link is generated, posted into the thread as a system message, and returned aspayment_url. Once the mentee pays (existing web checkout), the meeting link is generated and posted into the thread.
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": "confirmed",
"payment_url": "https://meetyy.test/payments/checkout/9c80âĶ?expires=âĶ&signature=âĶ"
}
}
Response 200 (free) â same without 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 for 24 h while holds expire after 10 minutes, 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 |
| âĒ | 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 |
| âĢ | 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 |
| Booking status | requested · confirmed · paid · completed · cancelled · refunded |
| Notification types | chat.message_request · chat.new_message · chat.calendar_share · chat.appointment_request |
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) |
| 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,
"participant": {
"id": "uuid",
"name": "Jane Doe",
"username": "jane",
"avatar_url": null,
"connection_status": "connected"
},
"created_at": "2026-07-19T05:00:00+00:00"
}
participantâ the other user in the direct thread (never the viewer).last_message_previewâ text/system messages are truncated to 120 chars; other types render as"Shared availability","Appointment request","Sent a file".
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,
"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.
Scope notes (this cut)
- Delivery is the synchronous database notification channel only â websockets/push deferred.
- The schema is group-capable, but only the direct (1:1) surface is wired.
- Blocking/mute is not built.