Listen on a websocket

.md

Sign an app onto a channel so it can see things happen while it is open.

Scopesme:read
POST/v1/me/broadcasting/auth

What the socket library calls when it subscribes to a private channel. Your connection hands you a socket_id, you send it here with the channel you want, and you get back the signature the socket needs.

Point your client's auth endpoint at this URL and send the token in the Authorization header, exactly as on every other call.

Five channels, and no others

An app may ask for these:

ChannelWhat arrives
The channel on your profileYour notifications, as they are raised
The channel of a mailboxmail.received when a message lands in a mailbox you can read
The channel of a calendarcalendar.changed when anything in a calendar you can see moves
The channel of the ticket summaryThe firm's support queue: tickets arriving, every message including the team's notes, and who holds what
The channel of a ticketOne conversation as the trader sees it, and the assistant writing

Subscribe to each channel exactly as the API gives it, rather than building the name from an id.

The two ticket channels are for the Propper app: a token holding admin:read, of somebody with view_tickets, in the firm the request acts in. Our own staff name that firm with the Propper-Workspace header, here as on every admin endpoint, and a channel of any other firm is refused.

Anything else is refused here. The channel definitions behind this endpoint were written for the browser, where the address bar already says which propfirm somebody is looking at, and a token has no address bar.

What to use it for, and what not to

A socket is for the screen somebody is looking at right now: mail appearing in an open list, a conversation growing while it is read.

It is not how an app finds out about mail when it is closed. Nothing arrives over a socket that is not connected, and a mobile operating system will not keep one alive in the background. Close it when the app goes to the background and rely on push notifications there.

Request

curl -X POST https://api.proppertrading.com/v1/me/broadcasting/auth \
  -H "Authorization: Bearer ptat_9f2c1a44_3b8e7d2f5c9a1b4e6d8f0a2c4e6b8d1f" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f4a5b6c-7d8e-4f90-a1b2-c3d4e5f60718" \
  -d '{"channel_name": "private-App.Models.User.usr_hraj4wtnqgrkeu9b", "socket_id": "123456.789012"}'

Response

The shape the socket expects, which is the broadcaster's contract rather than ours, so it is handed back as it comes:

{ "auth": "qjumnez25xejvxzahi9f:94750334014f307fd797e0de0b466e4a08b1257c" }

The events

calendar.changed says only that something changed, on purpose: a calendar can be shared at the busy level, where the words may not leave the server, and a socket cannot tell who is listening at which level. Ask for the appointments again. Which calendar it was is the channel it arrived on, and the calendar_id inside it is that calendar's id from the calendar list:

{ "calendar_id": "cal_r4d8h2m6t9w3k7nb" }

mail.received on a mailbox channel carries a list row, never a body:

{
    "message": {
        "id": "msg_h3k8q2w7n4r9t5vx",
        "thread_id": "thr_c4n8w2k7p5x9m3qd",
        "address_id": "mbx_w6sy3skvgydh5e3b",
        "folder": "inbox",
        "from_email": "[email protected]",
        "from_name": "Jamie Rivera",
        "subject": "Payout request",
        "preview": "Hi, I passed the evaluation last week",
        "has_attachments": false,
        "message_at": "2026-08-19T13:30:36.000000Z"
    },
    "unread_inbox": true
}

Enough to add a row and tick a badge. The ids are the ones a list row carries, so id is what opening it takes. Reading it still means opening it, which is where the body is sanitised and where being allowed to read it is checked again.

Spam is broadcast too, with its folder attached. Silently withholding it would make a false positive invisible instead of merely tucked away.

On the channel of your profile, a notification arrives as the event Illuminate\Notifications\Events\BroadcastNotificationCreated, which is the one the .notification() listener of Laravel Echo waits for. It carries the fields of a notification, without object, read_at and created_at:

{
    "id": "9f2c1a44-3b8e-4d2f-5c9a-1b4e6d8f0a2c",
    "type": "App\\Notifications\\NewDeviceSignedInNotification",
    "title_key": "notifications/new-device-signed-in.title",
    "message_key": "notifications/new-device-signed-in.message",
    "params": { "device": "Jamie iPhone", "app": "Propper Mail" },
    "action_url": "/settings/security",
    "action_label_key": "notifications/new-device-signed-in.action_label"
}

id is the same as in the list, so a notice that arrives here is one you can mark read. type is not the same. Here it is the name of the class that raised the notice, where the list gives a short name such as new_device_signed_in.

