> This page is for version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://docs.pinnacle.sh/v-2/llms.txt
> - v1: https://docs.pinnacle.sh/v1/llms.txt

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

# Conference Calls

> Put more than two people on a call, and control who hears whom.

A **conference** (`conf_…`) is a room. Its **participants** are ordinary calls: each has its own `call_id`, and everything that works on a call (events, recording, streams, metadata) works on a participant.

You can refer to a participant by its `call_id` or by a **label** you choose when adding it, like `"specialist"` or `"supervisor"`.

Two-person handoffs don't need a conference: `call.transfer` still connects two calls directly.

## Start a conference

Create one with the people you want, or start from a call you're already on. Existing calls are added first, so the conference is live before dialed people answer.

```bash
curl -X POST https://api.pinnacle.sh/conferences \
  -H "PINNACLE-API-KEY: $PINNACLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f0c1f1e-escalation-4412" \
  -d '{
    "label": "escalation-4412",
    "participants": [
      { "call_id": "call_7cQe4F8xT1yB2mN9pK3L", "label": "caller" },
      { "to": "+14155551234", "from": "+14155559876", "label": "specialist" }
    ]
  }'
```

The response is the conference with its participants. A dialed participant starts as `dialing` and becomes `joined` when they answer.

From a call's WebSocket, you don't need to create anything first: `conference.add_participant` puts this call and the new person into a conference, and the ack tells you its ID.

```json
{
  "event": "command",
  "command_id": "add-specialist-1",
  "action": "conference.add_participant",
  "params": { "to": "+14155551234", "label": "specialist" }
}
```

```json
{
  "event": "ack",
  "command_id": "add-specialist-1",
  "status": "ok",
  "data": {
    "conference_id": "conf_Hk2mX9pQ4rT7vB1nL8sW",
    "participant": { "call_id": "call_2bQf…", "label": "specialist", "status": "dialing", "to": "+14155551234", "from": "+14155559876" }
  }
}
```

Over the WebSocket, `from` defaults to your number on the current call.

## Add people

Either dial someone or pull in a call you already have:

| Form          | Fields       | Notes                                                    |
| ------------- | ------------ | -------------------------------------------------------- |
| Dial          | `to`, `from` | Creates a new outbound call, exactly like `POST /calls`. |
| Existing call | `call_id`    | Must be answered and not in another conference.          |

Options for both: `label`, `muted`, `on_hold`, `whisper_to`, `end_conference_on_leave`. Dialing also takes `announce`, `ring_timeout_secs`, `record`, and `metadata`.

**`announce`** plays to the new person when they answer, before they join; nobody else hears it:

```json
{ "to": "+14155551234", "from": "+14155559876", "label": "supervisor", "muted": true,
  "announce": { "text": "You're joining a billing call. You're muted." } }
```

Use `{ "url": "https://…/intro.mp3" }` to play a file instead of text-to-speech.

If a dialed person doesn't answer, is busy, or rejects the call, you get `participant.failed` with the reason and everyone else is unaffected.

## Control who hears whom

Each participant has three settings. Change them with [`PATCH /conferences/{id}/participants/{participant}`](/api-reference/conferences/participants/update-participant) or the `conference.update_participant` command. Only the fields you send change.

| Setting                      | Effect                                                                                  |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| `muted: true`                | Nobody hears them. They still hear everyone. Use it for someone listening in.           |
| `on_hold: true`              | They hear hold audio (`hold_audio_url`) and nobody hears them.                          |
| `whisper_to: ["specialist"]` | Only the specialist hears them; they still hear everyone. `null` goes back to everyone. |

A participant can't be muted and whispering at the same time.

```json
{
  "event": "command",
  "command_id": "coach-1",
  "action": "conference.update_participant",
  "params": { "participant": "supervisor", "muted": false, "whisper_to": ["specialist"] }
}
```

In commands, `"participant": "self"` means the call the WebSocket is on.

## Audio your app sends

Audio your app sends over a call's WebSocket is heard by everyone in that call's conference, and your app hears the whole room on the stream. If the call is whispering, your app's audio is whispered too.

When your app's call is **on hold**, your app's audio reaches only that call's own person, not the rest of the conference.

## Events

Every call in the conference gets these on its WebSocket. Each carries `conference_id` and `participant_count` (people currently joined).

| Event                  | When                                                                                           | Payload adds                      |
| ---------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------- |
| `conference.started`   | The first person joined.                                                                       |                                   |
| `participant.dialing`  | A dialed participant's call was placed.                                                        | `participant`                     |
| `participant.joined`   | Someone is in the conference.                                                                  | `participant`                     |
| `participant.updated`  | `muted`, `on_hold`, `whisper_to`, or `label` changed.                                          | `participant`, `changed`          |
| `participant.left`     | Someone hung up or was removed.                                                                | `participant` (see `left_reason`) |
| `participant.failed`   | A dialed participant never connected.                                                          | `participant` (see `left_reason`) |
| `conference.ended`     | The conference ended.                                                                          | `reason`                          |
| `participant.speaking` | The active speaker changed. Only when the conference was created with `speaking_events: true`. | `participant`                     |

`participant.speaking` can fire several times a second, so it's off by default and isn't saved to the call's event history. Turn it on with `speaking_events: true` when creating the conference (or with `PATCH /conferences/{id}`).

Each participant's own call still gets its normal events (`call.answered`, `call.ended`, …).

## When a conference ends

* When the last participant leaves.
* With [`DELETE /conferences/{id}`](/api-reference/conferences/end-conference) or the `conference.end` command.
* When a participant added with `end_conference_on_leave: true` leaves.
* After 4 hours.

It does **not** end just because one person is left; they might be waiting for someone to be added back. Ending a conference hangs up everyone still in it, including anyone still being dialed.

To take one person out while everyone else stays, remove them with [`DELETE /conferences/{id}/participants/{participant}`](/api-reference/conferences/participants/remove-participant) or `conference.remove_participant`. To leave from your own call, send `conference.leave`.

Closing your app's WebSocket doesn't end its call: the people in the conference keep talking.

## Recording

Recording works per participant call, the same as for any call: each recording is that person plus what they heard, so it captures the whole conversation from their side. As always, recording needs a stream with recording turned on. `record: true` when dialing a participant turns it on in that call's stream token.

## Pricing

Each participant is charged **\$0.003 per minute** they're in the conference, rounded up to the whole minute and deducted from your credits when they leave. Someone who never joins (busy, no answer, or still being dialed when the conference ends) isn't charged. This is on top of each participant's own call, which is billed like any [call](/guides/voice-calls#pricing).

## Example: bring in a human and hand off

1. Your app, on the caller's call, adds a specialist:
   `conference.add_participant { "to": "+14155551234", "label": "specialist" }`
2. On `participant.joined`, your app introduces the call. The caller hears this too.
3. Your app closes its WebSocket. The caller and the specialist keep talking, and the conference ends when they both hang up.

To let a supervisor listen silently, add them with `muted: true`. To let them coach the specialist, set `whisper_to: ["specialist"]`. To let them take over, set `muted: false` and `whisper_to: null`.

## Limits and errors

* `max_participants` is 10 by default, up to 50.
* Labels are 1–64 characters of `A–Z a–z 0–9 _ -`, unique within the conference, and can't start with `call_` or `leg_`.
* `409` when the conference has ended or is full, a label is taken, or the call isn't answered or is already in a conference.
* `Idempotency-Key` on `POST /conferences` and `POST /conferences/{id}/participants` makes dialing safe to retry. Over the WebSocket, retrying a command with the same `command_id` never dials twice.