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 throwGraphRequestValidationErrorat construction. Better there than in a request header.apiVersion: string— must match/^v\d+(\.\d+)?$/(for examplev20orv25.0). Values containing/,?,#,.., or control characters are rejected withGraphRequestValidationError.
Optional fields:
baseUrl?: string— defaults toDEFAULT_GRAPH_BASE_URL("https://graph.facebook.com/"). Must parse vianew URL(...)and the protocol must behttp:orhttps:; every other scheme (javascript:,file:,ftp:,data:,about:,blob:, …) is rejected withGraphRequestValidationError. The pathname ofbaseUrlis 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 tocreateFetchTransport(). Inject a customTransportfor 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: stringpath: stringquery?: Record<string, string | number | boolean | null | undefined>body?: unknownheaders?: HeadersInitsignal?: AbortSignal
What it does:
- builds Graph URLs under the API-version prefix (
/${apiVersion}/...) while preserving any path prefix frombaseUrl - 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
- rejects dot-segments (
- applies a managed
AuthorizationBearer header from the configured access token - rejects caller-supplied
authorizationheader overrides (any casing) withGraphRequestValidationError; 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; theTypeErrorfromnew 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 forcingapplication/json - passes
ReadableStreambodies through unchanged; defaults missingcontent-typetoapplication/octet-stream - throws
GraphSerializationErrorwhen 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):
optionsmust be a non-null, non-array object.methodmust be one ofGET,POST,PUT,PATCH, orDELETE(case-insensitive). Non-string, empty, whitespace-only, control-character, and unsupported methods reject.urlmust be an absolutehttp:orhttps: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 managedAuthorizationBearer header is always applied; callerauthorizationoverrides (any casing, object/tuple/Headers) reject. CR/LF/NUL in header names or values is rewrapped asGraphRequestValidationError, never a rawTypeError.body?: unknown— same BodyInit matrix asrequest:null/undefinedbecomenull;string,FormData,Blob,ArrayBuffer, typed-array/DataView views,URLSearchParams, andReadableStreampass through unchanged; other objects are JSON-stringified and serialization failures reject withGraphSerializationError.requestRawnever auto-setscontent-type.signal?: AbortSignalmust be AbortSignal-like (aborted: boolean,addEventListener()andremoveEventListener()functions). Partial fakes such as{ aborted: false }reject.
Error taxonomy:
- Shape/URL/method/header/signal validation:
GraphRequestValidationErrorbefore network I/O. - Body JSON serialization:
GraphSerializationErrorbefore network I/O. - Transport/fetch failures:
GraphNetworkError. - HTTP status handling and response parsing are yours — the raw
TransportResponsecomes 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_POLICYcreateFetchTransport(options?)— production default wrappingglobalThis.fetchcreateReliableTransport(inner, options?)— opt-in retry/backoff/per-attempt-timeout decoratorcreateMockTransport(config?)— in-memory Transport for tests, via the@wats/graph/testingsubpath
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:
inputmust be a non-null object, not an array.tomust 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.textmust be a non-empty, non-whitespace string at most 4096 characters.previewUrl, when present, must be boolean and maps to wiretext.preview_url.replyToMessageId, when present, must be a non-empty string at most 256 characters with no control characters; maps to wirecontext.message_id.- Validation failures reject with
GraphRequestValidationErrorbefore any network call. Graph API failures route through theGraphApiErrorregistry 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:
| Helper | Wire type | Media reference | Supported optional fields |
|---|---|---|---|
sendImage | image | exactly one of mediaId → image.id or link → image.link | caption, replyToMessageId |
sendVideo | video | exactly one of mediaId or link | caption, replyToMessageId |
sendAudio | audio | exactly one of mediaId or link | replyToMessageId only |
sendDocument | document | exactly one of mediaId or link | caption, filename, replyToMessageId |
sendSticker | sticker | exactly one of mediaId or link | replyToMessageId only |
Validation and limits:
inputmust be a non-null object, not an array.touses the same recipient policy asstartChat.- Exactly one of
mediaIdandlinkis 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 withnew URL(...),http:orhttps: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 wirecontext.message_id.- Validation failures reject with
GraphRequestValidationErrorbefore transport. Downstream Graph failures preserveGraphApiErrorregistry subclasses such asUnsupportedMessageTypeError.
Remaining helpers:
| Helper | Wire shape | Notes |
|---|---|---|
sendLocation | type: "location" | finite latitude [-90, 90] and longitude [-180, 180]; optional name, address, replyToMessageId |
sendContacts | type: "contacts" | 1..257 contacts; each contact requires name.formattedName; phone entries require phone or waId |
sendReaction / removeReaction | type: "reaction" | sendReaction requires non-empty emoji; removeReaction sends emoji: "" |
sendButtons | interactive.type: "button" | 1..3 reply buttons; bounded button id/title |
sendList | interactive.type: "list" | bounded sections/rows with buttonText |
sendCtaUrl | interactive.type: "cta_url" | http(s) URL only |
sendProduct / sendProducts / sendCatalog | commerce interactive variants | validates catalog/product ids and section/product counts |
requestLocation | interactive.type: "location_request_message" | asks the user to share location |
sendTemplate | type: "template" | sends an approved template by name/language/components |
markMessageAsRead | { status: "read", message_id } | marks inbound message thread read |
indicateTyping | read + 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*Payloadhelpers, plus matchingPhoneNumberClient.*andWhatsApp.*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 throwsTemplateParamCountMismatchErroron mismatch.WABAClientmethods for the same template operations with the boundwabaIdinjected.
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"
});