Skip to main content
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:
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

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: Browsers use WebRTC instead: submit an SDP offer with POST /messaging/voip/calls/{id}/offer and trickle candidates with /candidate. See the API methods 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

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

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.