> ## 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, and join WhatsApp calls on a connected Number from your own code.

A connected Number can place and receive WhatsApp voice and video calls. Your
integration chooses how inbound calls are answered, drives each call over the
API, and — when it wants the audio and video itself — attaches to a per-call
media WebSocket.

## Check relay configuration

`GET /health` includes TURN diagnostics when Calls configuration is loaded.
`checks.turn.status` is `degraded` when TURN URLs are set without a shared
secret; otherwise it is `healthy`. `checks["turn.rotation"].status` is
`in_progress` while a retiring shared secret remains configured and `none`
after the overlap ends.

These fields inspect API configuration. They do not connect to the TURN
listener or test DNS, allocation continuity, or ICE recovery. An HTTP `200`
means the required readiness checks passed even when an optional diagnostic
reports `degraded` or `in_progress`.

## Choose how a session answers

Set the answer mode once per session with `POST /messaging/voip/mode`:

| Mode      | What happens when a call arrives                                                                  |
| --------- | ------------------------------------------------------------------------------------------------- |
| `browser` | The call is answered and bridged to a browser that submits an SDP offer with `/offer`. Default.   |
| `agent`   | The call is answered and bridged to the raw-audio media socket.                                   |
| `sdk`     | The call rings. Your code answers with `/accept` or declines with `/reject` before the ring ends. |

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/voip/mode" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session":"support","mode":"sdk"}'
```

The mode persists until you change it and applies to calls that arrive after
it. A client token configures the session it is bound to; an team or
project key must name the session.

## Receive a call

Every inbound call raises a `call.received` webhook carrying the `callId`. In
`sdk` mode the call keeps ringing until you decide:

* `POST /messaging/voip/calls/{id}/accept` answers it. Send `{"video": true}` to take
  the caller's video too; an audio-only call stays audio.
* `POST /messaging/voip/calls/{id}/reject` declines it.

Both return `202` and `409` when the call is no longer ringing — it was already
answered, ended, or the session auto-answers. A call nobody decides on within
the ring window ends with a `call.missed` webhook.

## Place a call

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/voip/calls" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session":"support","to":"+15550100","video":false}'
```

The `201` response carries the `callId` while the callee's device rings.
`call.accepted` follows when they pick up and `call.ended` when either side
hangs up; a call that rings out ends with reason `ring_timeout`. `to` is an
E.164 phone number or a user ID. Hang up with
`DELETE /messaging/voip/calls/{id}`.

Outbound starts share a per-minute limit for each source session in your
organization. Changing the destination or using another server key or client
token does not reset that limit. A placement above the limit returns `429`
without ringing the destination. Wait for the next minute before retrying.
Client-token setup and concurrency rules also apply. Answering an inbound call,
attaching media, and hanging up do not consume this outbound-start limit.

## Invite more people

`POST /messaging/voip/calls/{id}/participants` with `{"to": "+15550199"}` rings
another person into a live call, turning a one-to-one call into a group call.
The response describes the invitee as `invited`, or its newer roster state if it
changed before the invitation completed. A `left` invitee is absent from the active
roster. Later roster changes
reach your media socket as `participant_joined`, `participant_state`, and
`participant_left` frames. Browser clients receive the same changes as
`call.participant_joined`, `call.participant_state`, and
`call.participant_left` lifecycle events. The browser feed is ephemeral: it
does not replay roster changes missed during a disconnect. The `audioMuted`
and `video` fields are reserved and remain `false` because WhatsApp does not
expose either state per participant.

## Attach media programmatically

To hear and speak on a call from your own code:

1. Mint a per-call ticket with `POST /messaging/voip/calls/{id}/agent-token`
   (team and project keys only). Project-key tickets bind to the call's
   current session and generation. They are rejected if either changes,
   including when another call replaces the same call ID. Team-key
   tickets allow team-wide access and validate the call, team,
   and expiry. Tickets expire after `ttlSeconds` (default 300).
2. Open a WebSocket to the `url` returned with the ticket or, when the response
   has none, to `/voip/sdk?callId={id}` on the API host. Request the
   subprotocols in this order: `pmfa.calls.v1`, then `pmfa.ticket.<token>`.
   The response selects only `pmfa.calls.v1`; it never echoes the ticket.
3. Wait for the first text frame, `{"type":"ready","sampleRate":16000,"video":false}`,
   before sending media.

