MeetyyAPI
Documentation / API Reference / Realtime & WebSockets

Realtime & WebSockets

Everything the platform pushes instead of waiting to be polled: chat messages, read receipts, typing indicators, and notifications. One websocket connection carries all of it.

  • Server: Laravel Reverb (Pusher protocol β€” any Pusher client works, no vendor account)
  • Auth: the same Sanctum bearer token as the REST API
  • Connect: wss://{host}/app/{REVERB_APP_KEY}
  • Authorize: POST /api/broadcasting/auth β€” note: not under /api/v1/

Realtime is an accelerator, never the source of truth. Every frame documented here has a REST equivalent. A client that misses frames (backgrounded, tunnel flapped, socket dropped) must still be correct after a plain refetch β€” so treat these as "something changed, here is the change already parsed", not as the only way the change is ever delivered.


Quick start

npm install laravel-echo pusher-js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

const echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,        // the API domain
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],

    // Private and presence channels are authorized by the API, with the same
    // bearer token the REST calls use.
    authEndpoint: '/api/broadcasting/auth',
    auth: {
        headers: {
            Authorization: `Bearer ${token}`,
            Accept: 'application/json',
        },
    },
});

Then subscribe β€” notifications on the user channel, one thread channel per open conversation:

echo.private(`users.${me.id}.notifications`)
    .notification((frame) => inbox.unshift(frame));

echo.join(`chat.threads.${threadId}`)
    .here((users)   => setOnline(users))
    .joining((user) => addOnline(user))
    .leaving((user) => removeOnline(user))
    .listen('.chat.message.sent',     (message) => appendMessage(message))
    .listen('.chat.message.read',     (receipt) => markSeenUpTo(receipt.read_at))
    .listen('.chat.message.reaction', (frame)   => setReactions(frame.message_id, frame.reactions))
    .listen('.chat.typing',           (frame)   => showTyping(frame));

The leading dot in .listen('.chat.message.sent') is required. It tells Echo the name is a literal wire name, not a PHP class to be namespaced. Without it Echo listens for App\Events\Chat\chat.message.sent and you will silently receive nothing.


Channels

Channel Type Who may subscribe Carries
users.{userId}.notifications private that user only every in-app notification, live
chat.threads.{threadId} presence thread participants only messages, read receipts, typing
users.{userId}.presence private anyone allowed to see that user online platform-wide online/away/offline β€” see presence-api.md

Authorization is enforced server-side in routes/channels.php on every subscribe β€” the user channel by id comparison, the thread channel by a chat_participants membership check, the presence channel by the target's privacy switch and the block list. A non-participant gets 403 from the auth endpoint and never receives a frame. There is no client-side gate to get wrong.

chat.threads.{threadId} is a presence channel

Join it with Echo's join(), not private(). A private- subscription authorizes fine and then receives nothing at all, because the events broadcast on the presence- channel β€” this is the single most likely reason a correct-looking integration sees no messages.

Presence pays for itself: here / joining / leaving give you the roster of who currently has the conversation open, so "online now" and "left the chat" need no extra endpoint. Each member arrives as:

{ "id": "uuid", "name": "Jane Doe", "avatar_url": "https://…" }

Events

chat.message.sent

A message landed in the thread β€” text, file, system, or an appointment bubble.

The payload is the exact ChatMessage object the REST API returns (messaging-api.md), so you can push it straight into an already-fetched list with no second shape to maintain:

{
  "id": "uuid",
  "thread_id": "uuid",
  "type": 1,
  "type_label": "Text",
  "body": "See you then!",
  "metadata": null,
  "sender": { "id": "uuid", "name": "Jane Doe", "avatar_url": null },
  "attachments": [],
  "created_at": "2026-07-19T05:30:00+00:00"
}

Two differences from the REST shape, both deliberate:

  • No is_mine. It is viewer-relative and a broadcast has many viewers, so shipping it would mean shipping it wrong. Derive it: message.sender?.id === me.id.
  • shared_slots / slot / session_type are resolved live for calendar_share (type 2) and appointment_request (type 3) frames, same as the REST list. A slot shared yesterday may since have been booked, so render its status / is_selectable from the frame rather than caching it.

You will not receive your own messages. Every chat event broadcasts with toOthers(), which suppresses the echo to the socket that caused it β€” your client already has the message from the 201 response, and delivering both is what produces duplicate bubbles. This only works if you send the X-Socket-ID header on your REST calls (see Socket ID below).

