:::endpoint 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.

## Six channels, and no others

An app may ask for these:

| Channel                                                     | What arrives                                                     |
| ----------------------------------------------------------- | ---------------------------------------------------------------- |
| The `channel` on [your profile](/docs/api/apps/profile)     | Your notifications, as they are raised                           |
| The `channel` of a [mailbox](/docs/api/mail/mailboxes)      | `mail.received` when a message lands in a mailbox you can read   |
| The `channel` of a [calendar](/docs/api/calendar/calendars) | `calendar.changed` when anything in a calendar you can see moves |
| The `channel` of the [ticket summary](/docs/api/admin/tickets-summary) | The firm's support queue: tickets arriving, every message including the team's notes, and who holds what |
| The `channel` of a [ticket](/docs/api/admin/ticket)         | One conversation as the trader sees it, and the assistant writing |
| The `channel` of a [conversation with the assistant](/docs/api/apps/ai-conversations) | The answer being written. See [the assistant](/docs/api/apps/ai-overview#listening-for-the-answer) |

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](/docs/api/admin/overview), and a channel of any other firm is
refused.

A conversation with the assistant is yours alone, in the firm the request acts
in, and needs a token holding `ai:read`. Our own staff name that firm with the
`Propper-Workspace` header here too.

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

```bash
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:

```json
{ "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](/docs/api/calendar/events) 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:

```json
{ "calendar_id": "cal_r4d8h2m6t9w3k7nb" }
```


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

```json
{
    "message": {
        "id": "msg_h3k8q2w7n4r9t5vx",
        "thread_id": "thr_c4n8w2k7p5x9m3qd",
        "address_id": "mbx_w6sy3skvgydh5e3b",
        "folder": "inbox",
        "from_email": "jamie@example.com",
        "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](/docs/api/mail/messages) carries, so `id` is what
[opening it](/docs/api/mail/message) 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](/docs/api/apps/notifications), without `object`, `read_at` and
`created_at`:

```json
{
    "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](/docs/api/apps/notifications), so a notice
that arrives here is one you can [mark read](/docs/api/apps/notifications-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](/docs/api/apps/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](/docs/api/admin/tickets-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](/docs/api/admin/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](/docs/api/admin/tickets)
carries. Most say what changed and no more: ask for the ticket, the list or
[the conversation](/docs/api/admin/ticket-messages) 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`:

```json
{
    "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.

```json
{
    "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:

```json
{
    "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`:

```json
{
    "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:

```json
{
    "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 six, a mailbox, calendar or ticket you cannot see, a ticket channel of another firm, or a conversation that is not yours, is in another firm, or is asked for without `ai:read` |
| 403    | `insufficient_scope`      | This device does not hold `me:read`              |
| 422    | `validation_failed`       | `channel_name` or `socket_id` missing            |