wats.sh
Reference

Client Reference

The public GraphClient API surface: configuration types, request lifecycle, and validation rules.

active · reviewed 2026-04-21

See the exact wire request in the playground.

Configuration types

Client initialization uses shared types from @wats/types.

WhatsAppClientConfig

External constructor/factory input.

Required: token: string, phoneNumberId: string. Optional: appSecret?, verifyToken?, apiVersion?, baseUrl? (all strings).

WhatsAppClientRuntimeConfig

The normalized internal shape after defaults (apiVersion, baseUrl) are resolved during async startup.

GraphClientConfig

Required fields:

  • accessToken: string — non-empty; max 4096 characters; not whitespace-only; no CR (U+000D), LF (U+000A), NUL (U+0000), or any other control character (U+0000..U+001F, U+007F). Invalid tokens throw GraphRequestValidationError at construction. Better there than in a request header.
  • apiVersion: string — must match /^v\d+(\.\d+)?$/ (for example v20 or v25.0). Values containing /, ?, #, .., or control characters are rejected with GraphRequestValidationError.

Optional fields:

  • baseUrl?: string — defaults to DEFAULT_GRAPH_BASE_URL ("https://graph.facebook.com/"). Must parse via new URL(...) and the protocol must be http: or https:; every other scheme (javascript:, file:, ftp:, data:, about:, blob:, …) is rejected with GraphRequestValidationError. The pathname of baseUrl is preserved in resolved request URLs: baseUrl: "https://proxy.example.com/api" + path: "/me" + apiVersion: "v25.0" resolves to "https://proxy.example.com/api/v25.0/me".
  • transport?: Transport — defaults to createFetchTransport(). Inject a custom Transport for retry, auth-refresh, tracing, or mocking. See Transport and Testing.

DEFAULT_GRAPH_BASE_URL is exported if you need the default explicitly.

GraphClient.request<TResponse>(options)

Options:

  • method: string
  • path: string
  • query?: Record<string, string | number | boolean | null | undefined>
  • body?: unknown
  • headers?: HeadersInit
  • signal?: AbortSignal

What it does:

  • builds Graph URLs under the API-version prefix (/${apiVersion}/...) while preserving any path prefix from baseUrl
  • validates request paths before network I/O:
    • rejects dot-segments (. / ..) across iterative decode stages, including nested/double-encoded markers such as %252e%252e
    • rejects traversal patterns and encoded slash/backslash escapes
    • rejects raw or encoded ? and # in path input
    • rejects ASCII control characters U+0000..U+001F and U+007F in path segments, including %0A, %0D, %00, and their double-encoded forms
  • applies a managed Authorization Bearer header from the configured access token
  • rejects caller-supplied authorization header overrides (any casing) with GraphRequestValidationError; the managed Bearer header is non-overridable (defense against request smuggling and credential confusion)
  • rejects CR / LF / NUL in any header name or value with GraphRequestValidationError; the TypeError from new Headers(init) is caught and rewrapped into the typed taxonomy
  • serializes JSON-like bodies; classifies serialization failures as GraphSerializationError, not network errors
  • passes BufferSource views (Uint8Array, DataView) through unchanged without forcing application/json
  • passes ReadableStream bodies through unchanged; defaults missing content-type to application/octet-stream
  • throws GraphSerializationError when a 2xx response declares JSON but contains invalid JSON
  • maps network and Graph API failures to typed errors
  • routes every HTTP call through the injected Transport

GraphClient.requestRaw(options)

The low-level escape hatch for already-resolved absolute URLs (for example, media binary download URLs). Unlike request, it does not prepend /${apiVersion} and does not parse JSON; it returns the raw TransportResponse from the injected transport.

Validation (all failures reject with GraphRequestValidationError before transport unless noted):

  • options must be a non-null, non-array object.
  • method must be one of GET, POST, PUT, PATCH, or DELETE (case-insensitive). Non-string, empty, whitespace-only, control-character, and unsupported methods reject.
  • url must be an absolute http: or https: URL. Relative URLs, malformed URLs, other schemes, and values containing CR/LF/NUL or other ASCII controls reject. The URL is used directly; no API-version prefix is added.
  • headers?: HeadersInit — the managed Authorization Bearer header is always applied; caller authorization overrides (any casing, object/tuple/Headers) reject. CR/LF/NUL in header names or values is rewrapped as GraphRequestValidationError, never a raw TypeError.
  • body?: unknown — same BodyInit matrix as request: null/undefined become null; string, FormData, Blob, ArrayBuffer, typed-array/DataView views, URLSearchParams, and ReadableStream pass through unchanged; other objects are JSON-stringified and serialization failures reject with GraphSerializationError. requestRaw never auto-sets content-type.
  • signal?: AbortSignal must be AbortSignal-like (aborted: boolean, addEventListener() and removeEventListener() functions). Partial fakes such as { aborted: false } reject.