System messages (type: 5, e.g. "meeting link is ready") broadcast too and have no sender.

chat.message.read

The other participant moved their read cursor β€” flip your sent bubbles to "seen".

{
  "thread_id": "uuid",
  "user_id": "uuid",
  "read_at": "2026-07-19T05:31:00+00:00"
}

The cursor is a timestamp, not a message id: everything created at or before read_at is read. That is the same rule unread_count is computed from, so a client that marks created_at <= read_at as seen can never disagree with the inbox badge.

Fired by POST /chat/threads/{thread}/read.

chat.message.reaction

Somebody added, changed or removed an emoji on a message β€” redraw that bubble's pills.

{
  "thread_id": "uuid",
  "message_id": "uuid",
  "user_id": "uuid",
  "type": "love",
  "reactions": [{ "type": "love", "label": "Love", "emoji": "❀️", "count": 1 }],
  "reaction_count": 1
}

It carries the resulting tally, not a delta. Add, swap and remove all arrive as "these are the pills now", so replace the row wholesale β€” a client applying +1/-1 drifts permanently the first time it misses a frame.

type is null when that person cleared their reaction; user_id says whose it was either way. There is no my_reaction on the frame β€” it is the same payload for everyone β€” so derive it: if user_id is you, yours is type; otherwise leave yours alone.

You do receive your own frames here, unlike chat.message.sent. Your other devices need the update and no response payload is racing it, so applying it unconditionally is safe.

Fired by POST and DELETE /chat/messages/{message}/reactions. See messaging-reactions-response.md.

chat.typing

{
  "thread_id": "uuid",
  "user": { "id": "uuid", "name": "Jane Doe" },
  "is_typing": true
}

Persists nothing β€” the frame is the feature. It is also the one event that bypasses the queue and broadcasts synchronously, because a typing bubble that arrives after a queue hop is worse than no typing bubble.

Always time your own indicator out (~3–4s since the last frame). A client that crashes mid-keystroke never sends is_typing: false, and without a local timeout the other side is stuck watching a phantom type forever.

Notification frames

On users.{userId}.notifications, delivered through Echo's .notification() rather than .listen(). Shape mirrors the notification REST list-item so it can be unshift()ed into a fetched list:

{
  "id": "uuid",
  "type": "chat.new_message",
  "type_label": "New Message",
  "data": { "…": "type-specific payload" },
  "read_at": null,
  "created_at": "2026-07-19T05:30:00+00:00"
}

Deliberately carries no unread_count β€” that would cost a COUNT(*) per recipient on every fan-out. Increment your own badge and reconcile from the REST endpoint on next load. Full type catalog: notification-system.md.

A new chat message produces two frames for a recipient who has the thread open: the chat.message.sent frame on the thread channel and a chat.new_message notification on their user channel. That is intended β€” one renders the bubble, the other drives the badge and the inbox. Suppress the notification toast while the thread is focused.


Sending typing state

The only REST endpoint that exists purely to feed the socket layer.

POST /api/v1/chat/threads/{thread}/typing
Field Rules Default
is_typing sometimes|boolean true
{ "is_typing": true }

Response 200 β†’ { "status": "success", "message": "Typing state broadcast.", "data": {} }

Code Meaning
200 Broadcast
401 Missing/invalid token
404 Thread not found, or you are not a participant
422 is_typing is not a boolean
429 Rate limited β€” 120 requests/minute

That ceiling is why you must not call this on every keystroke. Debounce: send once when composing starts, then at most every ~2s while it continues, and once with is_typing: false when the field empties or the thread closes.


Chat, end to end

The full client for one open conversation β€” presence, messages, receipts, and typing on a single subscription. Everything below assumes the echo instance from Quick start.

The HTTP client

One interceptor, and the duplicate-message problem never happens:

import axios from 'axios';

const api = axios.create({ baseURL: '/api/v1' });

api.interceptors.request.use((config) => {
    config.headers.Authorization = `Bearer ${token}`;

    // Excludes your own connection from broadcasts you cause. Read it per
    // request, not once at startup: it is null before the handshake and
    // changes on every reconnect.
    const socketId = echo.socketId();
    if (socketId) {
        config.headers['X-Socket-ID'] = socketId;
    }

    return config;
});

Opening a thread

