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

 ── 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 as payment_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"
}
  • 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.

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.