Error taxonomy:

  • Shape/URL/method/header/signal validation: GraphRequestValidationError before network I/O.
  • Body JSON serialization: GraphSerializationError before network I/O.
  • Transport/fetch failures: GraphNetworkError.
  • HTTP status handling and response parsing are yours — the raw TransportResponse comes back unchanged.

Transport seam

Every HTTP concern — retry, auth-refresh, tracing, mocking — lives in a composable layer you control. Exports:

  • Transport, TransportRequest, TransportResponse, TransportHttpMethod, TransportInterceptor, TransportRetryPolicy, DEFAULT_TRANSPORT_RETRY_POLICY
  • createFetchTransport(options?) — production default wrapping globalThis.fetch
  • createReliableTransport(inner, options?) — opt-in retry/backoff/per-attempt-timeout decorator
  • createMockTransport(config?) — in-memory Transport for tests, via the @wats/graph/testing subpath

The default transport does NOT retry. createReliableTransport retries transient GET/DELETE failures and HTTP 429 rate limits, honors Retry-After, composes native AbortSignal.timeout / AbortSignal.any, and does not retry non-idempotent POST 5xx/network failures by default. Full recipe: Transport and Testing.

Messages endpoint scaffold

GraphClient exposes messages.sendMessage({ phoneNumberId, to, text, previewUrl? }), which builds a WhatsApp text payload and routes it through the shared request helper.