function openThread(threadId, view) {
    const channel = echo.join(`chat.threads.${threadId}`);   // join(), NOT private()
    const typingTimers = new Map();

    const hideTyping = (userId) => {
        clearTimeout(typingTimers.get(userId));
        typingTimers.delete(userId);
        view.hideTyping(userId);
    };

    channel
        // ── who else has this conversation open right now ──────────────────
        .here((users) => view.setOnline(users))
        .joining((user) => view.addOnline(user))
        .leaving((user) => {
            view.removeOnline(user);
            hideTyping(user.id);                  // they cannot still be typing
        })

        // ── a message landed (never one of yours β€” see toOthers) ───────────
        .listen('.chat.message.sent', (message) => {
            view.appendMessage({
                ...message,
                is_mine: message.sender?.id === me.id,   // omitted from frames
            });
            hideTyping(message.sender?.id);              // sending ends typing

            // The thread is on screen, so it is read the moment it arrives.
            // This also emits the receipt the other side renders as "seen".
            if (view.isFocused) {
                api.post(`/chat/threads/${threadId}/read`);
            }
        })

        // ── the other side read up to a point in time ──────────────────────
        .listen('.chat.message.read', ({ user_id, read_at }) => {
            if (user_id !== me.id) {
                view.markSeenUpTo(read_at);   // every message with created_at <= read_at
            }
        })

        // ── typing, with a local timeout ───────────────────────────────────
        .listen('.chat.typing', ({ user, is_typing }) => {
            if (!is_typing) {
                return hideTyping(user.id);
            }

            clearTimeout(typingTimers.get(user.id));
            view.showTyping(user);
            // A client that crashes mid-keystroke never sends is_typing:false.
            // Without this the bubble stays forever.
            typingTimers.set(user.id, setTimeout(() => hideTyping(user.id), 4000));
        });

    // Call when the conversation closes β€” leave() handles the presence prefix;
    // leaveChannel() would need the literal `presence-chat.threads.{id}`.
    return () => {
        typingTimers.forEach(clearTimeout);
        echo.leave(`chat.threads.${threadId}`);
    };
}

Sending a message

Your own message arrives in the 201, not over the socket β€” render it from the response:

async function send(threadId, body) {
    const { data } = await api.post(`/chat/threads/${threadId}/messages`, { body });

    // Envelope is { status, message, data: { message } }.
    view.appendMessage({ ...data.data.message, is_mine: true });
}

If you render optimistically before the response, reconcile on the returned id β€” do not also wait for a chat.message.sent frame for it. toOthers() guarantees it will never come, so the placeholder would never resolve.

Sending typing state

The endpoint allows 120/min, so a raw keystroke handler will hit 429 on a fast typist:

let lastSentAt = 0;
let stopTimer;

function onKeystroke(threadId) {
    const now = Date.now();

    // At most one "started" frame every 2s while composing continues.
    if (now - lastSentAt > 2000) {
        lastSentAt = now;
        api.post(`/chat/threads/${threadId}/typing`);   // is_typing defaults to true
    }

    // …and one "stopped" frame once the keystrokes stop.
    clearTimeout(stopTimer);
    stopTimer = setTimeout(() => {
        lastSentAt = 0;
        api.post(`/chat/threads/${threadId}/typing`, { is_typing: false });
    }, 3000);
}

Also send is_typing: false when the composer is cleared, when the message is sent, and when the thread closes β€” the other side's 4s timeout is a safety net, not the mechanism.

Keeping the inbox live

Do not join a thread channel per row. An inbox of 50 conversations would mean 50 presence subscriptions, each announcing you as present in a conversation you do not have open.

The user notification channel already carries a chat.new_message frame for every thread you participate in, which is exactly what the list needs:

echo.private(`users.${me.id}.notifications`)
    .notification((frame) => {
        if (frame.type === 'chat.new_message') {
            // frame.data = { thread_id, message_id, sender_id, sender_name, preview }
            view.bumpThread(frame.data.thread_id, frame.data.preview);
            return;
        }

        view.pushNotification(frame);
    });

Join the thread channel when a conversation is opened, leave it when it closes. One presence subscription at a time.


Socket ID

Send X-Socket-ID: {echo.socketId()} on every REST write. It is what lets the server exclude your own connection from a broadcast it caused:

axios.defaults.headers.common['X-Socket-ID'] = echo.socketId();

