wats.sh
Reference

Calling Reference

WhatsApp Calling: lifecycle endpoints, call permissions, call-button/deep-link helpers, calling webhooks, and operator constraints.

shape-only — credential-free request/webhook shapes; live call sessions and calling-access provisioning are not exercised · applies to 0.3.x-alpha · reviewed 2026-06-23

WhatsApp Calling rides three surfaces: Graph lifecycle requests, call-button/deep-link message helpers, and calls/call_permission_reply webhooks. WATS covers the request and webhook shapes — it has no SIP server implementation and does not provision calling access. Bring your WebRTC/SIP edge, then use WATS for the Graph and webhook plumbing.

Lifecycle endpoints

All five call-lifecycle callables share one Graph route — POST /{phoneNumberId}/calls — and differ only in the action field of the request body.

CallableactionPurpose
initiateCallconnectBusiness-initiated call connect with an SDP offer
preAcceptCallpre_acceptPre-accept an incoming call
acceptCallacceptAccept an incoming call with an SDP answer
rejectCallrejectReject an incoming call
terminateCallterminateTerminate an in-progress call
import { GraphClient, initiateCall, acceptCall, terminateCall } from "@wats/graph";

// wa_id recipient (the classic `to` form):
await initiateCall(client, { phoneNumberId: "555000111" }, {
  to: "+155****0000",
  session: { sdpType: "offer", sdp: "v=0\r\n..." },
  bizOpaqueCallbackData: "tracker-optional"
});

// BSUID / parent BSUID recipient (Meta `recipient` form). At least one of
// `to` or `recipient` is required; supplying both emits both and Meta routes
// by `to` (i.e. `to` takes precedence):
await initiateCall(client, { phoneNumberId: "555000111" }, {
  recipient: "BSUID-parent-abc",
  session: { sdpType: "offer", sdp: "v=0\r\n..." }
});

await acceptCall(client, { phoneNumberId: "555000111" }, {
  callId: "wacid.ABGG...",
  session: { sdpType: "answer", sdp: "v=0\r\n..." }
});
await terminateCall(client, { phoneNumberId: "555000111" }, { callId: "wacid.ABGG..." });

Direct callables are exported from root @wats/graph and from @wats/graph/endpoints/calling. PhoneNumberClient exposes bound-id variants — phone.initiateCall(input), phone.preAcceptCall(input), phone.acceptCall(input), phone.rejectCall(input), phone.terminateCall(input) — that inject the configured phoneNumberId for you. See Scoped Clients.

initiateCall recipient policy

FieldRequiredShape
toone of to / recipientphone number / wa_id, same safety policy as to on sendMessage
recipientone of to / recipientMeta BSUID / parent BSUID
sessionrequireddescriptor-safe plain object; sdpType maps to Graph sdp_type
bizOpaqueCallbackDataoptionalnon-empty, ≤ 512 (CALL_BIZ_OPAQUE_CALLBACK_DATA_MAX_LENGTH), control-character-free

When both to and recipient are supplied, both are emitted in the Graph body and Meta resolves the call to to. Supply both only when you intend to mirror that wire contract.

Call permissions

getCallPermissions reads a consumer's calling-permission state.

CallableRouteRequired query
getCallPermissionsGET /{phoneNumberId}/call_permissionsexactly one of userWaId / recipient
import { getCallPermissions } from "@wats/graph";

const result = await getCallPermissions(client, {
  phoneNumberId: "555000111",
  userWaId: "15551230000"   // OR recipient: "BSUID-parent-abc" — XOR rule
});

userWaId maps to Graph user_wa_id and recipient is forwarded verbatim. Exactly one of the two is required (XOR); supplying both or neither rejects with GraphRequestValidationError before transport. The snake_case response is normalized back to camelCase (messagingProduct, permission.expirationTime, actions[].actionName / canPerformAction / limits[].timePeriod / maxAllowed / currentUsage / limitExpirationTime); unknown fields are preserved via index signatures.

getCallPermissions is a direct callable only — there is no PhoneNumberClient.getCallPermissions bound-id helper. Pass phoneNumberId explicitly.

PhoneNumberClient.sendVoiceCall and buildWhatsAppCallDeepLink build the two call-initiation message surfaces. Both validate before transport via GraphRequestValidationError.

sendVoiceCall

await phone.sendVoiceCall({
  to: "+155****0000",
  bodyText: "Call us on WhatsApp.",
  displayText: "Call on WhatsApp",
  ttlMinutes: 100,
  payload: "opaque-context"
});

