Endpoints Reference
The endpoint registry primitive defineEndpoint: path templates, param kinds, body handling, and error integration.
active · reviewed 2026-05-02
Every Graph endpoint in WATS sits on a single plumbing layer:
defineEndpoint(spec) → EndpointCallable(client, params, body?, opts?) → Promise<TResponse>
│
└──→ client.request({ method, path, query?, body?, headers?, signal? })
│
└──→ Transport.request(...) → response or
createGraphApiError(payload, ctx) → registered
subclass via resolveRegisteredErrorThe primitive centralizes path-template parsing, param validation, query serialization, body passthrough, and error routing. The first-class endpoint families add family-specific validators, builders, scoped-client helpers, and typed request/response surfaces around that plumbing.
Primitive vs first-class endpoint families
Prefer the first-class Graph endpoint family subpaths when they exist; use a custom defineEndpoint declaration for local or future Graph routes WATS has not wrapped yet.
Current subpaths:
@wats/graph/endpoints/messages@wats/graph/endpoints/media@wats/graph/endpoints/templates@wats/graph/endpoints/flows@wats/graph/endpoints/calling@wats/graph/endpoints/business-management@wats/graph/endpoints/groups@wats/graph/node-media
The business-management subpath includes read/admin helpers such as getPhoneNumberInfo, updateBusinessProfile, updateCommerceSettings, listBlockedUsers, blockUsers, unblockUsers, getOfficialBusinessAccountStatus, requestOfficialBusinessAccountReview, and submitDisplayNameForReview. They map Meta's whatsapp_business_profile, whatsapp_commerce_settings, block_users, official_business_account, and new_display_name wire surfaces while remaining credential-free in tests. Profile updates emit Graph profile_picture_handle; commerce updates emit is_cart_enabled and is_catalog_visible.
defineEndpoint
import {
defineEndpoint,
type EndpointDefinition,
type EndpointHttpMethod,
type EndpointParamSpec,
type EndpointCallable,
type EndpointInvokeOptions
} from "@wats/graph";Signature
function defineEndpoint<
TParams extends Record<string, string>,
TBody = unknown,
TResponse = unknown
>(
spec: EndpointDefinition<TParams, TBody, TResponse>
): EndpointCallable<TParams, TBody, TResponse>;EndpointDefinition fields:
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"— validated at define time. Any other value throwsGraphRequestValidationErrorimmediately.pathTemplate: string— non-empty, no ASCII control chars. Placeholders are written{name}wherenamematches/^[a-zA-Z_][a-zA-Z0-9_]*$/. Empty ({}), unbalanced ({x,x}), or duplicate ({x}{x}) placeholders are rejected at define time.params: { [K in keyof TParams]: EndpointParamSpec }— every placeholder must have a matching entry within: "path", and everyin: "path"entry must appear in the template. Mismatches are rejected at define time.buildBody?: (body: TBody) => unknown— optional body transformer. Non-functions are rejected at define time.bodyContentType?: string— optional content-type override (e.g."application/json"). Applied only when a body is present.
EndpointParamSpec:
in: "path" | "query"— placement in the resolved request.required?: boolean— defaults totruefor path params (always required) andfalsefor query params.
EndpointCallable:
- Call signature:
(client, params, body?, opts?) => Promise<TResponse>. .definitionis the originalEndpointDefinitionfor introspection.
Call-time behaviour
Path parameter values pass through the same sanitisation the client applies to inline path segments (assertSafeGraphPathSegment semantics):
- empty string → rejected with
GraphRequestValidationError; .or..→ rejected (dot-segments);/or\\→ rejected (traversal patterns);?or#→ rejected (query/fragment in a path segment);- ASCII control chars (U+0000..U+001F, U+007F) → rejected;
- non-string values → rejected.
Query parameter values are URL-encoded via URLSearchParams, with a preflight check that rejects CR (\\r), LF (\\n), and NUL (\\0) in values. undefined values are skipped (omitted from the URL). Query param keys and values with control chars are rejected before the URL is built.
Body handling is a passthrough to GraphClient.request:
- If
buildBodyis present, it is called on the caller'sbodyand the return value is handed to the client. - Otherwise the caller's
bodyis forwarded unchanged. - The client serialises objects to JSON (setting
content-type: application/jsonunless the caller already set one), forwardsUint8Array/ArrayBuffer/Blob/FormData/URLSearchParams/ReadableStreamverbatim, and never re-serialises.
EndpointInvokeOptions:
signal?: AbortSignal— forwarded to the Transport asTransportOptions.signal.headers?: Record<string, string> | Headers— merged on top ofbodyContentType, then handed toGraphClient.requestas a plain object. All header validation goes through the client's taxonomy guard: CR, LF, or NUL in any header name or value is rejected with a typedGraphRequestValidationError(never a rawTypeError), and any caller-suppliedauthorizationheader (any casing) is rejected. The endpoint path and a directclient.request(...)call produce the same error type for the same invalid input. See Transport and Testing.
Frozen, introspection-safe .definition
The EndpointCallable exposes its spec at ep.definition. Both the top-level object and its params sub-object are frozen with Object.freeze, matching the readonly TypeScript contract at runtime. External code attempting to mutate ep.definition.method or swap out ep.definition.params is silently rejected (sloppy mode) or throws (strict mode); the callable's invoke closure always uses the original frozen definition.
params keys are validated at define time against the same /^[a-zA-Z_][a-zA-Z0-9_]*$/ regex used for {name} placeholders. Empty keys, keys with whitespace, and other invalid identifiers are rejected up front with GraphRequestValidationError.
Integration with the error registry
defineEndpoint does not surface errors itself; it delegates to GraphClient.request, which:
- Calls
Transport.request(...). - On non-2xx responses, invokes
createGraphApiError({ status, payload, ... }), which callsresolveRegisteredError(code, subcode?)to pick the narrowest subclass — e.g. code 100 →InvalidParameterError, code 131051 →UnsupportedMessageTypeError, code 4 @ HTTP 429 →ToManyAPICallsError(aGraphRateLimitErrorsubclass). - Input validation errors raised inside the endpoint layer (missing or unknown params, unsafe path values, CR/LF in query values) are typed as
GraphRequestValidationError, a subclass ofGraphApiError.
Sibling-class assertions work without touching the endpoint layer:
try {
await sendMessage(client, { phoneNumberId: "123" }, body);
} catch (error) {
if (error instanceof UnsupportedMessageTypeError) {
// handle 131051
} else if (error instanceof InvalidParameterError) {
// handle 100/131009
} else if (error instanceof GraphRateLimitError) {
// handle rate limiting
}
}messages: the two invocation shapes
@wats/graph/endpoints/messages is built on defineEndpoint. Two shapes are exposed; both produce byte-for-byte identical HTTP requests.
1. Endpoint-registry callable (preferred for new call sites)
import { GraphClient, sendMessage } from "@wats/graph";
const client = new GraphClient({
accessToken: process.env.WATS_TOKEN!,
apiVersion: "v25.0"
});
const result = await sendMessage(
client,
{ phoneNumberId: "1234567890" },
{
messaging_product: "whatsapp",
to: "15551234567",
type: "text",
text: { body: "hello" }
}
);2. Legacy GraphMessagesEndpoint class (backward-compatible)
const result = await client.messages.sendMessage({
phoneNumberId: "1234567890",
to: "15551234567",
text: "hello"
});The class-based method pre-validates phoneNumberId (GraphRequestValidationError whose .message starts with "Invalid phoneNumberId.") and then delegates path/body plumbing to the sendMessage endpoint-registry callable.
Send-to-group
Message helpers accept recipientType: "group" for text, media, and standard template sends. Group template helper inputs require templateCategory: "UTILITY" or "MARKETING"; missing categories and "AUTHENTICATION" reject before transport. The Graph body uses recipient_type: "group"; to must be an opaque group id, not a phone number.
const body = buildSendTextPayload({
to: "grp-release-1",
recipientType: "group",
text: "hello group"
});
await sendMessage(client, { phoneNumberId }, body);Unsupported in groups: interactive messages, commerce/catalog/product sends, marketing/auth templates, calling, edit/delete, disappearing, and view-once. These reject with GraphRequestValidationError before transport. Pin/unpin is available through buildSendPinPayload({ to, pinType: "pin" | "unpin", messageId, expirationDays }); expirationDays must be an integer from 1 to 30. Meta enforces admin-only pinning, at most three pinned messages, and oldest-auto-unpin behavior.
Custom endpoint tutorial
Defining a new endpoint is one declaration. A hypothetical GET /{businessId}/analytics endpoint with a required path parameter and an optional since query parameter:
import { defineEndpoint, GraphClient } from "@wats/graph";
interface AnalyticsResponse {
readonly totals: { readonly messages: number };
}
export const getAnalytics = defineEndpoint<
{ businessId: string; since?: string },
never,
AnalyticsResponse
>({
method: "GET",
pathTemplate: "/{businessId}/analytics",
params: {
businessId: { in: "path", required: true },
since: { in: "query", required: false }
}
});
// call it:
const client = new GraphClient({
accessToken: "…",
apiVersion: "v25.0"
});
const report = await getAnalytics(client, {
businessId: "987654321",
since: "2026-01-01"
});The usual invariants hold automatically:
- the declaration fails at define time if
pathTemplateandparamsdisagree; - unknown call-time params (
{ businessId, typo: "x" }) are rejected before the HTTP call; sinceis URL-encoded and omitted when absent;- network/registry errors surface as
GraphApiErrorsubclasses.
Marketing Messages API
Credential-free, shape-only request helpers for Meta's Marketing Messages API for WhatsApp. They map the confirmed request surface; tests use MockTransport and make no live Meta calls.
await sendMarketingTemplate(client, { phoneNumberId }, {
to: "15551230000",
name: "promo_offer",
languageCode: "en_US",
productPolicy: "STRICT",
messageActivitySharing: false
});
// PhoneNumberClient also exposes the bound-id variant.
await phone.sendMarketingTemplate({
recipient: "bsuid-parent-1",
name: "promo_offer",
languageCode: "en_US"
});Wire mapping:
sendMarketingTemplatepostsPOST /{phoneNumberId}/marketing_messages.- The Graph body always includes
messaging_product: "whatsapp",recipient_type: "individual",type: "template", and atemplateobject. languageCodemaps totemplate.language.code.- Optional
productPolicymaps to Graphproduct_policy; limited toCLOUD_API_FALLBACKorSTRICT. - Optional
messageActivitySharingmaps to Graphmessage_activity_sharing. - Optional
recipientsupports BSUID routing whentois omitted; if both are present,toremains in the request as Meta's precedence field. - Responses may include
contacts.user_idfor BSUID sends andmessages[].message_statusvalues such asaccepted,held_for_quality_assessment, andpaused.
Out of scope: live Meta calls, credential validation, Ads Manager dashboards, ACO automation, campaign delivery strategy.
v24 message composers
Credential-free builders for the v24 send deltas: buildSendCallPermissionRequestPayload(input) and the voice?: boolean field on buildSendAudioPayload(input) / PhoneNumberClient.sendAudio(...). Both delegate to POST /{phoneNumberId}/messages and validate before transport via GraphRequestValidationError.
import {
buildSendCallPermissionRequestPayload,
buildSendAudioPayload
} from "@wats/graph";
buildSendCallPermissionRequestPayload({
to: "15551230000",
bodyText: "May we call you?"
});
// => interactive.type = "call_permission_request"
// => interactive.action.name = "call_permission_request"
buildSendAudioPayload({ to: "15551230000", mediaId: "AUDIO_ID", voice: true });
// => { type: "audio", audio: { id: "AUDIO_ID", voice: true } }voice: true marks an audio send as a voice message; omitting it preserves the standard audio payload. The call-permission helper accepts to, bodyText, optional footerText, and optional replyToMessageId; unknown fields reject before transport.
Template groups and analytics
Credential-free endpoint callables for Meta's Template Group surfaces. MockTransport-tested only; live WABA analytics and mutations remain credential-gated.
listTemplateGroups(client, { wabaId, fields?, limit?, after?, before? })→GET /{wabaId}/template_groups.createTemplateGroup(client, { wabaId }, body)→POST /{wabaId}/template_groups; converts camelCasetemplateIdsto Graphtemplate_ids.getTemplateGroup,updateTemplateGroup,deleteTemplateGroup→GET/POST/DELETE /{templateGroupId}.getTemplateGroupAnalytics(client, { wabaId, templateGroupId?, ... })→GET /{wabaId}/template_group_analytics; serializesmetricTypesas Graphmetric_types.
await listTemplateGroups(client, { wabaId, limit: "25" });
await createTemplateGroup(client, { wabaId }, {
name: "launch_group",
category: "MARKETING",
templateIds: ["template-id-1"]
});
await getTemplateGroupAnalytics(client, {
wabaId,
templateGroupId: "template-group-id",
metricTypes: ["sent", "delivered"]
});Authentication templates and local-storage settings
Two v21+ compatibility deltas, modeled without live Meta calls:
- Authentication template OTP buttons for one-tap / zero-tap app autofill use nested
supported_appsrecords. In the builder API, passsupportedApps: [{ packageName, signatureHash }]; the Graph body emitssupported_apps: [{ package_name, signature_hash }]. Legacy flatpackageName/signatureHashon the OTP button are rejected. - Zero-tap apps: pass
autofillTextandzeroTapTermsAccepted; WATS emits Graphautofill_textandzero_tap_terms_acceptedon the OTP button. - Local-storage enablement goes through
updatePhoneNumberSettings(..., { storageConfiguration }), which POSTsstorage_configurationto/{phoneNumberId}/settings. - Phone-registration helpers such as
registerPhoneNumber(...)may emit Graphdata_localization_regionbecause Meta still accepts it on registration. Local-storage settings belong instorage_configurationupdates and never use that field.
buildTemplateButtonComponent({
buttons: [{
type: "OTP",
otpType: "ZERO_TAP",
autofillText: "Autofill",
zeroTapTermsAccepted: true,
supportedApps: [{ packageName: "com.example.app", signatureHash: "abc123" }]
}]
});
// => autofill_text + zero_tap_terms_accepted + supported_apps
await updatePhoneNumberSettings(client, {
phoneNumberId,
storageConfiguration: { status: "ENABLED" }
});
// => POST /{phoneNumberId}/settings { storage_configuration: { status: "ENABLED" } }Calling lifecycle endpoints
The Calling API lifecycle rides one Graph route — POST /{phoneNumberId}/calls — with the action field selecting the operation. Call permissions are a separate read.
| Callable | Route | action |
|---|---|---|
initiateCall | POST /{phoneNumberId}/calls | connect |
preAcceptCall | POST /{phoneNumberId}/calls | pre_accept |
acceptCall | POST /{phoneNumberId}/calls | accept |
rejectCall | POST /{phoneNumberId}/calls | reject |
terminateCall | POST /{phoneNumberId}/calls | terminate |
getCallPermissions | GET /{phoneNumberId}/call_permissions | n/a |
getCallPermissions requires exactly one of userWaId (Graph user_wa_id) or recipient (XOR); supplying both or neither rejects with GraphRequestValidationError before transport. initiateCall requires at least one of to (wa_id) or recipient (BSUID / parent BSUID); when both are supplied, both are emitted and Meta routes by to.
Direct callables are exported from root @wats/graph and @wats/graph/endpoints/calling. PhoneNumberClient exposes bound-id variants that inject the configured phoneNumberId. See the Calling Reference for validation, call-button/deep-link helpers, calling webhooks, and operator constraints. Status: shape-only — MockTransport and synthetic webhook tests only.
Public API summary
defineEndpoint(spec): EndpointCallable- Types:
EndpointDefinition,EndpointHttpMethod,EndpointParamSpec,EndpointInvokeOptions,EndpointCallable. - Messages exports:
sendMessage(endpoint-registry callable),GraphMessagesEndpoint(legacy class),buildSendMessagePayload,GraphMessagesSendMessageInput,GraphMessagesSendResponse,GraphMessagesTextPayload,GraphMessagesSendBody.
Related docs
- Scoped Clients Reference —
PhoneNumberClientGroups helpers andGroupClientbound-id methods over@wats/graph/endpoints/groups. - Client Reference —
GraphClientconstruction, Transport seam, baseUrl/accessToken/apiVersion validation. - Errors Reference — error code registry and seeded subclasses.
- Transport and Testing — Transport decorators,
createMockTransportusage, retry/auth refresh recipes.