Skip to main content
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 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. Enable the call actions the client needs in the session’s client rules: 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

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

  • 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. 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:
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. 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 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 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: accept returns answered (true when this request answered the call, false when it joined an answered call), answeredBy, and exclusive. See the API reference 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 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. Conference mode decides whether they hear each other. 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:
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 and Update call settings.

Turn calling off for a number

Set callsEnabled to false to stop calls on a session without disconnecting it:
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 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:
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, 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

Call history

The Console 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. All three accept the same filters: 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

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:
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

Deletion starts on a date announced in the changelog before it begins. Until then, choosing a period records your choice and deletes nothing.
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: 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.
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.
Read the setting with any team key or project token that has sessions:read:
Change it with a team key that has sessions:manage. Send retentionDays only with the custom policy:
  • 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. See Get call retention and 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 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>.
A successful report returns 202 with { "success": true }. Quality figures are whole numbers or short names: Error codes: 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. 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

The SDKs raise these as typed errors; call_claimed is a CallClaimedError. See 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.