wats.sh
Reference

Scoped Clients Reference

PhoneNumberClient and WABAClient: scoped sub-clients that bind a path param at construction.

active · reviewed 2026-04-22

PhoneNumberClient and WABAClient sit on top of the endpoint registry and bind a scope-defining path param (phoneNumberId / wabaId) at construction so call sites stop threading it through every request. They are thin ergonomic wrappers around the same defineEndpoint callables — they never re-implement path construction, sanitization, or error mapping.

Graph endpoints come in two shapes: global / graph-root endpoints with no scope id in the path, and scope-bound endpoints with a leading id (/{phoneNumberId}/messages, /{wabaId}/phone_numbers, ...). The scope-bound ones are the ones every application repeats a dozen times. Scoped sub-clients bind the id once, validate it with the same sanitizer every endpoint call would apply, and expose endpoint callables as instance methods. PhoneNumberClient.sendMessage(body) is byte-identical on the wire to sendMessage(graphClient, { phoneNumberId }, body).

new PhoneNumberClient({ graphClient, phoneNumberId })
  └──→ assertSafePathParamValue("phoneNumberId", phoneNumberId)   // shared sanitizer
  └──→ .sendMessage(body, opts?)
         └──→ sendMessage(graphClient, { phoneNumberId }, body, opts)   // endpoint callable
                └──→ graphClient.request(...)                            // transport + header/CRLF guards
                       └──→ error registry on non-2xx

When to use which

UsePrefer
Most application code; multiple calls per scopescoped sub-client (new PhoneNumberClient)
One-off / scripts / testsdirect defineEndpoint callable
Custom endpoints you authoreddirect defineEndpoint callable
Registering listeners / handlers keyed by scopescoped sub-client

Direct callables stay first-class — the sub-clients build on them, they do not replace them.

PhoneNumberClient

Scope: a single WhatsApp Business phone number id. Used for every call on /{phoneNumberId}/....

import {
  GraphClient,
  PhoneNumberClient,
  type PhoneNumberClientConfig
} from "@wats/graph";

const graphClient = new GraphClient({
  accessToken: process.env.WHATSAPP_TOKEN!,
  apiVersion: "v25.0"
});

const phone = new PhoneNumberClient({
  graphClient,
  phoneNumberId: "555000111"
});

await phone.sendMessage({
  messaging_product: "whatsapp",
  to: "15551230000",
  type: "text",
  text: { body: "hello" }
});

Construction contract

