> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polymorfa.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Calls

> Place, answer, join, and leave WhatsApp calls from browsers and server code with the Polymorfa SDKs.

A connected Number can place and receive WhatsApp voice and video calls. You add
calling to your product with the Polymorfa SDKs:

* **In the browser**, `@polymorfa/browser` connects microphones, cameras and
  speakers to a call, and `@polymorfa/react` and `@polymorfa/elements` render the
  call interface.
* **In server code**, `@polymorfa/sdk/calls` lets your application speak and listen
  on a call, for example to run a voice agent.
* **On your server**, `@polymorfa/sdk` places, answers, and ends calls and
  manages call settings.

Browsers and server code can join the same call. Each participant hears the
others and receives their video. The SDKs handle media connections, reconnects,
and token renewal; your application works only with calls and participants.

The calling packages ship in the Polymorfa SDK repository. See
[TypeScript SDK](/sdks/typescript/installation) for installation.

## How calls work

* **The call.** It rings, is answered or declined, and ends. Ending it
  disconnects everyone.
* **Participants.** A participant is who acts in the call. Polymorfa derives it
  from the credential:
  * A client token acts as `client:<ephemeralId>`, using the `ephemeralId` it
    was minted with. Client tokens cannot name another participant.
  * A team or project key acts as `server:<participant>`. Pass the optional
    `participant` name (1 to 128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`,
    `:`, `@`, and `-`). It defaults to `server:default`.
* **Connections.** Each browser tab or server client that carries a
  participant's audio and video is one connection. A participant can hold
  several connections. Leaving closes one connection and never ends the call.

Every inbound call rings until a participant answers or declines it, or until
the ring window ends. Nothing answers a call automatically. Your application
decides who is invited, who answers, and whether other participants can join.

## Authenticate browser clients

Browsers and mobile apps use a client token. Mint it on your server with
`POST /platform/client-tokens` and send it to the client. Keep team and project
keys on your server. See [Client tokens](/console/client-tokens).

Enable the call actions the client needs in the session's
[client rules](/console/client-tokens#session-rules):

| Action | Allows |
| - | - |
| `voip_place` | Placing calls, inviting participants, and connecting to calls you place. |
| `voip_answer` | Answering, joining, and declining calls, and connecting to calls you answer or join. |
| `voip_signal` | Receiving call events, keeping connections alive, leaving, and ending calls. |

The browser SDK needs all three to place and answer calls. A client token acts
only on calls of the session it is bound to, and that session must still belong
to the token's project. Its `ephemeralId` must use the participant characters
listed above; otherwise call requests return `403`.

Client tokens expire. Give the SDK a function that fetches a fresh token from
your server; the SDK renews the token on open connections before it expires.
When a token is revoked or stops authorizing the session, the SDK reports an
`unauthorized` error and asks your function for a new token. It does the same
when Polymorfa cannot confirm the token for 3 minutes because checks are rate
limited or temporarily unavailable.

## Browser calls

```ts theme={null}
import { createBrowserCalls, createClientTokenProvider } from "@polymorfa/browser";
import { CallSurface, DialPad, PolymorfaProvider } from "@polymorfa/react";

const calls = createBrowserCalls({
  session: "support",
  getClientToken: createClientTokenProvider(),
});
await calls.connect();

<PolymorfaProvider>
  <CallSurface controller={calls.controller} exclusive={false} />
  <DialPad controller={calls.controller} />
</PolymorfaProvider>;
```

`createClientTokenProvider()` requests tokens from `/api/polymorfa/token` on your
own origin. Implement that route on your server and return a client token for
the signed-in user.

`CallSurface` shows ringing calls, the call stage, participant video, the
participant list, and controls for the microphone, camera, devices, leaving, and
ending the call. `IncomingCallCard`, `CallStage`, `ParticipantVideoGrid`,
`ParticipantList`, `CallControls`, and `DialPad` are available individually. For
plain HTML, assign the same controller to the `<pmfa-call>` element from
`@polymorfa/elements`.

To build your own interface, read `calls.controller.getSnapshot()` and call
`place`, `answer`, `join`, `reject`, `leave`, `end`, `setMuted`, and
`switchDevice` on the controller. Several calls can ring at once; the
controller lists them in `snapshot.invitations` and never declines a call for
you. Each remote participant's video is a separate stream in
`controller.remoteVideos`, labeled with the participant.

Call `await calls.dispose()` when your application releases the calling
interface.

## Calls from your server code

```ts theme={null}
import { CallsClient } from "@polymorfa/sdk/calls";

const client = new CallsClient({
  token: process.env.POLYMORFA_KEY!,
  session: "support",
  participant: "voice-agent",
});

client.on("incoming", async (call) => {
  await call.answer({ exclusive: true });
  call.audio.on("data", (pcm) => recognizer.push(pcm));
  call.audio.write(synth.speak("Hello, how can I help?"));
  call.on("ended", (reason) => console.log("ended:", reason));
});

await client.connect();
```

* `call.audio` carries the call's merged audio as signed 16-bit mono PCM at
  `call.audio.sampleRate`, in both directions. Write audio in real time.
* `call.video` receives each other participant's video as a separate stream of
  H.264 frames and sends one H.264 stream of your own. `call.video.sources`
  names the participant behind each stream. Send a keyframe (an access unit
  with an IDR slice, with SPS and PPS in it or before it) when the
  `keyframeRequest` event fires. Other participants start receiving your
  video at your first IDR.
* When the connection drops, the client reconnects it and keeps the call.

`@polymorfa/sdk/calls` runs on Node.js 22 or later, or on Node.js 20 with a
`WebSocket` implementation passed in the options.

## Answer, join, or decline

Every inbound call raises `call.received`. The SDKs expose it as a ringing call.

| SDK call | Effect |
| - | - |
| `answer({ exclusive, video })` | Answers the ringing call and connects your media. |
| `join({ video })` | Joins a call another participant answered without claiming it. |
| `reject()` | Declines a ringing call. This ends the call for everyone. |
| `leave()` | Disconnects this client. The call continues for others. |
| `end()` | Ends the call for everyone. |

Choose `exclusive` on each answer:

* `exclusive: true` claims the call. Other participants stop ringing, cannot
  answer or connect, and their open connections close. Use it when one person
  handles the call, for example when the first agent to pick up takes it.
* `exclusive: false` (the default) leaves the call shared. Other participants
  keep ringing and can join.

A later answer on a shared call joins it. Repeating an answer as the same
participant is safe. Only the participant that answered can claim a shared call
later; a claim from anyone else fails with `call_claimed`, so participants who
already joined stay connected. On a call you placed, the placing participant is
the answering participant.

When another participant claims a call, the SDK marks it as claimed elsewhere.
Stop ringing and do not decline it: declining ends the call for the participant
who answered.

A call that nobody answers or declines within the ring window ends with
`call.missed` and reason `ring_timeout`. After answering, connect media
promptly; a call with no connected media ends with reason `setup_timeout`.

## Place a call

From the browser, call `calls.controller.place("+15550100", { video: true })`.
From server code, call `client.place("+15550100")`. From your server:

```ts theme={null}
import { MessagingClient } from "@polymorfa/sdk";

const messaging = new MessagingClient({
  credential: { type: "apiKey", value: process.env.POLYMORFA_KEY! },
});

const placed = await messaging.voip.place({
  session: "support",
  to: "+15550100",
  video: false,
  exclusive: true,
  participant: "agent-7",
});
```

The response carries the `callId` while the callee's device rings.
`call.accepted` follows when the callee picks up and `call.ended` when the call
ends. `to` is an E.164 phone number or a user ID. A client token places calls
from its bound session; a team or project key must name `session`.

### Calls to people who have never chatted with the Number

WhatsApp sometimes restricts a Number to starting conversations and calls only
with people it has already chatted with. While that restriction is active,
Polymorfa refuses a call to anyone else with `403 number_restricted` before
anything reaches WhatsApp, and returns `Retry-After` when WhatsApp reported an
end time. Calls to people the Number has chatted with keep working, and so do
incoming calls. The same check applies to
[invites](#invite-more-people).

`session.restriction_updated` tells you when the restriction starts, changes,
and ends, so you can pause cold outreach and resume it automatically. If a call
does reach WhatsApp while the Number is restricted, it ends with
`reason: "call_restricted"`.

See [Calling best practices](/guides/calls/best-practices) for how
to choose who to call and how to grow a new Number's volume.

Outbound starts share a per-minute limit for each source session in your team.
Changing the destination or using another key or client token does not reset
that limit. A placement above the limit fails with `429` without ringing the
destination. Wait for the next minute before retrying.

WhatsApp can restrict a linked-device Number whose calls draw reports. The risk
is highest for calls to people who have never interacted with the Number. See [Calling best practices](/guides/calls/best-practices)
before you place calls at volume.

## Server call control

`messaging.voip` also answers, joins, declines, and ends calls from your server,
and invites more people:

| Method | HTTP request |
| - | - |
| `accept(callId, { exclusive, video, participant })` | `POST /messaging/voip/calls/{id}/accept` |
| `reject(callId)` | `POST /messaging/voip/calls/{id}/reject` |
| `leave(callId, { connectionId })` | `POST /messaging/voip/calls/{id}/leave` |
| `end(callId)` | `DELETE /messaging/voip/calls/{id}` |
| `addParticipant(callId, { to })` | `POST /messaging/voip/calls/{id}/participants` |

`accept` returns `answered` (`true` when this request answered the call,
`false` when it joined an answered call), `answeredBy`, and `exclusive`. See the
[API reference](/api/messaging) for request and response fields.

## Invite more people

`addParticipant` rings another WhatsApp user into a live call, turning a
one-to-one call into a group call. The SDKs report participants joining,
changing state, and leaving; webhooks report the same changes as
`call.participant_joined`, `call.participant_state`, and
`call.participant_left`. Participants use the same Polymorfa user `id`
everywhere. Known aliases appear as `phoneNumber`, `bsuid`, or `username`. The
`audioMuted` and `video` fields are reserved and remain `false` because WhatsApp
does not report either state per participant.

To add people from your own application, give each person a client token and
have them [join](#answer-join-or-decline) the shared call.

## Audio, video, and call settings

Each participant receives one merged audio stream. The people on WhatsApp hear
every participant you connect to the call. What your own participants hear
depends on conference mode.

### Conference mode

Your participants are everyone you connect to a call: people in a browser,
your servers and bots, and your PBX through a
[SIP trunk](/guides/calls/sip-trunks). Conference mode decides whether
they hear each other.

| Conference mode | Each of your participants hears | The people on WhatsApp hear |
| - | - | - |
| On (default) | The people on WhatsApp and every other participant you connected | All of your participants |
| Off | Only the people on WhatsApp | All of your participants |

Nobody hears their own voice in either mode. A participant with more than one
connection to the same call, for example while reconnecting, does not hear
itself through its other connection.

Turn conference mode off when each participant talks to the WhatsApp side
independently, for example a bot that transcribes the call alongside a human
agent who should not hear the bot. Change it per session:

```ts theme={null}
await messaging.voip.updateCallSettings("support", { conferenceMode: false });
const settings = await messaging.voip.retrieveCallSettings("support");
```

An update changes only the settings you send; the others keep their values.
`revision` increases on every change. To apply a change only if nobody else
changed the settings since you read them, send the `revision` you read as
`expectedRevision`; the update then fails with `409 state_conflict` when they
changed. Read them again and retry.

A session without saved settings returns `callsEnabled: true`,
`conferenceMode: true`, `inboundRoute: "clients"`, `sipTrunkId: null`,
`sipClaim: true`, `hostCloudApiCalls: false`, `revision: 0`, and
`updatedAt: null`. Reading requires `sessions:read` and saving requires
`sessions:manage`, with a team key or a project token. Client tokens cannot read
or change call settings. A saved setting applies to a call the next time a
participant answers it or connects to it, including calls in progress. When the
setting cannot be read, answering and connecting fail with `503` instead of
using a value you did not choose. You can also change it in the Console on the
Number's details. See [Get call settings](/api-reference/platform/calls/get-call-settings)
and [Update call settings](/api-reference/platform/calls/update-call-settings).

### Turn calling off for a number

Set `callsEnabled` to `false` to stop calls on a session without disconnecting
it:

```ts theme={null}
await messaging.voip.updateCallSettings("support", { callsEnabled: false });
```

While calling is off:

* placing a call, answering or joining one, inviting a participant, and
  connecting media fail with `403 calls_disabled`. Starting or answering a call
  through the Graph API's `/calls` endpoint fails with a permission error;
* incoming calls are declined. You still receive `call.received`, followed by
  `call.rejected` and `call.ended`; and
* a [SIP trunk](/guides/calls/sip-trunks) cannot place calls through the
  session.

Calls in progress continue: the participant that answered or placed a call can
still reconnect its media to it, and leaving, ending, and declining calls keep
working. Other participants cannot join a call in progress. Set `callsEnabled` back to `true` to allow calls again. A change reaches
incoming calls and SIP trunks within about a minute.

Polymorfa forwards each participant's video as a separate stream and does not
combine them into a grid. The WhatsApp side of the call receives one video
stream: the camera of the first participant that sends video while that
participant remains connected.

Each call reports its `capabilities`: `video` and `invite`. The SDK components
hide controls that a call does not support.

## Cloud API Numbers

A Number connected through the WhatsApp Business Cloud API keeps answering its
incoming calls through your Graph API integration until you turn on
`hostCloudApiCalls` in its call settings:

```typescript theme={null}
await messaging.voip.updateCallSettings("support", { hostCloudApiCalls: true });
```

With `hostCloudApiCalls: true`, your Calls apps receive and answer the
Number's incoming calls, and the Graph API `/calls` endpoint refuses to
answer them. With `false` (the default), Polymorfa Calls does not host
incoming calls on the Number, and your Graph API integration answers them.
While [calling is off](#turn-calling-off-for-a-number), Polymorfa declines the Number's
incoming calls whatever this setting is.
The setting applies to calls that arrive after the change. Calls you place
with the SDKs always use Polymorfa Calls.

Calls on a Cloud API Number work the same way in the SDKs, with these limits:

* Calls are one-to-one and audio only. Their `capabilities` report `video:
  false` and `invite: false`. Placing a video call or inviting a participant
  fails with `unsupported_for_connection`. Answering with video answers with
  audio only.
* Before you place a call, the recipient must grant your business permission
  to call them. Without it, placing the call fails with
  `call_permission_required`.
* WhatsApp allows 30 to 60 seconds to answer an inbound Cloud API call. Answer
  within 30 seconds to avoid a missed call.

## Webhooks

| Event | When |
| - | - |
| `call.received` | An inbound call arrived and is ringing. Carries `capabilities` and the Number's `sessionConnection`. |
| `call.accepted` | The call was answered by a participant or by the callee. Carries `answeredBy`, `capabilities`, and, for a claimed call, `exclusive: true`. |
| `call.rejected` | A participant declined a ringing inbound call. |
| `call.missed` | A ringing inbound call was never answered (`reason: "ring_timeout"`). |
| `call.ended` | The call ended, with `reason`, `direction`, and `durationSeconds`. A call whose media host was lost reports `reason: "pod_lost"` and `from: null`. A call WhatsApp refused because the Number is restricted to existing contacts reports `reason: "call_restricted"`. |
| `call.telemetry` | Timing and quality figures for the finished call. |
| `call.participant_joined` | A participant entered the call roster. |
| `call.participant_state` | A participant's call state changed. |
| `call.participant_left` | A participant left the call roster, with an optional reason. |
| `call.connection_joined` | One of your participants connected to the call. Carries the participant and the connection `id`. |
| `call.connection_left` | A connection left, with `reason`: `left`, `replaced`, or `claimed`. [SIP trunks](/guides/calls/sip-trunks) report more reasons. |
| `usage.recorded` | The ended call was metered. |

## Call history

The [Console](/console/calls) lists your calls with their state, direction,
and duration, and shows each call's participants, connections, quality figures,
the diagnostics your app reported, and webhook delivery results. Owners and admins can end a live call or remove
one connection from it there.

## Call analytics and call records

Three Platform API endpoints read your call history. They accept an
organization API key or a project token with `sessions:read`. A project token
reads only its own project; an organization key reads the whole team unless
you pass `projectId`. The Console's call summary uses the same statistics
endpoint.

A call keeps its original project after its number is deleted. Reusing the
number's name in another project does not transfer its history, statistics,
or usage. Records whose original project is unknown have `projectId: null`
and appear only in team-wide reads.

| Endpoint | Returns |
| - | - |
| [`GET /platform/calls/stats`](/api-reference/platform/calls/get-call-stats) | Volume, outcomes, answer rate, talk time and an hour-of-week heatmap |
| [`GET /platform/calls`](/api-reference/platform/calls/list-call-records) | Call detail records as JSON, 100 per page at most |
| [`GET /platform/calls/export`](/api-reference/platform/calls/export-call-records) | Call detail records as CSV or NDJSON, 1,000 per page at most |

All three accept the same filters:

| Filter | Values |
| - | - |
| `since`, `until` | Start-time range. `since` is inclusive and `until` is exclusive. |
| `projectId` | One project. |
| `sessionId` | One number, by session name. |
| `direction` | `inbound` or `outbound`. |
| `upstream` | `linked_device` or `cloud_api`: how the number connects to WhatsApp. |
| `outcome` | `answered`, `missed`, `declined`, `failed`, or `in_progress`. |

Each call has one outcome:

* `answered`: media connected. Answered calls still in progress count here.
* `declined`: a participant or the other party declined, or the line was busy.
* `missed`: nobody answered before ringing stopped, or the caller hung up first.
* `failed`: the call ended before it connected for any other reason, such as a
  connection or capacity failure.
* `in_progress`: the call is still ringing.

### Statistics

```bash theme={null}
curl "https://api.polymorfa.com/platform/calls/stats?since=2026-09-01T00:00:00Z&until=2026-09-08T00:00:00Z&groupBy=day&timezone=America/Sao_Paulo" \
  -H "Authorization: Bearer $POLYMORFA_API_KEY"
```

Without `since` and `until`, the range is the last 7 days. `groupBy` selects
the groups:

* `day` (the default) and `hour` return one group per day or hour in
  `timezone`, including empty ones. A `day` range covers at most 366 days and
  an `hour` range at most 31 days.
* `session` returns one group per number, most calls first, up to 500 numbers.
  `groupsTruncated` is `true` when more numbers had calls.
* `outcome` returns one group per outcome.

`timezone` takes an IANA name such as `UTC`, `CET` or `Europe/Lisbon`, in any
case; the response's `timezone` is the canonical name. UTC offsets and POSIX
strings such as `UTC+3` are refused. Hour groups are local clock hours: on the day a zone
moves its clocks, the skipped hour has no group and the repeated hour is one
group. Every group and the `totals` report `calls`, the count for each
outcome, `answerRate`, `totalDurationSeconds`, and `averageDurationSeconds`.
`answerRate` is answered calls divided by calls that are no longer ringing.
Durations count connected time only. `heatmap` always has 168 cells, one per
day of week (`1` is Monday) and hour, in `timezone`.

### Call detail records

Each record has `callId`, `projectId`, `sessionId`, `direction`, `upstream`,
`outcome`, `state`, `hasVideo`, `peerRef`, `startedAt`, `connectedAt`,
`endedAt`, `durationSeconds`, and `endReason`. `peerRef` is the other party's
team-specific pseudonym: the same person has the same `peerRef` across your
team's calls, but it is not a phone number and Polymorfa cannot turn it back
into one.

`GET /platform/calls` returns records newest first. Pass `page.nextCursor` as
`cursor`, with the same filters, to read the next page.

`GET /platform/calls/export` returns the same records as CSV (`format=csv`, the
default) or newline-delimited JSON (`format=ndjson`), up to 1,000 per request.
Each CSV page starts with a header row. When more records match, the
`Polymorfa-Next-Cursor` response header holds the cursor for the next page:

```bash theme={null}
cursor=""
while :; do
  curl -sS -D headers.txt "https://api.polymorfa.com/platform/calls/export?format=ndjson&since=2026-09-01T00:00:00Z${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $POLYMORFA_API_KEY" >> calls.ndjson
  cursor=$(grep -i '^polymorfa-next-cursor:' headers.txt | cut -d' ' -f2 | tr -d '\r')
  [ -n "$cursor" ] || break
done
```

In CSV, a text value that starts with `=`, `+`, `-`, `@`, a tab or a carriage
return is prefixed with an apostrophe so spreadsheets do not run it as a
formula.

Use the authenticated HTTP endpoints above for call analytics, records, and
exports. The TypeScript SDK and CLI do not expose these analytics operations.

Call history can lag behind live events during a temporary recording failure.
Failed writes are retried for up to 24 hours. A delayed event does not move an
accepted or finished call back to ringing.

## Call data retention

<Note>
  Deletion starts on a date announced in the [changelog](/changelog) before it
  begins. Until then, choosing a period records your choice and deletes
  nothing.
</Note>

Your team chooses how long Polymorfa keeps its call data. Once deletion starts,
Polymorfa deletes call data older than that period. The period applies to:

* call records: the call history the Console and API list;
* call events: each call's participant and connection history;
* the diagnostics your apps report for their call connections.

Polymorfa does not record call audio or video, and it does not store the phone
number or WhatsApp address of the other party.

Choose a policy:

| Policy | Period |
| - | - |
| `short` | 7 days |
| `standard` | 30 days |
| `extended` (default) | 90 days |
| `compliance` | 365 days |
| `custom` | 1 to 2,555 days that you set |

Once deletion runs, Polymorfa deletes call data within 24 hours after it becomes
older than the period. A large backlog, such as the call data already stored
when deletion starts or when you shorten the period, can take longer to
delete. A call is measured from when it started; a call that is still ringing or
in progress is kept until it ends. The period covers every project in the team.

<Warning>
  Once deletion runs, a shorter period also applies to call data already stored:
  data older than the new period is deleted within 24 hours and cannot be
  recovered. A longer period
  does not restore data that was already deleted.
</Warning>

Read the setting with any team key or project token that has `sessions:read`:

```bash theme={null}
curl https://api.polymorfa.com/platform/call-retention \
  -H "Authorization: Bearer $POLYMORFA_TEAM_KEY"
```

```json theme={null}
{
  "success": true,
  "data": {
    "policy": "extended",
    "retentionDays": 90,
    "appliesTo": ["call_records", "call_events", "client_reports"],
    "revision": 0,
    "updatedAt": null
  }
}
```

Change it with a team key that has `sessions:manage`. Send `retentionDays` only
with the `custom` policy:

```bash theme={null}
curl -X PUT https://api.polymorfa.com/platform/call-retention \
  -H "Authorization: Bearer $POLYMORFA_TEAM_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "policy": "custom", "retentionDays": 180, "expectedRevision": 0 }'
```

* Project tokens can read the setting but not change it; a change fails with
  `403`. Client tokens can do neither.
* A team without a saved setting returns `revision: 0` and `updatedAt: null`.
  `revision` increases on every change. Send the `revision` you read as
  `expectedRevision` to refuse the update with `409 state_conflict` when someone
  changed the setting after you read it.
* A named policy with a different `retentionDays`, or `custom` without one,
  fails with `400 invalid_parameter`.
* `appliesTo` lists the kinds of call data the period covers. When Polymorfa
  stores a new kind of call data, it is added to this list and follows the same
  period.

Team owners and admins can also change the period in the Console on the
[Calls page](/console/calls#call-data-retention). See
[Get call retention](/api-reference/platform/calls/get-call-retention) and
[Update call retention](/api-reference/platform/calls/update-call-retention).

## Report call diagnostics from your app

Your app can send the quality figures and errors it measures on each of its
connections. The [Console](/console/calls#app-reported-diagnostics) shows them
on the call details page, next to the measurements of Polymorfa's media
service. Reports are optional; a call without them works the same.

Send reports with `POST /messaging/voip/calls/{id}/reports`. The Polymorfa SDKs
do not send them for you; call the endpoint from your app. Use the credential
that carries the call: a client token needs the `voip_signal` action, and a
team or project key reports as `server:<participant>`.

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/voip/calls/CALL_ID/reports" \
  -H "Authorization: Bearer $POLYMORFA_CLIENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "quality",
    "connectionId": "conn_0123456789",
    "client": { "sdk": "my-call-app", "version": "2.3.0", "platform": "browser" },
    "quality": {
      "rttMs": 84,
      "jitterMs": 6,
      "packetsLost": 12,
      "packetsReceived": 4810,
      "audioCodec": "audio/opus",
      "candidateType": "relay",
      "reconnects": 0
    }
  }'
```

A successful report returns `202` with `{ "success": true }`.

| Field | Rule |
| - | - |
| `kind` | `quality` or `error`. |
| `connectionId` | The ID your app chose for the connection, as `call.connection_joined` reports it. 8 to 64 characters from `A-Z`, `a-z`, `0-9`, `_`, and `-`. |
| `participant` | Team and project keys only; see [How calls work](#how-calls-work). Client tokens omit it. |
| `client` | Optional. `sdk` (1 to 32 characters from `a-z`, `0-9`, `@`, `/`, `.`, `_`, `-`), `version` (`MAJOR.MINOR.PATCH` with an optional suffix, at most 32 characters), and `platform` (`browser`, `node`, or `other`). |
| `quality` | Required for `kind: "quality"`. Send at least one figure and omit the ones you did not measure. |
| `error.code` | Required for `kind: "error"`. One of the codes below. |

Quality figures are whole numbers or short names:

| Figure | Meaning | Range |
| - | - | - |
| `rttMs` | Round-trip time in milliseconds. | 0 to 60000 |
| `jitterMs` | Receive jitter in milliseconds. | 0 to 60000 |
| `packetsLost` | Packets lost since the connection started. | 0 to 2147483647 |
| `packetsReceived` | Packets received since the connection started. | 0 to 2147483647 |
| `audioCodec` | Negotiated audio codec, such as `audio/opus`. | 1 to 32 characters from `A-Z`, `a-z`, `0-9`, `/`, `.`, `-` |
| `videoCodec` | Negotiated video codec, such as `video/VP8`. | Same as `audioCodec` |
| `candidateType` | Local ICE candidate type in use: `host`, `srflx`, `prflx`, or `relay` (through a TURN relay). | — |
| `reconnects` | Times the connection reconnected so far. | 0 to 1000 |

Error codes:

| Code | Meaning |
| - | - |
| `media_permission_denied` | The user or browser denied microphone or camera access. |
| `device_not_found` | No microphone or camera was found. |
| `device_in_use` | Another application was using the microphone or camera. |
| `ice_failed` | The media connection could not be established or was lost. |
| `negotiation_failed` | SDP negotiation failed. |
| `media_timeout` | Media stopped arriving. |
| `reconnect_exhausted` | The app stopped trying to reconnect. |
| `token_refresh_failed` | The app could not get a new client token. |
| `unsupported_browser` | The browser does not support calls. |
| `other` | Any other error. |

Reports accept only these fields. Polymorfa does not accept error messages,
device names, IP addresses, SDP, or user agents in a report; a request that
contains them fails with `400`.

Limits:

* Each connection can send one quality report every 5 seconds and 20 error
  reports per minute. More fail with `429`. Reports also count toward a client
  token's `rateLimit` rule.
* Polymorfa accepts reports while the call is live and for 10 minutes after it
  ends. Later reports fail with `409`, or `410` when the call ended because
  its media host was lost.
* Polymorfa keeps up to 200 reports per call. At that limit, a quality report
  replaces the same connection's oldest quality report. A quality report from
  a connection with no stored quality report, and every error report, returns
  `202` but is not kept.
* Polymorfa authorizes the call, not the connection: any participant your
  credential can act as can report for any `connectionId` on a call that
  credential can reach.

Reports are deleted with the call's history when they pass your team's
[call data retention period](#call-data-retention). Deleting a team removes access
to its history; it does not guarantee immediate erasure of retained records.

A cadence that keeps the Console current: a quality report every 15 seconds
during the call and one when the connection closes, and an error report when an
error occurs.

## Access limits

Every call operation stays within the credential's resource boundary. Team keys
can control calls across their team. Project keys can control calls on any
session owned by that project. Client tokens can control calls only on their
bound session. A call outside the boundary fails with `409` before the
operation changes call state.

Client rules apply to call setups, placements, and invites:

* `maxSetupsPerMinute` limits call setups per client token user (default 10).
  Each placement and invite counts as a setup. For an existing call, the first
  successful answer or connection counts as one setup; later ones for the same
  call do not.
* `allowedNumber` is a comma-separated list of E.164 numbers a client token can
  call or invite.
* `maxConcurrency` limits calls a client token user holds at once. A call holds
  its slot until it ends.

Exceeding a limit fails with `429`.

A call releases its concurrency slot when it ends, even if your client
disconnects without ending it. If a placement or setup fails without a confirmed
outcome, its slot remains reserved for up to four hours, because the call can
still be active when the response was lost.

## Errors

| Status | Code | Meaning |
| - | - | - |
| `400` | `invalid_parameter` | A field is invalid, or a client token named a `participant`. |
| `403` | `permission_denied` | The credential cannot act on this call, session, or destination. |
| `403` | [`call_permission_required`](/api/errors#call-permission-required) | The recipient has not granted your business permission to call them. |
| `403` | [`number_restricted`](/api/errors#number-restricted) | WhatsApp restricts the Number to calling people it has already chatted with, and this callee is not one of them. `Retry-After` gives the seconds until the restriction ends, when WhatsApp reports it. |
| `409` | [`call_claimed`](/api/errors#call-claimed) | Another participant claimed the call. |
| `409` | [`call_not_ringing`](/api/errors#call-not-ringing) | The call was already answered, declined, or ended. |
| `409` | [`connection_limit`](/api/errors#connection-limit) | The call has no room for another connection. Leave an unused connection first. |
| `409` | [`unsupported_for_connection`](/api/errors#unsupported-for-connection) | The Number's connection does not support the operation, such as video on a Cloud API Number. |
| `409` | `state_conflict` | The call is not ready for this operation or is outside your credential's boundary. |
| `410` | `resource_gone` | The call ended because its media host is gone. |
| `429` | `rate_limit_exceeded` | The source session's outbound-start limit, a client-token call limit, or a connection's report limit was reached. |
| `501` | `operation_not_supported` | Outbound calls are not available on this deployment. |
| `503` | `service_unavailable` | The call is at capacity, call authority or call settings are unavailable, or the media host is draining. |

The SDKs raise these as typed errors; `call_claimed` is a `CallClaimedError`.
See [Errors](/api/errors) for the error object.

### Console table controls

The Calls table opens first. Use its filters to select activity, state,
direction, Number, and dates. SIP trunks, Call policy, Refresh, and the API
button sit beside the filters. Call analytics appears below the table.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.