WhatsApp facade reference (`WhatsApp`)
The WhatsApp composition root: what it binds, what it exposes, and when to bypass it.
active · reviewed 2026-07-05
WhatsApp is the composition root for application code. It does not hide the lower-level packages; it binds them into one object so handlers, listeners, webhook dispatch, and scoped Graph calls share the same runtime context.
Construction
import { WhatsApp, createWhatsApp } from "@wats/core";
import { GraphClient, createFetchTransport } from "@wats/graph";
const graphClient = new GraphClient({
accessToken: process.env.META_WA_TOKEN!,
apiVersion: "v25.0",
transport: createFetchTransport()
});
const wa = new WhatsApp({
graphClient,
phoneNumberId: "1234567890",
wabaId: "99999",
routerOptions: { concurrency: "sequential" }
});Required:
graphClient: GraphClient— or a structural object exposingrequest(...).
Optional (WhatsAppFacadeConfig):
phoneNumberId?: string— createswa.phoneNumberClient.wabaId?: string— createswa.wabaClient.router?: TypedRouter— reuse an existing router.observer?: RouterObserver— dispatch/listener observability hooks.routerOptions?: TypedRouterOptions— options for a default router.listenerRegistry?: ListenerRegistry— reuse an existing listener registry.listenerRegistryOptions?: ListenerRegistryOptions— options for lazy registry creation.
Construction-time validation checks required shapes immediately. Facade config failures throw WhatsAppFacadeConfigError; path-unsafe scoped ids propagate the underlying GraphRequestValidationError from the Graph scoped-client validators.
Factory
createWhatsApp(options) is the one-call path for the 80% case. It constructs the GraphClient internally so you do not wire one by hand. The only required option is accessToken; apiVersion defaults to "v25.0" and transport defaults to createFetchTransport().
import { createWhatsApp } from "@wats/core";
const wa = createWhatsApp({
accessToken: process.env.META_WA_TOKEN!,
phoneNumberId: "1234567890"
});CreateWhatsAppOptions accepts the same phoneNumberId / wabaId / router / observer / routerOptions / listenerRegistry / listenerRegistryOptions fields as WhatsAppFacadeConfig, plus:
apiVersion?: string— defaults to"v25.0".baseUrl?: string— Graph base URL; defaults tohttps://graph.facebook.com/.transport?: Transport— defaults tocreateFetchTransport().
accessToken flows into the Authorization header, so the factory validates it at the boundary: non-string / empty / whitespace / control-char / over-4096-char values throw WhatsAppFacadeConfigError with code invalid_access_token before any client is built. The token value is never placed in an error message. Malformed apiVersion, baseUrl, and transport throw invalid_api_version, invalid_base_url, and invalid_transport respectively. The remaining config is validated by the WhatsApp constructor.
Exposed components
wa.graphClient; // GraphClient
wa.phoneNumberClient; // PhoneNumberClient | undefined
wa.wabaClient; // WABAClient | undefined
wa.router; // TypedRouter
wa.listenerRegistry; // ListenerRegistry | undefined
wa.activeListenerCount; // numberAbsent ids produce undefined scoped clients. Use optional chaining:
await wa.phoneNumberClient?.sendMessage({
messaging_product: "whatsapp",
to: "15551230000",
type: "text",
text: { body: "hello" }
});Group helpers
When constructed with phoneNumberId, the facade exposes thin Groups helpers over the phone-number scoped client:
await wa.createGroup({ subject: "Launch team" });
await wa.sendGroupMessage({ groupId: "120363...", text: "Hello group" });
const groupClient = wa.group("120363...");createGroup returns the async Graph acknowledgement with camelCase public fields such as requestId; the group id and inviteLink arrive later through the group_lifecycle_update webhook. wa.group(groupId) returns the scoped GroupClient, whose read methods expose camelCase response fields such as joinApprovalMode, creationTimestamp, and totalParticipantCount. Meta snake_case stays only at the Graph wire boundary.
sendGroupMessage sends a text payload to POST /{phoneNumberId}/messages with recipient_type: "group" and to set to the group id. Missing phoneNumberId or malformed group/text input rejects before transport with GraphRequestValidationError. Use listen({ groupId: "120363..." }) to wait for a group message/status or group_*_update webhook from one group.
Groups enforce Meta's bounded surface: subject ≤128, description ≤2048, and participant removal accepts at most 8 ids. Photo upload is not implemented in this facade slice; there is no direct participant-add helper and no promote/demote helper because official Cloud API groups are invite-only and the business is the sole admin.
Handlers
wa.on(filter, handler) delegates directly to the underlying TypedRouter and returns the same RegistrationHandle.
import { and, message } from "@wats/core/filtersTyped";
const handle = wa.on(and(message, message.textMatches(/hello/i)), async (ctx) => {
if (ctx.update.kind !== "message") return;
await wa.phoneNumberClient?.sendMessage({
messaging_product: "whatsapp",
to: ctx.update.message.from,
type: "text",
text: { body: "hello back" }
});
});
handle.unregister();onMessage(handler) and onStatus(handler) are sugar over the broadest kind filter — equivalent to wa.on(filtersTyped.message, handler) and wa.on(filtersTyped.status, handler). Use them when you want every inbound message or status update without composing a filter.
wa.onMessage(async (ctx) => {
await wa.sendText({ to: ctx.update.message.from, text: "ack" });
});
wa.onStatus((ctx) => {
// every status update — sent, delivered, read, failed
});Router dispatch rules apply unchanged:
- registration-order matching
- sequential or parallel handler execution
"stop"return to halt subsequent handlers- handler/predicate errors collected into
DispatchReport.errors - dispatch resolves rather than propagating handler exceptions
Listeners
wa.listen(options) registers a one-shot listener for a future typed update. The facade lazily creates a default listener registry when needed.
const nextReply = wa.listen({
type: "message",
from: "15551230000",
timeoutMs: 30_000,
description: "wait for next customer reply"
});
const update = await nextReply.promise;Options:
type: "message" | "status" | "account" | "unknown" | "callConnect" | "callTerminate" | "callStatus" | "groupLifecycle" | "groupParticipants" | "groupSettings" | "groupStatus"from?: string— currently narrows message sender or status recipient.groupId?: string— group narrower for group messages/statuses andgroup_*_updatewebhooks.filter?: TypedFilter<...>— additional typed constraint.timeoutMs?: numbersignal?: AbortSignaldescription?: string
Listener evaluation runs before normal handler dispatch when wa.dispatch(update) is called. Listeners are additive; a matched listener does not prevent normal handlers from running.
Sent-result waiters
wa.sendText(...) (and wa.startChat(...), retained as an alias) return a WhatsAppWaitableSentResult: the normal Graph send response plus pywa-like helper methods:
const sent = await wa.sendText({ to: "15551230000", text: "Need help?" });
const reply = await sent.waitForReply({ timeoutMs: 30_000 });
const read = await sent.waitUntilRead({ timeoutMs: 60_000 });Every facade send method that produces a sent message returns the same waitable shape — sendButtons, sendList, sendCtaUrl, sendImage, sendVideo, sendAudio, sendDocument, sendSticker, sendLocation, sendContacts, sendReaction, removeReaction, sendProduct, sendProducts, sendCatalog, requestLocation, sendTemplate, and sendMarketingTemplate. The recipient for the waiters is the input to field, falling back to the contacts[].wa_id in the Graph response.
sendMarketingTemplate returns GraphMessagesMarketingTemplateResponse & WhatsAppSentResultWaiters, so the marketing-specific messages[].message_status and contacts[].user_id fields are preserved alongside the waiters.
markMessageAsRead and indicateTyping are not waitable: they mutate read/typing state and return { success: true } with no sent message id, so there is nothing for the waiters to key on. sendGroupMessage is also not waitable: its recipient is a group id, and the wait helpers match on a phone-number from/recipient, so group semantics do not fit the existing waiter substrate.
Helpers:
waitForReply({ timeoutMs?, signal? })resolves on an observed inboundmessagewhosecontext.messageIdequals the sent message id and, when known, whosefrommatches the sent recipient.waitUntilDelivered({ timeoutMs?, signal? })resolves on an observedstatusupdate for the sent message id withstatus === "delivered".waitUntilRead({ timeoutMs?, signal? })resolves on an observedstatusupdate for the sent message id withstatus === "read".waitUntilFailed({ timeoutMs?, signal? })resolves on an observedstatusupdate for the sent message id withstatus === "failed".
All waiters use the existing in-memory listener registry, so timeout and AbortSignal cleanup behave like wa.listen(...). No delivered/read inference is made from Graph send success: these methods require an observed webhook dispatched through wa.dispatch(...). There is no persistence-backed replay, cross-process wait, webhook delivery guarantee, or retry scheduler.
Dispatch
const report = await wa.dispatch(typedUpdate);dispatch first evaluates facade listeners, then delegates to TypedRouter.dispatch. The returned DispatchReport is the router report.
@wats/http uses this shape through createWebhookAdapter({ whatsapp: wa, ... }), but the facade itself is not an HTTP server.
Error taxonomy
-
WhatsAppFacadeConfigErrorinvalid_configinvalid_graph_clientinvalid_phone_number_idinvalid_waba_idinvalid_routerinvalid_observerinvalid_listener_registryinvalid_listener_registry_optionsinvalid_access_token— factory onlyinvalid_api_version— factory onlyinvalid_base_url— factory onlyinvalid_transport— factory only
-
WhatsAppListenOptionsErrorinvalid_listen_optionsinvalid_listen_typeinvalid_listen_frominvalid_listen_filter
Scoped-client id safety errors may be GraphRequestValidationError rather than facade-coded errors so the Graph path-param taxonomy remains consistent.
Non-goals
The facade currently does not provide:
- a standalone HTTP service
- endpoint breadth beyond current
PhoneNumberClient/WABAClientmethods - persistence for router or listener state
- retry/backoff for failed handlers
Future CLI/service packages should compose this facade rather than embedding new routing semantics.