The ticket must match both the call and its organization. An invalid or expired
ticket returns `401` before the connection opens. Expiry does not close an
already connected call; obtain a fresh ticket before reconnecting after expiry. Unavailable call media returns
`409`. Keep each frame at or below 2 MiB and send no more than 500 frames per
second. Close the socket when your integration stops using it.

Frames on the socket:

| Frame                                                           | Direction | Contents                                                                                                                                                                |
| --------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| binary, first byte `0x01`                                       | both      | Signed 16-bit little-endian mono PCM at `sampleRate`.                                                                                                                   |
| binary, first byte `0x02`                                       | both      | One H.264 access unit after a 14-byte header: codec (`0x01`), flags (`0x01` = keyframe), width and height (16-bit), and a 64-bit timestamp in microseconds, big-endian. |
| `{"type":"ping"}` / `{"type":"pong"}`                           | both      | Liveness. The socket answers each ping.                                                                                                                                 |
| `{"type":"hangup"}`                                             | to server | Ends the call and closes the socket.                                                                                                                                    |
| `participant_joined` / `participant_state` / `participant_left` | to client | Group roster changes. Each carries a `participant` object or a `participantId` and optional `reason`.                                                                   |

Browsers use WebRTC instead: submit an SDP offer with
`POST /messaging/voip/calls/{id}/offer` and trickle candidates with `/candidate`. See
the [API methods](/guides/communicate/api/submit-call-offer) for the browser
flow.

The calls signaling socket binds each call ID to its current generation when
it first receives a candidate or teardown command. Later commands on that
socket keep the same binding. Reconnect before controlling a replacement that
reuses the call ID. After 256 distinct call bindings, the socket closes with
code `1013` and asks the client to reconnect; it does not discard older bindings.

## Webhooks

| Event                     | When                                                                                                                                                                              |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `call.received`           | An inbound call arrived.                                                                                                                                                          |
| `call.accepted`           | The call was answered by `/accept`, auto-answer, or the callee.                                                                                                                   |
| `call.rejected`           | You 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 lost media host reports `reason: "pod_lost"` and `from: null` because its peer identity cannot be recovered. |
| `call.telemetry`          | Timing and media-quality figures for the finished call.                                                                                                                           |
| `call.participant_joined` | A participant entered the call roster.                                                                                                                                            |
| `call.participant_state`  | A participant's authoritative call state changed.                                                                                                                                 |
| `call.participant_left`   | A participant left the call roster, with an optional reason.                                                                                                                      |

## Call history

Call history can lag behind live events during a temporary recording failure.
Failed writes are retried for up to 24 hours. A delayed offer does not move an
accepted or finished call back to ringing. The final `call.ended` event supplies
the call direction and duration.

## Client tokens

Every call-ID operation stays within the credential's resource boundary.
Organization keys can control calls across their organization. Project keys
can control calls on any session owned by that project. Client tokens can
control calls only on their bound session, and that session must still belong
to the token's project. A call outside the boundary returns `409` before the
operation changes or drains call state.

A client token needs the matching action: `voip_place` to place calls, invite
participants, or submit an offer; `voip_answer` to accept or reject; and
`voip_signal` for ICE candidates and hang-up. Either `voip_place` or
`voip_answer` may set the answer mode. Client rules apply to placements and
invites: `max_setups_per_minute` (default 10), `allowed_number` (checked against
`to`), and `max_concurrency` (a placed call holds a slot until it ends). Exceeding
a rule returns `429`.

Call completion releases its concurrency slot even if your client disconnects
without sending a hang-up request. Other active calls keep their slots. A call
that ends before its placement response arrives does not keep a slot reserved.
A lost media host also ends the known call and releases its slot. A slot expires
after four hours only when Polymorfa cannot associate the reservation with a
known call.
A failed reject or hang-up request does not free capacity. A confirmed setup
refusal releases its new reservation while preserving any existing call.

## Errors

| Status | Meaning                                                                                              |
| ------ | ---------------------------------------------------------------------------------------------------- |
| `409`  | The call is not ringing, not ready for this operation, or not yours.                                 |
| `410`  | The call ended because its media host is gone.                                                       |
| `429`  | The source session's outbound-start limit or a client-token setup or concurrency limit was exceeded. |
| `501`  | Outbound calls are not available on this deployment.                                                 |
| `503`  | The call was refused for capacity.                                                                   |

If placement or call setup fails after dispatch without a confirmed outcome,
its client-token concurrency slot remains reserved for up to four hours. The call
can still be active when the response was lost. A known call releases its slot
when it ends; an unknown placement remains reserved until expiry.
