- In the browser,
@polymorfa/browserconnects microphones, cameras and speakers to a call, and@polymorfa/reactand@polymorfa/elementsrender the call interface. - In server code,
@polymorfa/sdk/callslets your application speak and listen on a call, for example to run a voice agent. - On your server,
@polymorfa/sdkplaces, answers, and ends calls and manages call settings.
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 theephemeralIdit was minted with. Client tokens cannot name another participant. - A team or project key acts as
server:<participant>. Pass the optionalparticipantname (1 to 128 characters fromA-Z,a-z,0-9,.,_,:,@, and-). It defaults toserver:default.
- A client token acts as
- 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.
Authenticate browser clients
Browsers and mobile apps use a client token. Mint it on your server withPOST /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.audiocarries the call’s merged audio as signed 16-bit mono PCM atcall.audio.sampleRate, in both directions. Write audio in real time.call.videoreceives each other participant’s video as a separate stream of H.264 frames and sends one H.264 stream of your own.call.video.sourcesnames 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 thekeyframeRequestevent 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 raisescall.received. The SDKs expose it as a ringing call.
Choose
exclusive on each answer:
exclusive: trueclaims 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.
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, callcalls.controller.place("+15550100", { video: true }).
From server code, call client.place("+15550100"). From your server:
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 with403 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:
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
SetcallsEnabled to false to stop calls on a session without disconnecting
it:
- 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/callsendpoint fails with a permission error; - incoming calls are declined. You still receive
call.received, followed bycall.rejectedandcall.ended; and - a SIP trunk cannot place calls through the session.
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 onhostCloudApiCalls in its call settings:
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
capabilitiesreportvideo: falseandinvite: false. Placing a video call or inviting a participant fails withunsupported_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 withsessions: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
since and until, the range is the last 7 days. groupBy selects
the groups:
day(the default) andhourreturn one group per day or hour intimezone, including empty ones. Adayrange covers at most 366 days and anhourrange at most 31 days.sessionreturns one group per number, most calls first, up to 500 numbers.groupsTruncatedistruewhen more numbers had calls.outcomereturns 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 hascallId, 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:
=, +, -, @, 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.
- 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.
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.
Read the setting with any team key or project token that has
sessions:read:
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: 0andupdatedAt: null.revisionincreases on every change. Send therevisionyou read asexpectedRevisionto refuse the update with409 state_conflictwhen someone changed the setting after you read it. - A named policy with a different
retentionDays, orcustomwithout one, fails with400 invalid_parameter. appliesTolists 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.
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 withPOST /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>.
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’srateLimitrule. - Polymorfa accepts reports while the call is live and for 10 minutes after it
ends. Later reports fail with
409, or410when 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
202but is not kept. - Polymorfa authorizes the call, not the connection: any participant your
credential can act as can report for any
connectionIdon a call that credential can reach.
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 with409 before the
operation changes call state.
Client rules apply to call setups, placements, and invites:
maxSetupsPerMinutelimits 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.allowedNumberis a comma-separated list of E.164 numbers a client token can call or invite.maxConcurrencylimits calls a client token user holds at once. A call holds its slot until it ends.
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.