MeetyyAPI
Documentation / API Reference / Messaging & Chat

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 use multipart/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"
    }
  }
}
  • voice is present only on a voice bubble (type: 6) — switch on its absence rather than sniffing mime types. duration_ms may be null if 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_edit is always false) — 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 voice to null alongside body and attachments.
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(), not private() — the events broadcast on the presence channel; a private subscription authorizes and then receives nothing.
  • Send X-Socket-ID on your POSTs — messages broadcast with toOthers(), so this is what stops your own message coming back as a duplicate bubble on top of the 201 response.
  • Derive is_mine yourself from sender.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 requested with awaiting_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 as payment_url. Once the mentee pays, the booking goes straight to confirmed and the meeting link is generated and posted into the thread.

Why requested and not confirmed. Bookings are otherwise pay-first: the mentee is charged up front and confirmed now 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 to confirmed instead of back into the mentor's queue. Read awaiting_payment, not status, 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 to GET /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_until is null unless is_muted is 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_rating is a string, not a float — a 2-decimal fixed-point value such as "4.50", matching how average_rating is rendered everywhere else in this API. It is null (not "0.00") when count is 0, so an unreviewed user is distinguishable from a badly reviewed one. count is always an integer.
  • What reviews counts — 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_sessions sums 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_links always carries all four keys, null included, so a client can map straight to icons without key-checking. twitter is accepted on input and always emitted as x — 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/threads costs no more queries at per_page=50 than at per_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"
}
  • metadata is null for plain text/file messages; typed messages carry it as shown in steps â‘Ī/â‘Ĩ.

  • Resolved availability — the ids inside metadata are expanded for you, on both the single-message responses and GET .../messages. The keys are omitted entirely when they do not apply:

    Key Present on Shape
    shared_slots calendar_share (type 2) array of AvailabilitySlot, ordered by starts_at
    slot appointment_request (type 3) one AvailabilitySlot
    session_type types 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_selectable always reflect the slot now, not at share time.

  • System messages (type: 5) have no sender field and a human-readable body (e.g. the meeting link or payment link) plus link metadata.

  • is_forwarded marks 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_forward says 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"
}
  • id identifies the attachment, not the message. message_id points back at the bubble it was sent in, so tapping a tile can jump to that point in the history.
  • type / type_key / type_label are the bucket, derived from mime_type (never stored).
  • caption is the body typed alongside the file, or null — useful as gallery alt text.
  • sender is omitted when the attachment came from a system message.
  • is_voice marks 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.
  • url is 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"
}
  • body is 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_mine is 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.