Path safety: phoneNumberId must be a numeric Graph phone number ID path segment. Non-numeric values (including /, ?, #, dot-segments) are rejected before any network call with GraphRequestValidationError (a subclass of GraphApiError, so existing instanceof GraphApiError checks remain valid).

The legacy GraphMessagesEndpoint class remains for backward compatibility and delegates to the endpoint-registry callable sendMessage under the hood. See the Endpoints Reference for the defineEndpoint contract.

Scoped sub-clients

PhoneNumberClient and WABAClient are ergonomic wrappers over the endpoint registry that bind a phoneNumberId / wabaId at construction and inject it into every call. The phone-number scope covers sendText plus the full composer catalog: media, location, contacts, reaction, interactive buttons/lists/CTA/catalog/product/product-list/location-request, template send, mark-as-read, and typing indicators. The WABA scope covers message-template management (listMessageTemplates, createMessageTemplate, getMessageTemplate, updateMessageTemplate, deleteMessageTemplate), template component builders, and parameter-count validation helpers. Media file bytes live in the media runtime (Media Reference). See the Scoped Clients Reference for the construction contract, method catalog, and validation rules.

WhatsApp.startChat(input)

The facade-level text conversation starter for contacts/inbox flows that initiate a conversation with an arbitrary phone number. The facade must be constructed with phoneNumberId; otherwise the method rejects before transport with GraphRequestValidationError.

await wa.startChat({
  to: "15551230000",
  text: "Hello from WATS",
  previewUrl: false,
  replyToMessageId: "wamid.OPTIONAL"
});

Validation:

  • input must be a non-null object, not an array.
  • to must be a string of E.164-ish digits with an optional leading +, at most 15 digits. Empty, whitespace-only, non-string, control characters, slashes, URL markers, and address-like values are rejected. This is shape validation only; WATS does not check whether the recipient is a saved contact.
  • text must be a non-empty, non-whitespace string at most 4096 characters.
  • previewUrl, when present, must be boolean and maps to wire text.preview_url.
  • replyToMessageId, when present, must be a non-empty string at most 256 characters with no control characters; maps to wire context.message_id.
  • Validation failures reject with GraphRequestValidationError before any network call. Graph API failures route through the GraphApiError registry taxonomy.

WhatsApp composer helpers

Facade composer helpers mirror the phone-number scoped methods and require the facade to be constructed with phoneNumberId; otherwise they reject before transport with GraphRequestValidationError. Media helpers send existing media references only — a previously uploaded media id or a resolvable http(s) link. Byte upload/download/delete/decrypt are media runtime APIs.

await wa.sendImage({
  to: "15551230000",
  mediaId: "MEDIA_ID_FROM_UPLOAD",
  caption: "Optional image caption",
  replyToMessageId: "wamid.OPTIONAL"
});

await wa.sendDocument({
  to: "15551230000",
  link: "https://cdn.example.test/report.pdf",
  caption: "Optional document caption",
  filename: "report.pdf"
});

Body matrix:

HelperWire typeMedia referenceSupported optional fields
sendImageimageexactly one of mediaIdimage.id or linkimage.linkcaption, replyToMessageId
sendVideovideoexactly one of mediaId or linkcaption, replyToMessageId
sendAudioaudioexactly one of mediaId or linkreplyToMessageId only
sendDocumentdocumentexactly one of mediaId or linkcaption, filename, replyToMessageId
sendStickerstickerexactly one of mediaId or linkreplyToMessageId only

Validation and limits:

  • input must be a non-null object, not an array.
  • to uses the same recipient policy as startChat.
  • Exactly one of mediaId and link is required. Missing both or providing both rejects before transport.
  • mediaId: non-empty, non-whitespace, control-character-free, at most 2048 characters.
  • link: non-empty, non-whitespace, control-character-free, at most 2048 characters, parses with new URL(...), http: or https: only.
  • caption, where supported: non-empty when provided, at most 1024 characters.
  • filename (document only): non-empty, control-character-free, at most 256 characters.
  • replyToMessageId: non-empty, control-character-free, at most 256 characters; maps to wire context.message_id.
  • Validation failures reject with GraphRequestValidationError before transport. Downstream Graph failures preserve GraphApiError registry subclasses such as UnsupportedMessageTypeError.

Remaining helpers:

HelperWire shapeNotes
sendLocationtype: "location"finite latitude [-90, 90] and longitude [-180, 180]; optional name, address, replyToMessageId
sendContactstype: "contacts"1..257 contacts; each contact requires name.formattedName; phone entries require phone or waId
sendReaction / removeReactiontype: "reaction"sendReaction requires non-empty emoji; removeReaction sends emoji: ""
sendButtonsinteractive.type: "button"1..3 reply buttons; bounded button id/title
sendListinteractive.type: "list"bounded sections/rows with buttonText
sendCtaUrlinteractive.type: "cta_url"http(s) URL only
sendProduct / sendProducts / sendCatalogcommerce interactive variantsvalidates catalog/product ids and section/product counts
requestLocationinteractive.type: "location_request_message"asks the user to share location
sendTemplatetype: "template"sends an approved template by name/language/components
markMessageAsRead{ status: "read", message_id }marks inbound message thread read
indicateTypingread + typing_indicator: { type: "text" }typing indicator

Exports

Core:

  • GraphClient, typed request configuration primitives, Graph typed error classes and mapping helpers, messages endpoint scaffold + payload builders.
  • GraphMessagesSendTextInput, buildSendTextPayload, PhoneNumberClient.sendText(input, opts?), WhatsApp.startChat(input), WhatsAppStartChatInput.

Composer surface:

  • Input types: GraphMessagesSendImageInput, GraphMessagesSendVideoInput, GraphMessagesSendAudioInput, GraphMessagesSendDocumentInput, GraphMessagesSendStickerInput, plus the input types for location, contacts, reaction, interactive variants, template, read receipts, and typing indicators.
  • Payload types: GraphMessagesImagePayload, GraphMessagesVideoPayload, GraphMessagesAudioPayload, GraphMessagesDocumentPayload, GraphMessagesStickerPayload, GraphMessagesLocationPayload, GraphMessagesContactsPayload, GraphMessagesReactionPayload, GraphMessagesInteractivePayload, GraphMessagesTemplatePayload, GraphMessagesStatusPayload.
  • Constants: GRAPH_MESSAGES_MEDIA_ID_MAX_LENGTH, GRAPH_MESSAGES_MEDIA_LINK_MAX_LENGTH, GRAPH_MESSAGES_MAX_REPLY_BUTTONS, GRAPH_MESSAGES_MAX_LIST_ROWS, GRAPH_MESSAGES_MAX_CONTACTS, GRAPH_MESSAGES_MAX_PRODUCT_ITEMS.
  • All buildSend*Payload helpers, plus matching PhoneNumberClient.* and WhatsApp.* methods.

Template management:

  • WABA-scoped callables: listMessageTemplates, createMessageTemplate, getMessageTemplate, updateMessageTemplate, deleteMessageTemplate.
  • Model/response types: TemplateCategory, TemplateStatus, TemplateLanguageCode, TemplateParameterFormat, TemplateComponent, TemplateDetails, TemplateListResponse, TemplateMutationResponse.
  • Component helpers: buildTemplateHeaderComponent, buildTemplateBodyComponent, buildTemplateFooterComponent, buildTemplateButtonComponent.
  • validateTemplateParameterCounts(definition, sendComponents) — compares HEADER/BODY placeholders ({{1}} positional or {{name}} named) against send-time parameters and throws TemplateParamCountMismatchError on mismatch.
  • WABAClient methods for the same template operations with the bound wabaId injected.

Usage

import { GraphClient } from "@wats/graph";
import { WhatsApp } from "@wats/core";

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

const wa = new WhatsApp({
  graphClient,
  phoneNumberId: "1234567890"
});

await wa.startChat({
  to: "+155****0000",
  text: "Hi — starting this chat from WATS.",
  previewUrl: false
});

await wa.sendImage({
  to: "+155****0000",
  mediaId: "MEDIA_ID_FROM_UPLOAD",
  caption: "Image sent from WATS"
});

On this page