A notice carries no id other than its own. To find the device behind a sign-in notice, ask for your sessions again.

The ticket channels

The firm's queue and each ticket have a channel of their own. The trader listens on the ticket's channel too, so what only the team may see never goes there: internal notes and who holds the ticket travel on the queue's channel alone. With a ticket open, listen on both.

On the channel of the ticket summary:

EventWhen
ticket.createdA ticket was opened, by a trader or by the team
ticket.message.addedAny message on any ticket, the team's notes included
ticket.status.changedA ticket moved: an answer, a handover, a rename, a new priority, an assignment, the assistant blocked, or the end
ticket.assignedThe agent who holds a ticket changed
ticket.updatedSomething only the team sees changed, such as the tags

On the channel of a ticket:

EventWhen
ticket.message.addedA message the trader can see. Never a note
ticket.status.changedThe same as on the queue's channel, for this ticket
ticket.read.receiptThe trader read what the team, the assistant or the system wrote
stream_start, text_delta, tool_call, stream_endThe assistant writing an answer
client-typingThe trader is typing

Every event names the ticket by the id the list carries. Most say what changed and no more: ask for the ticket, the list or the conversation again for the rest.

ticket.created carries the new ticket, with the trader as user_id and the agent holding it as assigned_admin_id:

{
    "ticket": {
        "id": "tkt_wm9qfkgsnpfhtzfj",
        "subject": "Your trades on Friday",
        "status": "awaiting_trader",
        "user_id": "usr_ec8dc3bqfsuh2p5r",
        "assigned_admin_id": "usr_pj68ba8tmrbb7fxb",
        "last_message_at": "2026-10-15T22:30:22+00:00",
        "created_at": "2026-10-15T22:30:22+00:00"
    }
}

ticket.message.added carries the message, with the same id as in the conversation but in the shape the website's screens share with the trader's: sender_type for from, is_internal for internal, and has_image where the conversation has image_url. Who on the team wrote it, the event and read_at are only in the conversation.

{
    "message": {
        "id": "tmsg_ua7ypkjx6kas5gvf",
        "ticket_id": "tkt_cbqjrnz84cq45ca7",
        "sender_type": "admin",
        "body": "Hi Leila, a larger account is a new challenge. This is where you find them.",
        "has_image": true,
        "image_name": "challenges.png",
        "is_streaming": false,
        "is_internal": false,
        "via_email": false,
        "created_at": "2026-10-15T22:29:42+00:00"
    }
}

ticket.status.changed carries where the ticket stands. Who holds it is left out, because the trader hears this one too:

{
    "ticket_id": "tkt_cbqjrnz84cq45ca7",
    "status": "resolved",
    "closed_at": "2026-10-15T22:30:07+00:00",
    "ai_enabled": false,
    "ai_blocked": true
}

ticket.assigned carries the agent who holds the ticket now, null for nobody. A change of team alone arrives as ticket.status.changed:

{
    "ticket_id": "tkt_cbqjrnz84cq45ca7",
    "assigned_admin_id": "usr_afekquw8p97wv6tb"
}

ticket.updated carries only ticket_id.

ticket.read.receipt carries the messages the trader just read, and when:

{
    "ticket_id": "tkt_cbqjrnz84cq45ca7",
    "message_ids": ["tmsg_ua7ypkjx6kas5gvf"],
    "read_at": "2026-10-15T22:41:03+00:00"
}

The four stream events are the assistant writing, as the AI relays them. Each carries its type, an id, an invocation_id and a timestamp. stream_start comes once per answer. text_delta brings the next piece of text in delta, with a message_id that is the same for every piece of one answer. tool_call names a tool the assistant used, in tool_name, with its arguments. stream_end closes the answer with a reason. The pieces are for drawing the answer as it is written: the finished answer arrives after them as ticket.message.added from ai, and that message is the one to keep.

client-typing is not sent by the server. The trader's browser sends it to the channel while they type, with typing true, at most every two seconds, and with typing false when they send or leave the box. Treat a few seconds of silence as stopped too: a tab that closes says nothing.

Errors

StatusCodeMeaning
400missing_idempotency_keyEvery POST needs one
403channel_not_allowedNot one of the five, a mailbox, calendar or ticket you cannot see, or a ticket channel of another firm
403insufficient_scopeThis device does not hold me:read
422validation_failedchannel_name or socket_id missing