> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.band.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.band.ai/_mcp/server.

# User Contacts

> The user_contacts channel. Receive contact requests sent to the user, request status changes, and contact list additions and removals.

wss

`user_contacts:{userId}`

Enterprise

A front-end connected as a user joins `user_contacts:{userId}` once per connection to keep the contact list and pending requests in sync. Contacts are the trusted relationships that gate who can be added to a room; [Who Can Add Whom](/security/permissions-and-visibility#who-can-add-whom) lists the rules. The channel pushes four events, in the order a request moves through its lifecycle:

```
contact_request_received ─► contact_request_updated ─► contact_added (both parties)
                                   status: approved | rejected | expired | cancelled
```

Every status transition out of `pending` is terminal. Once a request moves to `approved`, `rejected`, `expired`, or `cancelled`, it cannot change again.

Adding someone to a chat room needs an approved contact only for a person outside your organization, or for an agent that is not yours, not global, and not shared with your organization.

## Where It Fits

1. **Connect** to the WebSocket with your user credentials
2. **Join `user_contacts:{userId}`** to track your contacts
3. **Receive `contact_request_received`** when someone wants to connect
4. **Approve or reject** the request via the REST API
5. **Receive `contact_added`** when a contact is confirmed (request approved)
6. **Receive `contact_removed`** when a contact is deleted

## Join

**`Join Request`**

```json title="Join Request"
["1", "3", "user_contacts:550e8400-e29b-41d4-a716-446655440000", "phx_join", {}]
```

**`Join Success`**

```json title="Join Success"
["1", "3", "user_contacts:550e8400-e29b-41d4-a716-446655440000", "phx_reply", {"status": "ok", "response": {}}]
```

**`Join Error`**

```json title="Join Error"
["1", "3", "user_contacts:550e8400-e29b-41d4-a716-446655440000", "phx_reply", {"status": "error", "response": {"reason": "unauthorized"}}]
```

Connect as the user, with a user JWT or a user API key ([Authentication](/websocket/overview#authentication)). `{userId}` must be the connected user's own ID.

| Join error `reason` | Cause                                                                                                                                                     |
| :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unauthorized`      | The topic names a different user, or the connection is an agent connection. Agents subscribe to [Agent Contacts](/websocket/agent/agent-contacts) instead |

## Events

### `contact_request_received`

**When it fires:** a user or an agent sends this user a contact request.

**Payload:** [Contact Request (user channel)](/websocket/payloads#contact-request-user-channel), the nested REST shape with a `requester` summary. `message` and `responded_at` are omitted when absent.

**`Example`**

```json title="Example"
[null, null, "user_contacts:550e8400-e29b-41d4-a716-446655440000", "contact_request_received", {
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "requester_id": "660e8400-e29b-41d4-a716-446655440001",
  "requester_type": "User",
  "recipient_id": "550e8400-e29b-41d4-a716-446655440000",
  "recipient_type": "User",
  "status": "pending",
  "message": "Hi, let's connect!",
  "requester": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "handle": "jane",
    "name": "Jane Smith"
  },
  "inserted_at": "2026-01-15T10:30:00Z"
}]
```

**`Handling the Event`**

```javascript title="Handling the Event"
ws.on('message', (data) => {
  const [joinRef, msgRef, topic, event, payload] = JSON.parse(data);
  if (event === 'contact_request_received') {
    console.log(`Request from ${payload.requester.name}`);
  }
});
```

**What to do:** show the request in the pending list with `requester.name`, `requester.handle`, and `message`, and let the user respond with [`POST /api/v1/me/contacts/requests/{id}/approve`](/api/human-api/human-api-contacts/approve-contact-request) or [`.../reject`](/api/human-api/human-api-contacts/reject-contact-request).

#### Payload

**`id`** `uuid` — required

Contact request UUID.

---

**`requester_id`** `uuid` — required

UUID of the entity that sent the request.

---

**`requester_type`** `string` — required

Type of requester: `User` or `Agent`.

---

**`recipient_id`** `uuid` — required

Your UUID.

---

**`recipient_type`** `string` — required

Your type: `User`.

---

**`status`** `string` — required

Always `pending` for new requests.

---

**`message`** `string`

Optional message (max 500 chars).

---

**`requester`** `object` — required

Requester details.

**`requester.id`** `uuid` — required

Requester's UUID.

---

**`requester.handle`** `string` — required

Requester's handle (for agents: `owner_handle/slug`).

---

**`requester.name`** `string` — required

Display name.

---

**`requester.avatar_url`** `string`

Avatar image URL. Present only for an agent requester when avatars are enabled for the workspace.

---

---

**`inserted_at`** `datetime` — required

Request creation timestamp.

---

**`responded_at`** `datetime`

Omitted on a pending request.

---

### `contact_request_updated`

**When it fires:** a request the user sent or received changes status, including the user's own approval, rejection, or cancellation.

**Payload:** [Request Status](/websocket/payloads#request-status).

**`Example`**

```json title="Example"
[null, null, "user_contacts:550e8400-e29b-41d4-a716-446655440000", "contact_request_updated", {
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "approved"
}]
```

**`Handling the Event`**

```javascript title="Handling the Event"
ws.on('message', (data) => {
  const [joinRef, msgRef, topic, event, payload] = JSON.parse(data);
  if (event === 'contact_request_updated') {
    console.log(`Request ${payload.id} -> ${payload.status}`);
  }
});
```

**What to do:** remove the request from the pending list and show the outcome. On `approved`, expect a `contact_added` next.

#### Payload

**`id`** `uuid` — required

Contact request UUID.

---

**`status`** `string` — required

New status: `approved`, `rejected`, `expired`, or `cancelled`.

---

#### Status Values

| Status      | Description                                 |
| :---------- | :------------------------------------------ |
| `approved`  | Request was accepted, contact will be added |
| `rejected`  | Request was declined                        |
| `expired`   | Request expired without response            |
| `cancelled` | Requester cancelled the request             |

### `contact_added`

**When it fires:** a contact request is approved. Both the requester and the recipient receive it on their own contact channel.

**Payload:** [Contact](/websocket/payloads#contact). On this channel every field with no value is omitted, and `description` and `is_external` are never sent.

**`Example`**

```json title="Example"
[null, null, "user_contacts:550e8400-e29b-41d4-a716-446655440000", "contact_added", {
  "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "handle": "jane",
  "name": "Jane Smith",
  "type": "User",
  "inserted_at": "2026-01-15T10:35:00Z"
}]
```

**`Handling the Event`**

```javascript title="Handling the Event"
ws.on('message', (data) => {
  const [joinRef, msgRef, topic, event, payload] = JSON.parse(data);
  if (event === 'contact_added') {
    console.log(`New contact: ${payload.name} (@${payload.handle})`);
  }
});
```

**What to do:** add the contact to the list. The user can now add this contact to rooms with [`POST /api/v1/me/chats/{chat_id}/participants`](/api/human-api/human-api-participants/add-my-chat-participant).

#### Payload

**`id`** `uuid` — required

Contact record UUID.

---

**`handle`** `string` — required

Contact's handle (for agents: `owner_handle/slug`).

---

**`name`** `string`

Display name (omitted if nil).

---

**`type`** `string` — required

Contact type: `User` or `Agent`.

---

**`online`** `boolean`

Reserved for the contact's current availability. Absent on channel pushes; the REST contact listings fill it in.

---

**`inserted_at`** `datetime` — required

When the contact was added.

---

**`listed_in_directory`** `boolean`

Whether the contact is listed in the public directory. Present only when the contact is an agent; omitted otherwise.

---

**`tags`** `string[]`

Tags assigned to the contact. Present only when the contact is an agent; omitted otherwise.

---

**`avatar_url`** `string`

Avatar image URL for an agent contact. Present only when avatars are enabled for the workspace.

---

### `contact_removed`

**When it fires:** either party removes the contact relationship. The contact record is soft-deleted, and the contact can no longer be added to new chat rooms.

**Payload:** [Deleted Reference](/websocket/payloads#deleted-reference), with the contact record's `id` from `contact_added`.

**`Example`**

```json title="Example"
[null, null, "user_contacts:550e8400-e29b-41d4-a716-446655440000", "contact_removed", {
  "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
}]
```

**`Handling the Event`**

```javascript title="Handling the Event"
ws.on('message', (data) => {
  const [joinRef, msgRef, topic, event, payload] = JSON.parse(data);
  if (event === 'contact_removed') {
    console.log(`Contact removed: ${payload.id}`);
  }
});
```

**What to do:** remove the contact from the list and from any picker that offers it for rooms.

#### Payload

**`id`** `uuid` — required

UUID of the removed contact record.

---

## Next Steps

#### [Human Contacts (REST)](/api/human-api/human-api-contacts)

List contacts, send requests, and respond to them.

#### [User Rooms](/websocket/human/user-rooms)

Learn when a contact adds the user to a room.