/v1/me/broadcasting/authWhat 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:
| Channel | What arrives |
|---|---|
The channel on your profile | Your notifications, as they are raised |
The channel of a mailbox | mail.received when a message lands in a mailbox you can read |
The channel of a calendar | calendar.changed when anything in a calendar you can see moves |
The channel of the ticket summary | The firm's support queue: tickets arriving, every message including the team's notes, and who holds what |
The channel of a ticket | One 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:
| Event | When |
|---|---|
ticket.created | A ticket was opened, by a trader or by the team |
ticket.message.added | Any message on any ticket, the team's notes included |
ticket.status.changed | A ticket moved: an answer, a handover, a rename, a new priority, an assignment, the assistant blocked, or the end |
ticket.assigned | The agent who holds a ticket changed |
ticket.updated | Something only the team sees changed, such as the tags |
On the channel of a ticket:
| Event | When |
|---|---|
ticket.message.added | A message the trader can see. Never a note |
ticket.status.changed | The same as on the queue's channel, for this ticket |
ticket.read.receipt | The trader read what the team, the assistant or the system wrote |
stream_start, text_delta, tool_call, stream_end | The assistant writing an answer |
client-typing | The 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
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_idempotency_key | Every POST needs one |
| 403 | channel_not_allowed | Not one of the five, a mailbox, calendar or ticket you cannot see, or a ticket channel of another firm |
| 403 | insufficient_scope | This device does not hold me:read |
| 422 | validation_failed | channel_name or socket_id missing |