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-2xxWhen to use which
| Use | Prefer |
|---|---|
| Most application code; multiple calls per scope | scoped sub-client (new PhoneNumberClient) |
| One-off / scripts / tests | direct defineEndpoint callable |
| Custom endpoints you authored | direct defineEndpoint callable |
| Registering listeners / handlers keyed by scope | scoped 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:
configmust be a non-null object.config.graphClientmust duck-type as aGraphClient: 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.phoneNumberIdmust be a non-empty, non-whitespace string.config.phoneNumberIdmust passassertSafePathParamValue("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.
- No dot-segments (
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.
| Method | Status | Endpoint |
|---|---|---|
createGroup / listGroups / group(groupId) | implemented | POST/GET /{phoneNumberId}/groups; factory returns GroupClient bound to groupId |
sendMessage | implemented | raw body passthrough |
sendText | implemented | text payload |
sendImage / sendVideo / sendDocument / sendSticker | implemented | media payloads |
sendAudio | implemented | audio payload; supports voice?: boolean |
sendLocation / sendContacts | implemented | location / contacts payloads |
sendReaction / removeReaction | implemented | reaction payload |
sendButtons / sendList / sendCtaUrl / sendCallPermissionRequest / sendVoiceCall | implemented | interactive button/list/CTA/call-permission/voice-call payloads |
sendProduct / sendProducts / sendCatalog | implemented | commerce interactive payloads |
requestLocation | implemented | interactive location request payload |
sendTemplate | implemented | approved template send payload |
sendMarketingTemplate | implemented | POST /{phoneNumberId}/marketing_messages; recipient supports BSUID routing when to is omitted |
markMessageAsRead / indicateTyping | implemented | read status / typing payloads |
uploadAndSendImage / uploadAndSendVideo / uploadAndSendAudio / uploadAndSendDocument / uploadAndSendSticker | implemented | upload media, then send the resulting media id |
requestVerificationCode / verifyPhoneNumber / registerPhoneNumber / deregisterPhoneNumber / setTwoStepVerificationPin | implemented | phone registration lifecycle request shapes |
createQrCode / listQrCodes / getQrCode / updateQrCode / deleteQrCode | implemented | phone-number QR code CRUD |
getBusinessPublicKey / setBusinessPublicKey | implemented | business public-key read/update helpers |
getInfo({ fields? }) | implemented | GET /{phoneNumberId} |
getSettings({ fields?, includeSipCredentials? }) | implemented | GET /{phoneNumberId}/settings; includeSipCredentials maps to include_sip_credentials and responses may be sensitive |
updateSettings({ storageConfiguration }) | implemented | POST /{phoneNumberId}/settings; emits storage_configuration and never data_localization_region |
getBusinessProfile({ fields? }) | implemented | GET /{phoneNumberId}/whatsapp_business_profile |
getCommerceSettings({ fields? }) | implemented | GET /{phoneNumberId}/whatsapp_commerce_settings |
updateBusinessProfile({ about?, address?, description?, email?, vertical?, websites?, profilePictureHandle? }) | implemented | POST /{phoneNumberId}/whatsapp_business_profile; emits messaging_product: "whatsapp" plus Graph profile_picture_handle |
updateCommerceSettings({ isCartEnabled?, isCatalogVisible? }) | implemented | POST /{phoneNumberId}/whatsapp_commerce_settings; emits is_cart_enabled / is_catalog_visible |
initiateCall / preAcceptCall / acceptCall / rejectCall / terminateCall | implemented | POST /{phoneNumberId}/calls, actions connect / pre_accept / accept / reject / terminate |
uploadMedia scoped method | not implemented yet | use 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:
inputmust be a non-null object.tomust 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.textmust 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:
inputmust be a non-null object.tofollows thesendTextrecipient policy.- Exactly one of
mediaIdandlinkis required. Missing both or providing both rejects. mediaId: non-empty string, control-character-free, at most 2048 characters.link: non-emptyhttp:orhttps: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 assendText.- 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:
configmust be a non-null object.config.graphClientmust expose a.request(options)function.config.wabaIdmust be a non-empty, non-whitespace string.config.wabaIdmust passassertSafePathParamValue("wabaId", value)— same sanitizer rules (no dot-segments, no slashes, no?/#, no ASCII control chars).
Violations throw GraphRequestValidationError at construction.
Method catalog
| Method | Status | Endpoint |
|---|---|---|
getInfo({ fields? }) | implemented | GET /{wabaId} |
listSubscribedApps() | implemented | GET /{wabaId}/subscribed_apps |
listPhoneNumbers({ fields?, limit?, after?, before? }) | implemented | GET /{wabaId}/phone_numbers |
createPhoneNumber | implemented | POST /{wabaId}/phone_numbers |
setCallbackOverride / clearCallbackOverride | implemented | POST /{wabaId}/subscribed_apps; WABA callback override |
listMessageTemplates | implemented | GET /{wabaId}/message_templates |
createMessageTemplate | implemented | POST /{wabaId}/message_templates |
getMessageTemplate | implemented | GET /{templateId} |
updateMessageTemplate | implemented | POST /{templateId} |
deleteMessageTemplate | implemented | DELETE /{wabaId}/message_templates?name=...&hsm_id=... |
compareTemplates / unpauseTemplate / migrateTemplates | implemented | advanced template helpers |
archiveTemplates / unarchiveTemplates / upsertAuthenticationTemplate | implemented | archive/unarchive and auth-template upsert helpers |
listTemplateGroups / createTemplateGroup / getTemplateGroup / updateTemplateGroup / deleteTemplateGroup / getTemplateGroupAnalytics | implemented | /{wabaId}/template_groups, /{templateGroupId}, /{wabaId}/template_group_analytics |
listFlows | implemented | GET /{wabaId}/flows |
createFlow | implemented | POST /{wabaId}/flows |
getFlow | implemented | GET /{flowId} |
updateFlowMetadata | implemented | POST /{flowId} |
updateFlowJson | implemented | POST /{flowId}/assets |
publishFlow | implemented | POST /{flowId}/publish |
deleteFlow | implemented | DELETE /{flowId} |
deprecateFlow | implemented | POST /{flowId}/deprecate |
getFlowAssets | implemented | GET /{flowId}/assets |
getFlowMetrics / migrateFlows | implemented | Flow metrics and migration helpers |
subscribeApp | not implemented yet | POST /{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, orFlowClient; WABA-scoped template and Flow operations live onWABAClientplus direct callables. - No OAuth token refresh or lifecycle management.
- No convenience helpers that aren't in pywa.
- The legacy
GraphMessagesEndpointclass (exposed asclient.messages) is preserved —PhoneNumberClientdoes not replace or remove it.
Related
- Endpoints Reference — the
defineEndpointprimitive every sub-client method delegates to. - Client Reference —
GraphClientconstruction andrequestsemantics. - Errors Reference — the error registry that
instanceofnarrowing rides on.