await phone.sendVoiceCall({ recipient: "US.13491208655302741918", bodyText: "Call us." });
FieldRequiredShape / wire mapping
to / recipientone or bothto phone number / recipient BSUID; Meta routes by to when both present
bodyTextrequirednon-empty
displayTextoptional≤ 20 characters → Graph display_text
ttlMinutesoptionalinteger 1..43200 → Graph ttl_minutes
payloadoptional≤ 512 characters → Graph payload; surfaces in call webhooks as ctaPayload when Meta sends cta_payload

Wire body: interactive.type = "voice_call" and interactive.action.name = "voice_call". Calling permissions, App Review, device support, and platform eligibility still apply.

import { buildWhatsAppCallDeepLink } from "@wats/graph";

const url = buildWhatsAppCallDeepLink({
  phoneNumber: "15551230000",
  bizPayload: "opaque-context"
});
// => https://wa.me/call/<BUSINESS_PHONE_NUMBER>?biz_payload=...

bizPayload maps to Graph biz_payload and surfaces in call webhooks as deeplinkPayload when Meta sends deeplink_payload. WhatsApp desktop clients do not support calling deep links.

Calling webhooks

normalizeWebhookEnvelope(...) promotes field: "calls" changes into typed calling updates when the payload shape is stable and safe. See the Webhook Reference for the full normalization contract.

Meta event / statusTyped kind
value.calls[].event === "connect"callConnect
value.calls[].event === "terminate"callTerminate
value.statuses[].status ∈ { "RINGING", "ACCEPTED", "REJECTED" }callStatus

Every emitted calling update carries updateId, wabaId, phoneNumberId, receivedAt, rawChange, and a call or callStatus payload with the guarded wire fields (camelCase throughout). Call-button and deep-link payloads surface as ctaPayload and deeplinkPayload when Meta sends cta_payload / deeplink_payload. Malformed nested calling objects, missing/unsafe ids, unsupported call events/statuses, and accessor-backed nested fields are recorded in skipped[] with reason: "malformed_field"; they do not throw host errors. Live webhook fixtures remain credential-gated.

call_permission_reply is a separate interactive-message surface: an inbound interactive.type = "call_permission_reply" message carries a camelCase callPermissionReply object (response, expirationTimestamp, isPermanent, responseSource, plus fromUserId / fromParentUserId). It is normalized alongside other interactive replies, not as a calls field change.

Operator notes

Meta documents three media/signaling modes for WhatsApp Calling:

ModeSignalingMedia
Graph APIs + webhooks + WebRTCHTTPS Graph requests and calls webhooksWebRTC with ICE, DTLS, and SRTP
SIP + WebRTCSIP over TLS to your SIP edgeWebRTC media
SIP + SDES SRTPSIP over TLS to your SIP edgeSRTP keys negotiated with SDES

Audio support is negotiated by the media stack. The current Meta reference examples include OPUS and G.711 codecs (PCMA and PCMU) in SDP. WATS passes SDP through the Calling request helpers and validates the envelope; it does not choose codecs or run media negotiation for you.

Operator constraints remain platform-side:

  • The business must be calling-eligible and normally needs a 2,000 daily messaging limit before production calling access.
  • Business-initiated calling is not available for phone numbers from USA, Canada, Egypt, Vietnam, Nigeria.
  • Calling requires App Review and the WhatsApp Business Messaging permission.
  • Calling sandbox/test-number access is limited; Meta documents Tech Partner sandbox flows separately.
  • WATS has no SIP server implementation and cannot provision calling access. Bring your WebRTC/SIP edge, then use WATS for the Graph request and webhook shapes.

Validation envelope

Path ids, to, recipient, and callId must be non-empty strings with no control characters, dot segments, slashes, query/fragment markers, malformed percent encoding, or encoded/double-encoded dot-segments. For initiateCall, at least one of to or recipient must be provided.

session must be a descriptor-safe plain object. WATS clones it before transport, maps sdpType to Graph sdp_type, and rejects accessors, toJSON, custom prototypes, cycles, unsafe prototype keys (__proto__, constructor, prototype), non-finite numbers, functions, symbols, empty/oversized strings, NUL/DEL-bearing SDP text, and serialized bodies over CALL_SESSION_MAX_BYTES (65_536). Undefined optional fields are omitted from the Graph JSON body.

The Graph error taxonomy is preserved after the request reaches transport, including calling subclasses such as CallingNotEnabledError, DuplicateCallError, and CallConnectionError. Repository tests use createMockTransport; live call sessions remain credential-gated and are not performed in CI.

  • Scoped Clients ReferencePhoneNumberClient calling methods over the bound phone-number scope.
  • Endpoints Reference — the defineEndpoint primitive every callable delegates to; Calling lifecycle and permissions route summary.
  • Webhook Referencecalls / call_permission_reply normalization and the ctaPayload / deeplinkPayload surfaces.
  • Errors Reference — calling error subclasses and the error registry.

On this page