wats.sh
Reference

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

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 exposing request(...).

Optional (WhatsAppFacadeConfig):

  • phoneNumberId?: string — creates wa.phoneNumberClient.
  • wabaId?: string — creates wa.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 to https://graph.facebook.com/.
  • transport?: Transport — defaults to createFetchTransport().

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;  // number

Absent 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 and group_*_update webhooks.
  • filter?: TypedFilter<...> — additional typed constraint.
  • timeoutMs?: number
  • signal?: AbortSignal
  • description?: 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 inbound message whose context.messageId equals the sent message id and, when known, whose from matches the sent recipient.
  • waitUntilDelivered({ timeoutMs?, signal? }) resolves on an observed status update for the sent message id with status === "delivered".
  • waitUntilRead({ timeoutMs?, signal? }) resolves on an observed status update for the sent message id with status === "read".
  • waitUntilFailed({ timeoutMs?, signal? }) resolves on an observed status update for the sent message id with status === "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

  • WhatsAppFacadeConfigError

    • invalid_config
    • invalid_graph_client
    • invalid_phone_number_id
    • invalid_waba_id
    • invalid_router
    • invalid_observer
    • invalid_listener_registry
    • invalid_listener_registry_options
    • invalid_access_token — factory only
    • invalid_api_version — factory only
    • invalid_base_url — factory only
    • invalid_transport — factory only
  • WhatsAppListenOptionsError

    • invalid_listen_options
    • invalid_listen_type
    • invalid_listen_from
    • invalid_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 / WABAClient methods
  • 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.

On this page