WhatsApp audio calling is a major addition to OpenWA's developer surface. An application can answer a call with a greeting, start an outgoing call, receive the other person's audio, and keep controlling the conversation from the SDK or the Calls dashboard.
The public API and dashboard shipped in the 5.5 release. Calling remains a licensed audio preview and needs its corresponding licensed runtime patches. Installing the package alone doesn't activate that private implementation. Camera calls and ready-made telephony or AI-provider adapters are still in development.
Choose SDK or Easy API
Use the tabs below to choose an embedded Node.js SDK or an HTTP client. SDK examples assume a ready client. For Easy API, start the server, scan its QR, and use the printed address. Set these variables in the shell that runs your examples:
export OPENWA_API_URL='http://127.0.0.1:8080'export WA_API_KEY='REPLACE_WITH_YOUR_API_KEY'export OPENWA_LICENSE_KEY='YOUR_LICENSE_KEY'npx @open-wa/wa-automate@5.5.1 \ --host 127.0.0.1 --port 8080 --api-key "$WA_API_KEY" --license-key "$OPENWA_LICENSE_KEY"Python examples use requests: install it with python -m pip install requests. JavaScript examples use Node.js 22.21.1 or later and its built-in fetch. Keep the API key on your application server. Method responses contain { success: true, data }; the examples read data. The API explorer at /api-docs/ lists your server's routes and inputs.
Start with a greeting and a recording
Use Node.js 22.21.1 or later and install the current package:
npm install @open-wa/wa-automate@5.5.1Keep your licence key in your application's environment. The main package includes the browser driver and audio decoder, so the basic example needs no separate media server:
import { create } from '@open-wa/wa-automate';const client = await create({ licenseKey: process.env.OPENWA_LICENSE_KEY });await client.onIncomingCall(call => call.accept('greeting.wav', 'caller.wav'));The SDK example answers incoming calls continuously. The HTTP examples answer the current incoming offer once; send that request while the phone is ringing. cURL uses jq to extract the current call identity. The id, sessionId and generation must come from this running session, rather than from an old recording or another session.
With direct HTTP, greeting.wav must exist on the Easy API host and caller.wav is written there. A file path on the computer running Python or cURL is not uploaded automatically. The SocketClient example below attaches files from its own machine.
The first SDK argument supplies audio to the caller, in place of the microphone. The second receives audio from the caller and writes it to a file. Both paths belong to the machine running this client. Choose a greeting file that exists there and an output directory the process can write to.
OpenWA prepares the source and recording before answering. The greeting starts when the call becomes active, plays once, then leaves the microphone silent while the conversation stays open. Returning from the callback doesn't close the attachment. At call end, OpenWA releases the source and finishes the recording. If an output file already exists, it chooses a new filename rather than replacing the existing recording.
WAV files give the recording a familiar container. Raw .pcm defaults to signed 16-bit little-endian samples at 16 kHz, mono. Encoded sources are detected from their container headers; renaming an incompatible file doesn't change its format.
Use the browser's devices
For a person speaking through the browser that owns the WhatsApp session, omit both arguments:
await client.onIncomingCall(call => call.accept());That browser needs microphone permissions and working devices. A headless server without devices should attach files or application streams. DEFAULT_MIC and DEFAULT_SPEAKER let you make either default explicit when mixing roles:
import { DEFAULT_SPEAKER } from '@open-wa/wa-automate';await client.onIncomingCall(call => call.accept('greeting.wav', DEFAULT_SPEAKER));This sends a file and plays received audio through the browser's speaker. Pass null to disable a role: call.accept('greeting.wav', null) sends without a receive sink; call.accept(null, 'caller.wav') records without sending microphone audio.
Feed live application audio
Web streams are local objects; direct HTTP JSON cannot carry them. Easy API applications that need local stream processing can use the JavaScript SocketClient helper shown below. REST clients can attach JSON file or URL descriptors instead.
A voice application can supply a Web ReadableStream<Uint8Array> as its microphone and consume a Web WritableStream<Uint8Array> as its speaker:
// microphone and speaker are streams owned by your application.await client.onIncomingCall(call => call.accept(microphone, speaker));Raw stream input and received output use 16-bit little-endian PCM, 16 kHz, mono. The attachment owns its stream reader and writer and releases them when the call ends. Your application supplies the processing logic; it doesn't need to manage internal Effect scopes or attachment identifiers.
HTTP audio URLs and supported HLS URLs can also supply audio. HLS introduces buffering, so an application stream is generally a better fit for a conversation. A compatible WebSocket source or sink must speak the OpenWA audio protocol and announce its format. An arbitrary provider's wss:// URL isn't a complete integration: its framing, sample format and lifecycle still need an adapter. This preview doesn't include turnkey Twilio, Discord or AI voice-provider adapters.
For looping audio, a different raw sample rate, or an authenticated URL, use a media options object. For example:
await call.accept({ microphone: { kind: 'file', path: 'hold-music.pcm', raw: { encoding: 'pcm16le', sampleRate: 48_000, channels: 2 }, loop: true, }, speaker: { kind: 'file', path: 'caller.wav' },});Start a call and follow its state
const result = await client.startCall('447700900123', 'greeting.wav', 'caller.wav');console.log(result);Listen for incoming calls and state changes
Open the HTTP event stream before you place a call. SSE data: lines contain JSON envelopes; incoming snapshots are at payload.call, and state snapshots are at payload.state. These HTTP examples display the frames. Close the connection to stop listening. A disconnected SSE consumer must reconnect; this stream does not replay missed frames.
await client.onIncomingCall(call => console.log('Incoming:', call.id));await client.onCallState(call => { console.log(call.direction, call.state); if (call.terminal) console.log(call.endReason);});{ ok: true, status: 'requested' } means the native action accepted the request. Follow onCallState for observed progress. Neither a requested state nor an active state proves that the recipient heard audio, and OpenWA doesn't automatically redial an uncertain request.
Incoming calls can be rejected with call.reject(). During an active call, use the current Call object from a callback or fetch it with client.getActiveCall():
const call = await client.getActiveCall();if (call) { await call.mute(); await call.unmute(); await call.setMedia({ microphone: 'next-prompt.wav', speaker: 'next-recording.wav', }); // When the conversation is complete: await call.end();}setMedia() prepares the replacement before switching, then releases the old attachment. There is one media writer per call, controlled by the client that owns it. clearAudio() clears outgoing audio; for a finite greeting, it stops that greeting.
A separate receive-only observer can record without taking over the microphone:
const observer = await call.observeAudio('audit.wav');if (observer.ok) await observer.stop();Observers close at call end. A slow or failed observer closes its own sink instead of accumulating unbounded audio.
Control a call from Easy API
Get a current identity, then send it with a control request. This example mutes the microphone. Use /api/calls/endCall with { call } to hang up, or /api/calls/setCallMedia with { call, media } to replace sources. Do not serialize the SDK's bound methods.
const call = await client.getActiveCall();if (call) console.log(await call.mute());Try it from the Calls dashboard
Start Easy API with your licence:
npx @open-wa/wa-automate@5.5.1 --license-key YOUR_LICENSE_KEY --dashboardOpen /dashboard/calls on the printed API address. Search for a contact by name, number or LID and select it, or enter an international number and select that destination. Choose Bundled OpenWA greeting, enable Record received audio, then choose Start audio call. The greeting starts after the other person answers. End the call and use Download received audio for the completed recording.
For an incoming call, the page offers Answer and Reject. Choose your microphone, an audio file, or an HTTP audio URL for the source. The dashboard's browser owns its local files and devices; it asks for microphone permission when needed. HTTP URLs used by this browser need CORS access.
The Chat page also has a Call action in the conversation header. A compact call control at the top of the dashboard shows ringing, connection progress and connected time. Expand it to answer, reject, mute or end a call from another dashboard page. Navigation preserves the call and its audio settings. Reloading the browser closes its media attachment.
Incoming video offers are labelled, and can be answered with audio. The dashboard doesn't yet send camera video or render received video.
Connect an application on another machine
The SocketClient helper resolves local files and streams on the machine running that helper:
import { SocketClient } from '@open-wa/socket-client';const client = await SocketClient.connect( process.env.OPENWA_API_URL, process.env.WA_API_KEY,);await client.onIncomingCall(call => call.accept('greeting.wav', 'caller.wav'));A direct HTTP media descriptor instead resolves on the API host. These are different filesystem boundaries. Use the API's configured authentication and HTTPS/WSS when exposing it remotely. Disconnecting or reloading the helper releases its attachment; reconnect and attach fresh sources with a current Call object.
What happens when audio pauses or fails
The microphone sends silence when a live source temporarily has no data, then resumes when that stream supplies more. A finished file leaves the call open and received audio keeps playing or recording. A failed microphone source also leaves the call and receive sink open by default, so your application can replace it with setMedia().
You can explicitly choose onMediaFailure: 'end' when a media failure should hang up. Calling access expiry and browser recovery close devices, sockets and recordings. Old Call objects can't control a new browser generation.
Live input queues hold at most 200 ms of audio. If processing falls behind, OpenWA discards queued samples and logs the discarded duration, preventing an ever-growing conversation delay. You may hear a gap. Received recordings insert silence for missing timestamp intervals so later speech keeps its position; they cannot reconstruct lost speech. Live speaker playback catches up instead of replaying that missing interval.
Expected licence, stale-call and source failures return { ok: false, status, reason } with an actionable log message. Authentication and programming failures remain normal API errors. Terminal snapshots include a readable endReason with its source. An unspecified WhatsApp termination isn't relabelled as a confirmed peer hang-up or network failure.
You don't need a capability-check ritual before each call. Actions handle availability themselves, and an unavailable calling feature doesn't stop the messaging session. The licence menu and client.getLicenseFeatures() expose the feature list if you want to display it in your own UI.
Read the calling guide for the complete workflow and the calls reference for exact wire methods and schemas. Start with one greeting and one recording, then attach the stream processing your application needs.