PhoneNumberClient validates its config at construction and throws GraphRequestValidationError (an instanceof WatsError — see errors) on any violation:

  • config must be a non-null object.
  • config.graphClient must duck-type as a GraphClient: it must expose a .request(options) function. A bare {} is rejected so misconfigurations fail at the call site, not deep inside the request pipeline.
  • config.phoneNumberId must be a non-empty, non-whitespace string.
  • config.phoneNumberId must pass assertSafePathParamValue("phoneNumberId", value) — the same helper the endpoint registry uses at call time, so the rules are reused byte-for-byte:
    • No dot-segments (".", "..").
    • No forward slash (/) or backslash (\\).
    • No query-string (?) or fragment (#) markers.
    • No ASCII control characters (U+0000..U+001F, U+007F), which includes CR / LF / NUL.

An invalid phoneNumberId fails at construction, never at the first call.

Method catalog

All implemented methods delegate through the same POST /{phoneNumberId}/messages endpoint callable unless another endpoint is listed.

MethodStatusEndpoint
createGroup / listGroups / group(groupId)implementedPOST/GET /{phoneNumberId}/groups; factory returns GroupClient bound to groupId
sendMessageimplementedraw body passthrough
sendTextimplementedtext payload
sendImage / sendVideo / sendDocument / sendStickerimplementedmedia payloads
sendAudioimplementedaudio payload; supports voice?: boolean
sendLocation / sendContactsimplementedlocation / contacts payloads
sendReaction / removeReactionimplementedreaction payload
sendButtons / sendList / sendCtaUrl / sendCallPermissionRequest / sendVoiceCallimplementedinteractive button/list/CTA/call-permission/voice-call payloads
sendProduct / sendProducts / sendCatalogimplementedcommerce interactive payloads
requestLocationimplementedinteractive location request payload
sendTemplateimplementedapproved template send payload
sendMarketingTemplateimplementedPOST /{phoneNumberId}/marketing_messages; recipient supports BSUID routing when to is omitted
markMessageAsRead / indicateTypingimplementedread status / typing payloads
uploadAndSendImage / uploadAndSendVideo / uploadAndSendAudio / uploadAndSendDocument / uploadAndSendStickerimplementedupload media, then send the resulting media id
requestVerificationCode / verifyPhoneNumber / registerPhoneNumber / deregisterPhoneNumber / setTwoStepVerificationPinimplementedphone registration lifecycle request shapes
createQrCode / listQrCodes / getQrCode / updateQrCode / deleteQrCodeimplementedphone-number QR code CRUD
getBusinessPublicKey / setBusinessPublicKeyimplementedbusiness public-key read/update helpers
getInfo({ fields? })implementedGET /{phoneNumberId}
getSettings({ fields?, includeSipCredentials? })implementedGET /{phoneNumberId}/settings; includeSipCredentials maps to include_sip_credentials and responses may be sensitive
updateSettings({ storageConfiguration })implementedPOST /{phoneNumberId}/settings; emits storage_configuration and never data_localization_region
getBusinessProfile({ fields? })implementedGET /{phoneNumberId}/whatsapp_business_profile
getCommerceSettings({ fields? })implementedGET /{phoneNumberId}/whatsapp_commerce_settings
updateBusinessProfile({ about?, address?, description?, email?, vertical?, websites?, profilePictureHandle? })implementedPOST /{phoneNumberId}/whatsapp_business_profile; emits messaging_product: "whatsapp" plus Graph profile_picture_handle
updateCommerceSettings({ isCartEnabled?, isCatalogVisible? })implementedPOST /{phoneNumberId}/whatsapp_commerce_settings; emits is_cart_enabled / is_catalog_visible
initiateCall / preAcceptCall / acceptCall / rejectCall / terminateCallimplementedPOST /{phoneNumberId}/calls, actions connect / pre_accept / accept / reject / terminate
uploadMedia scoped methodnot implemented yetuse root uploadMedia(client, { phoneNumberId }, ...) from @wats/graph

Live template/Flow/calling validation, production Flow hosting, encrypted data-exchange handling, live call sessions, and broad admin APIs remain credential-gated or unimplemented. Consumer code can type-check this list via the exported method/input types.

Groups helpers

Groups hang off the business phone-number id, not the WABA id. PhoneNumberClient binds that id for create/list, and phone.group(groupId) returns a GroupClient that validates and binds the group id at construction.

const created = await phone.createGroup({
  subject: "Launch team",
  joinApprovalMode: "approval_required"
});

const groups = await phone.listGroups({ limit: "25" });

const group = phone.group("GROUP_ID_FROM_WEBHOOK");
await group.update({ description: "Support and launch coordination" });
await group.resetInviteLink();

GroupClient exposes getInfo, update, delete, getInviteLink, resetInviteLink, removeParticipants, getJoinRequests, approveJoinRequests, and rejectJoinRequests. Each method injects the bound groupId after inspecting optional params, so caller input cannot override the constructor scope. Direct callables remain available from @wats/graph/endpoints/groups.

updateSettings({ storageConfiguration })

Binds the configured phone-number id and POSTs local-storage settings to /{phoneNumberId}/settings. Pass camelCase storageConfiguration; WATS emits Graph storage_configuration. Registration helpers live separately: registerPhoneNumber(...) may emit Graph data_localization_region because Meta still accepts it on phone registration, not on settings updates.

Authentication template OTP builders map public camelCase fields to Graph wire names: supportedApps becomes supported_apps records with package_name and signature_hash; autofillText and zeroTapTermsAccepted become autofill_text and zero_tap_terms_accepted.

await phone.updateSettings({
  storageConfiguration: { status: "ENABLED" }
});

sendText(input, opts?)

Builds the Meta text payload and delegates to sendMessage(...) with the bound phoneNumberId. It is the low-level primitive behind WhatsApp.startChat(...).

await phone.sendText({
  to: "+155****0000",
  text: "Hello",
  previewUrl: false,
  replyToMessageId: "wamid.OPTIONAL"
});

Two v24 send-message deltas:

await phone.sendAudio({
  to: "+155****0000",
  mediaId: "AUDIO_ID",
  voice: true
});

await phone.sendCallPermissionRequest({
  to: "+155****0000",
  bodyText: "May we call you?",
  replyToMessageId: "wamid.OPTIONAL"
});

sendAudio(..., { voice: true }) emits Graph audio.voice = true; omitting voice preserves the standard audio body. sendCallPermissionRequest(...) emits interactive.type = "call_permission_request" with interactive.action.name = "call_permission_request" and validates unknown or malformed fields before transport.

sendVoiceCall(...) builds Meta's interactive voice_call call-button message:

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

The wire body is interactive.type = "voice_call" and interactive.action.name = "voice_call". Optional parameters map to Graph display_text, ttl_minutes, and payload; payload surfaces in call webhooks as ctaPayload when Meta sends cta_payload. The helper accepts to, recipient, or both; Meta routes by to when both are present. Field caps, validation, calling webhooks, and operator constraints live on the Calling Reference page.

Use buildWhatsAppCallDeepLink({ phoneNumber, bizPayload? }) for https://wa.me/call/<BUSINESS_PHONE_NUMBER> links. bizPayload maps to biz_payload and surfaces in call webhooks as deeplinkPayload when Meta sends deeplink_payload. WhatsApp desktop clients do not support calling deep links.

Validation is runtime-enforced for JavaScript callers and rejects with GraphRequestValidationError before any transport call:

  • input must be a non-null object.
  • to must be E.164-ish digits with optional leading +, max 15 digits; not empty, whitespace-only, non-string, a URL/path/address, or control-character-bearing. This is deliberately not a contacts check — arbitrary non-contact phone numbers are accepted when they satisfy the shape.
  • text must be non-empty and max 4096 characters.
  • previewUrl, when provided, must be boolean.
  • replyToMessageId, when provided, must be non-empty, max 256 characters, control-character-free.
  • The Graph error taxonomy is preserved after the request reaches Meta/transport.

sendImage|sendVideo|sendAudio|sendDocument|sendSticker(input, opts?)

Outbound media helpers build Meta messages payloads that reference an existing media id or an http(s) link. They do not upload bytes; use the media runtime (uploadMedia, downloadMedia, downloadMediaBytes, deleteMedia, decryptEncryptedMedia) for media file operations.

await phone.sendImage({
  to: "+155****0000",
  mediaId: "MEDIA_ID_FROM_UPLOAD",
  caption: "Optional image caption",
  replyToMessageId: "wamid.OPTIONAL"
});

await phone.sendDocument({
  to: "+155****0000",
  link: "https://cdn.example.test/report.pdf",
  filename: "report.pdf"
});

Validation rejects with GraphRequestValidationError before any transport call:

  • input must be a non-null object.
  • to follows the sendText recipient policy.
  • Exactly one of mediaId and link is required. Missing both or providing both rejects.
  • mediaId: non-empty string, control-character-free, at most 2048 characters.
  • link: non-empty http: or https: URL, control-character-free, at most 2048 characters.
  • caption: image/video/document only; non-empty when provided; capped at 1024 characters.
  • filename: document-only; non-empty when provided; control-character-free; capped at 256 characters.
  • replyToMessageId: same non-empty/control-free/256-character policy as sendText.
  • Graph error taxonomy preserved after the request reaches transport.

Remaining composers

PhoneNumberClient also exposes sendLocation, sendContacts, sendReaction, removeReaction, sendButtons, sendList, sendCtaUrl, sendProduct, sendProducts, sendCatalog, requestLocation, sendTemplate, markMessageAsRead, and indicateTyping. Each validates public JavaScript inputs before transport and delegates to sendMessage(...) with the bound phoneNumberId. Invalid options reject with GraphRequestValidationError; Graph API failures preserve the error registry taxonomy.

Calling lifecycle helpers

PhoneNumberClient exposes the credential-free Calling API lifecycle over the bound phone-number scope:

await phone.initiateCall({ to: "+155****0000", session: { sdpType: "offer", sdp: "v=0\r\n..." } });
await phone.acceptCall({ callId: "wacid.ABGG...", session: { sdpType: "answer", sdp: "v=0\r\n..." } });
await phone.terminateCall({ callId: "wacid.ABGG..." });

initiateCall / preAcceptCall / acceptCall / rejectCall / terminateCall all delegate to POST /{phoneNumberId}/calls; only the Graph body action differs (connect / pre_accept / accept / reject / terminate). initiateCall accepts to (a wa_id) or recipient (a Meta BSUID / parent BSUID); at least one is required, and to takes precedence when both are supplied.

Validation, call permissions, call-button/deep-link helpers (sendVoiceCall, buildWhatsAppCallDeepLink), calling webhooks (ctaPayload / deeplinkPayload), media/signaling modes, and operator constraints (App Review, 2,000 daily messaging limit, USA/Canada/Egypt/Vietnam/Nigeria restrictions, Tech Partner sandbox, no SIP server implementation) live on the dedicated Calling Reference page. Status: shape-only — MockTransport and synthetic webhook tests only; live call sessions remain credential-gated.

Bound-id path substitution

PhoneNumberClient.sendMessage(body, opts?) is a pure delegation to the sendMessage endpoint-registry callable with { phoneNumberId: this.phoneNumberId } injected. sendText and the media helpers build typed bodies and then use that same path. The wire-level result is byte-identical to calling sendMessage(graphClient, { phoneNumberId }, body, opts) directly.

WABAClient

Scope: a WhatsApp Business Account id (wabaId). Used for endpoints rooted at /{wabaId}/... — most visibly the phone-number registry.

import { GraphClient, WABAClient } from "@wats/graph";

const waba = new WABAClient({
  graphClient,
  wabaId: "9876543210"
});

const { data } = await waba.listPhoneNumbers();
for (const pn of data ?? []) {
  console.log(pn.id, pn.display_phone_number);
}

Construction contract

Parallel to PhoneNumberClient:

  • config must be a non-null object.
  • config.graphClient must expose a .request(options) function.
  • config.wabaId must be a non-empty, non-whitespace string.
  • config.wabaId must pass assertSafePathParamValue("wabaId", value) — same sanitizer rules (no dot-segments, no slashes, no ?/#, no ASCII control chars).

Violations throw GraphRequestValidationError at construction.

Method catalog

MethodStatusEndpoint
getInfo({ fields? })implementedGET /{wabaId}
listSubscribedApps()implementedGET /{wabaId}/subscribed_apps
listPhoneNumbers({ fields?, limit?, after?, before? })implementedGET /{wabaId}/phone_numbers
createPhoneNumberimplementedPOST /{wabaId}/phone_numbers
setCallbackOverride / clearCallbackOverrideimplementedPOST /{wabaId}/subscribed_apps; WABA callback override
listMessageTemplatesimplementedGET /{wabaId}/message_templates
createMessageTemplateimplementedPOST /{wabaId}/message_templates
getMessageTemplateimplementedGET /{templateId}
updateMessageTemplateimplementedPOST /{templateId}
deleteMessageTemplateimplementedDELETE /{wabaId}/message_templates?name=...&hsm_id=...
compareTemplates / unpauseTemplate / migrateTemplatesimplementedadvanced template helpers
archiveTemplates / unarchiveTemplates / upsertAuthenticationTemplateimplementedarchive/unarchive and auth-template upsert helpers
listTemplateGroups / createTemplateGroup / getTemplateGroup / updateTemplateGroup / deleteTemplateGroup / getTemplateGroupAnalyticsimplemented/{wabaId}/template_groups, /{templateGroupId}, /{wabaId}/template_group_analytics
listFlowsimplementedGET /{wabaId}/flows
createFlowimplementedPOST /{wabaId}/flows
getFlowimplementedGET /{flowId}
updateFlowMetadataimplementedPOST /{flowId}
updateFlowJsonimplementedPOST /{flowId}/assets
publishFlowimplementedPOST /{flowId}/publish
deleteFlowimplementedDELETE /{flowId}
deprecateFlowimplementedPOST /{flowId}/deprecate
getFlowAssetsimplementedGET /{flowId}/assets
getFlowMetrics / migrateFlowsimplementedFlow metrics and migration helpers
subscribeAppnot implemented yetPOST /{wabaId}/subscribed_apps

The phone-number, template, and Flow endpoint callables are also exported from @wats/graph so direct-callable users do not need the sub-client:

import {
  buildTemplateBodyComponent,
  createMessageTemplate,
  listMessageTemplates
} from "@wats/graph";

const { data } = await listMessageTemplates(graphClient, {
  wabaId: "999",
  status: "APPROVED"
});

await createMessageTemplate(graphClient, { wabaId: "999" }, {
  name: "order_ready",
  language: "en_US",
  category: "UTILITY",
  components: [buildTemplateBodyComponent({ text: "Hi {{1}}" })]
});

Template groups and analytics

WABAClient exposes Template Group helpers over the bound WABA id: listTemplateGroups, createTemplateGroup, getTemplateGroup, updateTemplateGroup, deleteTemplateGroup, and getTemplateGroupAnalytics. Direct callables with the same names are exported from root @wats/graph and @wats/graph/endpoints/templates.

await waba.listTemplateGroups({ limit: "25" });
await waba.createTemplateGroup({
  name: "launch_group",
  category: "MARKETING",
  templateIds: ["template-id-1"]
});
await waba.getTemplateGroupAnalytics({
  templateGroupId: "template-group-id",
  metricTypes: ["sent", "delivered"]
});

The wire endpoints are Graph template_groups and template_group_analytics. WATS does not claim a dashboard or undocumented metric schema; unknown analytics fields are preserved structurally.

Template and Flow helpers are credential-free, shape-only SDK surfaces: they build and validate Graph request shapes and are covered through MockTransport. They do not run live Meta/WABA mutations in CI.

import { buildFlowJson, createFlow, listFlows } from "@wats/graph";

await listFlows(graphClient, { wabaId: "999", status: "DRAFT" });

await createFlow(graphClient, { wabaId: "999" }, {
  name: "signup_flow",
  categories: ["SIGN_UP"],
  endpointUri: "https://flows.example.test/data-exchange",
  flowJson: buildFlowJson({
    version: "7.0",
    screens: [{ id: "WELCOME", layout: { type: "SingleColumnLayout", children: [] } }]
  })
});

Flow JSON/data-exchange helpers enforce finite local caps before transport: FLOW_JSON_MAX_DEPTH = 16, FLOW_JSON_MAX_ARRAY_LENGTH = 1000, FLOW_JSON_MAX_SCREENS = 50, FLOW_JSON_MAX_COMPONENTS = 1000, FLOW_JSON_MAX_STRING_LENGTH = 16384, FLOW_JSON_MAX_BYTES = 131072. Malformed runtime inputs reject with GraphRequestValidationError; live Flow mutations and production Flow hosting remain credential-gated.

Business/admin inventory (read-only)

Credential-free, MockTransport-tested read-only business/admin inventory callables and scoped methods:

import {
  getWabaInfo,
  getBusinessProfile,
  getPhoneNumberSettings
} from "@wats/graph";

await waba.getInfo({ fields: ["id", "name", "business_verification_status"] });
await waba.listSubscribedApps();
await waba.listPhoneNumbers({ fields: ["id", "display_phone_number"], limit: "25" });

await phone.getInfo({ fields: ["id", "display_phone_number", "quality_rating"] });
await phone.getBusinessProfile({ fields: ["about", "address", "websites"] });
await phone.getCommerceSettings({ fields: ["is_cart_enabled", "is_catalog_visible"] });
await phone.getSettings({ fields: "calling", includeSipCredentials: false });

Direct callables mirror those methods: getWabaInfo, listSubscribedApps, listPhoneNumbers, getPhoneNumberInfo, getPhoneNumberSettings, getBusinessProfile, getCommerceSettings, updateBusinessProfile, updateCommerceSettings. getWabaInfo({ fields: ["health_status"] }) normalizes calling SIP health to healthStatus.canReceiveCallSip when Meta returns health_status.can_receive_call_sip. Exported from root @wats/graph and from @wats/graph/endpoints/business-management.

Validation is fail-closed before transport: path ids reject raw/encoded/double-encoded traversal and control characters; fields accepts a string or dense readonly string array, joined with commas through URLSearchParams; includeSipCredentials must be boolean when provided and maps to Graph include_sip_credentials=true|false. getPhoneNumberSettings({ includeSipCredentials: true }) may return SIP credentials as camelCase sipUserPassword on calling.sip.servers[]; treat that response as sensitive and keep it out of logs. Profile mutations require at least one profile field and map camelCase profilePictureHandle to Graph profile_picture_handle; commerce mutations require at least one boolean setting and map isCartEnabled / isCatalogVisible to Graph is_cart_enabled / is_catalog_visible. Broader admin mutations are not implemented yet.

Block API, OBA, and display-name review helpers

Credential-free, MockTransport-tested request-shape helpers for business phone-number deltas. They make no live Meta calls in CI, perform no credential validation, and make no automatic user-block decisions.

await phone.listBlockedUsers();
await phone.blockUsers({ users: ["15551234567"] });
await phone.unblockUsers({ users: ["15551234567"] });

await phone.getOfficialBusinessAccountStatus({ fields: ["oba_status", "status_message"] });
await phone.requestOfficialBusinessAccountReview({
  businessWebsiteUrl: "https://example.com",
  primaryCountryOfOperation: "US"
});
await phone.submitDisplayNameForReview({ newDisplayName: "Acme Support" });

Direct callables mirror the scoped methods, exported from root @wats/graph and @wats/graph/endpoints/business-management: listBlockedUsers, blockUsers, unblockUsers, getOfficialBusinessAccountStatus, requestOfficialBusinessAccountReview, submitDisplayNameForReview. Wire paths: GET|POST|DELETE /{phoneNumberId}/block_users, GET|POST /{phoneNumberId}/official_business_account, and POST /{phoneNumberId} with Graph new_display_name. OBA review bodies use business_website_url and primary_country_of_operation plus optional primary_language, parent_business_or_brand, supporting_links, and additional_supporting_information.

Validation rejects bad path ids, empty/non-array/sparse/accessor-backed users, non-phone-number user strings, invalid display names, non-http(s) OBA URLs, invalid country codes, duplicate or invalid supporting links, GET bodies, and unsafe headers before transport with GraphRequestValidationError. The Graph error taxonomy is preserved after a request reaches transport.

Interplay with the error registry

Both sub-clients route all errors through GraphClient.request, which routes Graph error envelopes through resolveRegisteredError(code, subcode?). instanceof narrowing works exactly as with the direct callable shape:

import {
  PhoneNumberClient,
  GraphAuthError,
  UnsupportedMessageTypeError,
  InvalidParameterError
} from "@wats/graph";

try {
  await phone.sendMessage(body);
} catch (error) {
  if (error instanceof UnsupportedMessageTypeError) {
    // code 131051 — sibling-NOT InvalidParameterError / GraphAuthError
    // (the two would otherwise be reasonable mis-guesses for a 400)
  } else if (error instanceof GraphAuthError) {
    // 401/403 with OAuth / code 190, etc.
  }
}

The sibling-NOT language matters: an instanceof UnsupportedMessageTypeError on a 131051 response is NOT also an instanceof InvalidParameterError and NOT an instanceof GraphAuthError — the taxonomy is axis-exclusive. The sub-client test suites pin this for at least two error pairs each.

What the sub-clients deliberately do NOT do

  • No separate TemplateClient, MediaClient, or FlowClient; WABA-scoped template and Flow operations live on WABAClient plus direct callables.
  • No OAuth token refresh or lifecycle management.
  • No convenience helpers that aren't in pywa.
  • The legacy GraphMessagesEndpoint class (exposed as client.messages) is preserved — PhoneNumberClient does not replace or remove it.

On this page