Echo's own axios integration does this automatically; a hand-rolled fetch client does not. Get it wrong and every message you send comes back to you as a duplicate bubble β€” the classic chat realtime bug, and it will look like a server problem.

Read socketId() after the connection is established, and refresh it on reconnect: it is null before the handshake and changes every time the socket comes back.


Reconnection

Pusher-protocol clients reconnect on their own, but frames sent while you were away are gone β€” Reverb has no replay buffer. So on every connected event after the first:

  1. Refetch GET /chat/threads/{thread}/messages for any open thread (or everything after your newest known created_at).
  2. Refetch GET /chat/threads to resync unread counts.
  3. Re-read echo.socketId() and update the header.

This is the same path the REST-only fallback takes, which is the point: if a client is correct after a cold start, it is correct after a reconnect.


Deployment (Coolify Β· Nixpacks)

Production is a single Nixpacks container built from nixpacks.toml at the repo root; supervisord runs nginx, php-fpm, the queue workers, the scheduler, and Reverb side by side. See notification-system.md Β§3.3 for the full supervisor table.

The websocket-specific pieces:

Piece Setting Why it is that way
Supervisor program worker-reverb β†’ reverb:start --host=0.0.0.0 --port=8081, numprocs=1 Connection and channel state live in memory. A second process would only see half the subscribers β€” scaling out needs REVERB_SCALING_ENABLED=true + Redis, not more processes
Nginx location ^~ /app β†’ 127.0.0.1:8081 Prefix match covers both /app/{key} (client socket) and /apps/{id}/events (signed publish). ^~ stops the \.php$ regex handing an upgrade request to php-fpm
Nginx timeouts proxy_read_timeout 3600s Chat sockets are idle by nature and Reverb pings every 60s. A 60s read timeout races the keepalive and churns reconnects on quiet threads
Supervisord minfds=65535 One file descriptor per open socket, inherited by every child. The stock 1024 caps concurrent connections at ~1k
Queue broadcasts queue in the worker's --queue= list chat.message.sent / chat.message.read are queued. Drop the queue name and messages persist but never arrive

Coolify environment variables (server side β€” the container reaches Reverb over loopback; clients come in through the public domain):

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=<generate fresh β€” never reuse the dev values>
REVERB_APP_KEY=<fresh>
REVERB_APP_SECRET=<fresh>

# Where the app POSTs events to publish them. Loopback, so publishing never
# leaves the container or pays for TLS.
REVERB_HOST=127.0.0.1
REVERB_PORT=8081
REVERB_SCHEME=http

REVERB_APP_KEY is public by design β€” clients need it to connect. REVERB_APP_SECRET signs publishes and must never reach a client.

Coolify's Traefik passes websocket upgrades through untouched, so nothing extra is needed at the proxy layer.

Verifying a deploy

supervisorctl status worker-reverb                 # RUNNING, uptime climbing, not restarting
php artisan queue:monitor broadcasts               # ~0 and not growing
tail -f /var/log/worker-reverb.log                 # connections appear as clients join

Troubleshooting

Symptom Cause
Subscribe returns 403 Not a participant of that thread β€” or a stale/missing bearer token on authEndpoint
Subscribe returns 401 Token not reaching /api/broadcasting/auth; check the auth.headers block, and that the path has no /v1
Connects, authorizes, no messages Subscribed with private() instead of join(), or missing the leading dot in .listen('.chat.message.sent')
Every sent message appears twice X-Socket-ID not sent on the POST, so toOthers() had no socket to exclude
Messages save but never broadcast broadcasts missing from the worker --queue= list, or no queue:work running at all
Sockets drop every ~60s A proxy read timeout in front of nginx that is shorter than Reverb's ping interval
Nothing broadcasts anywhere BROADCAST_CONNECTION still log or null

Testing

Broadcasts dispatch through the event dispatcher, so Event::fake() intercepts them like any other event β€” no Reverb process needed, and phpunit.xml pins BROADCAST_CONNECTION=null:

Event::fake([MessageSent::class]);

$this->actingAs($sender, 'sanctum')
    ->postJson(route('chat.threads.messages.store', $thread), ['body' => 'hi'])
    ->assertCreated();

Event::assertDispatched(MessageSent::class, fn ($e) => $e->broadcastWith()['body'] === 'hi');

See tests/Feature/Chat/ChatBroadcastTest.php for the full set, including presence-channel authorization against the real broadcaster.