Types Reference
Shared TypeScript contracts: discriminated-union domain types for inbound WhatsApp objects.
shape-only — shared domain types; locally tested · reviewed 2026-04-21
@wats/types ships discriminated-union shapes for every inbound WhatsApp domain object the types package exposes. Narrow via switch on the type discriminator; exhaustive switches with a never default branch catch any future variant added to the union.
Config Types
WhatsAppClientConfig- required:
token,phoneNumberId - optional:
appSecret,verifyToken,apiVersion,baseUrl
- required:
WhatsAppClientRuntimeConfig- normalized runtime shape for async client initialization paths
WhatsAppMessage (discriminated union)
WhatsAppMessage is a closed union keyed by a string-literal type:
TextMessage—type: "text",text: { body: string }ImageMessage—type: "image",image: MediaReferenceVideoMessage—type: "video",video: MediaReferenceAudioMessage—type: "audio",audio: MediaReferenceDocumentMessage—type: "document",document: DocumentReference(filenamerequired)StickerMessage—type: "sticker",sticker: MediaReferenceLocationMessage—type: "location",location: { latitude, longitude, name?, address? }ContactsMessage—type: "contacts",contacts: WhatsAppContact[]ReactionMessage—type: "reaction",reaction: { messageId, emoji }OrderMessage—type: "order",order: OrderPayloadSystemMessage—type: "system",system: SystemNotificationUnsupportedMessage—type: "unsupported",errors?: WhatsAppError[],raw: unknownInteractiveMessage—type: "interactive",interactive: InteractiveReply(nested discriminated union)ButtonMessage—type: "button",button: { text, payload? }
Every variant shares the base fields id: string, from: string, timestamp: string (with SystemMessage retaining the same base and UnsupportedMessage reserving raw as an escape hatch). Every variant also exposes an optional raw?: unknown so userland keeps byte-level access to the original wire payload without re-opening the typed surface — the camelCase-only contract with a single bounded escape hatch.
InteractiveReply (nested union)
InteractiveMessage.interactive is itself a discriminated union keyed by type:
button_reply→{ buttonReply: { id, title } }list_reply→{ listReply: { id, title, description? } }nfm_reply→{ nfmReply: { responseJson?, body?, name? } }product_reply→{ productReply: { catalogId, productRetailerId } }product_list_reply→{ productListReply: { catalogId, productItems: ReadonlyArray<{ productRetailerId }> } }cta_url_reply→{ ctaUrlReply: { displayText, url } }
WhatsAppMessageStatus
WhatsAppMessageStatus.status: WhatsAppMessageStatusKind narrows to one of six literals:
sent,delivered,read,failed,deleted,warning
Other fields: id, recipientId, timestamp, optional conversation?, pricing?, errors?: WhatsAppError[], raw?.
WhatsAppContact
Closed shape (no [key: string]: unknown), camelCase-only. The Meta wire
payload uses snake_case sub-fields (wa_id, first_name, formatted_name,
country_code, ...); the @wats/core webhook normalizer owns the
snake→camel mapping for inbound contacts. The original wire record is
preserved on the normalizer output via raw and is reachable on the raw
envelope via rawChange. Optional fields:
waId?profile?: { name? }name?: WhatsAppContactName(formattedName,firstName,lastName,middleName,suffix,prefix)phones?: ContactPhone[](phone,type,waId)emails?: ContactEmail[](email,type)addresses?: ContactAddress[](street,city,state,zip,country,countryCode,type)org?: ContactOrg(company,department,title)urls?: ContactUrl[](url,type)birthday?: stringraw?: unknown
Inbound contact normalization
The normalizer (normalizeWebhookEnvelope) maps two inbound contact
surfaces to camelCase:
- Field-value contacts[] — sender profile data accompanying inbound
messages (
value.contacts[]). Mapped from Meta wire{ wa_id, profile: { name } }toNormalizedContact(waId,profile.name,raw) and surfaced on eachTypedMessageUpdate.contacts. Malformed entries (null, primitive, array, garbage records with nowaIdor validprofile) are skipped without throwing. - Contacts-message entries — a contact card sent by a user
(
messages[].contacts[]whentype: "contacts"). Wire sub-fields are mapped to camelCase on the normalized message payload:formatted_name→formattedName,first_name→firstName,wa_id→waIdon phones,country_code→countryCodeon addresses, etc. Malformed entries are skipped; the original wire is preserved viarawon each contact.
WhatsAppWebhookValue (discriminated union)
WhatsAppWebhookValue is closed but every variant keeps a raw: unknown escape hatch. Variants:
WhatsAppMessagesFieldValue— standard inbound messages payload (messagingProduct: "whatsapp",metadata,messages?,statuses?,contacts?,errors?).WhatsAppTemplateStatusUpdateValue—messageTemplateId,messageTemplateName,messageTemplateLanguage,event: "APPROVED" | "REJECTED" | "FLAGGED" | "PAUSED" | "DISABLED" | "PENDING_DELETION",reason?.WhatsAppAccountReviewUpdateValue—decision.WhatsAppUserMarketingPreferencesValue—waId,category,preference: "opt_in" | "opt_out",timestamp.WhatsAppPhoneNumberChangeValue—mobileDisplayName?,oldPhoneNumber?,newPhoneNumber.WhatsAppIdentityChangeValue—waId,acknowledged,createdTimestamp,hash?.WhatsAppRawWebhookValue— catch-all for unrecognized fields; exposesraw: unknownand optionalerrors?.
The outer envelope remains WhatsAppWebhookEnvelope → entry[] → changes[] with the discriminating field on each change.
Narrowing recipes
Exhaustive switch
import type { WhatsAppMessage } from "@wats/types";
function describe(message: WhatsAppMessage): string {
switch (message.type) {
case "text":
return message.text.body;
case "image":
return `image:${message.image.mimeType}`;
case "interactive":
return `interactive:${message.interactive.type}`;
// ...handle every variant...
default: {
const _exhaustive: never = message;
return _exhaustive;
}
}
}The _exhaustive: never branch forces a compile error the moment a new variant is added to the union.
Status narrowing
import type {
WhatsAppMessageStatus,
WhatsAppMessageStatusKind
} from "@wats/types";
function isTerminal(status: WhatsAppMessageStatus): boolean {
const kind: WhatsAppMessageStatusKind = status.status;
return kind === "failed" || kind === "deleted" || kind === "read";
}Interactive narrowing
import type { InteractiveMessage, InteractiveReply } from "@wats/types";
function replyBody(message: InteractiveMessage): string {
const reply: InteractiveReply = message.interactive;
switch (reply.type) {
case "button_reply":
return reply.buttonReply.title;
case "list_reply":
return reply.listReply.title;
case "nfm_reply":
return reply.nfmReply.name ?? "";
case "product_reply":
return reply.productReply.productRetailerId;
case "product_list_reply":
return reply.productListReply.catalogId;
case "cta_url_reply":
return reply.ctaUrlReply.url;
default: {
const _exhaustive: never = reply;
return _exhaustive;
}
}
}Raw escape hatch
Every typed variant exposes an optional raw?: unknown so you can reach into the original wire payload without breaking strict types:
if (message.type === "unsupported") {
const wire = message.raw; // typed as `unknown`
// userland does its own narrowing / logging / forwarding
}Normalized update event types
@wats/core normalizes raw Meta webhook envelopes into a flat array of TypedUpdate discriminated-union values via normalizeWebhookEnvelope. See webhook-normalizer for the full option surface (maxEventsPerEnvelope, safety limits, and the skipped/limitError taxonomy) and router for TypedRouter dispatch. The typed update variants and their discriminators are part of @wats/types:
TypedMessageUpdate,TypedStatusUpdate,TypedAccountUpdate,TypedUnknownUpdate,TypedUserPreferencesUpdate,TypedSystemUpdate,TypedChatOpenedUpdate, and the calling/group variants- each carries stable
updateId,wabaId,phoneNumberId, andreceivedAtfields handlers and listeners consume downstream
The old untyped parseWebhookUpdate / createUpdateRouter / UpdateRouter / ParsedUpdateEvent / UpdateFilter legacy surfaces were removed; normalizeWebhookEnvelope + TypedRouter + filtersTyped are the typed replacements.
API Surface
Exports are available via:
@wats/types/config—WhatsAppClientConfig,WhatsAppClientRuntimeConfig@wats/types/webhook— webhook envelope + value union@wats/types/entities— entity barrel (retained for compatibility)@wats/types/messages— WhatsAppMessage union + every variant + supporting media/context types@wats/types/statuses— WhatsAppMessageStatus + status kind union@wats/types/contacts— WhatsAppContact + sub-shapes@wats/types/errors— WhatsAppError + legacy WhatsAppErrorPayload@wats/types— barrel (all of the above)
Runtime contract constants are also exported so external-consumer fixtures can assert the documented surface without relying on type-only exports:
WATS_TYPES_CONFIG_EXPORTSWATS_TYPES_WEBHOOK_EXPORTSWATS_TYPES_ENTITIES_EXPORTSWATS_TYPES_MESSAGES_EXPORTSWATS_TYPES_STATUSES_EXPORTSWATS_TYPES_CONTACTS_EXPORTSWATS_TYPES_ERRORS_EXPORTS
Usage Notes
- Prefer camelCase property names in all public contracts. Wire-level snake_case survives only inside
WhatsAppContact/SystemNotificationsub-fields, pending camelCase migration. - Keep async-first ergonomics in calling layers by passing
WhatsAppClientConfigthrough initialization boundaries and normalizing once. - Never rely on
[key: string]: unknownon the typed surface: every new field Meta introduces must either be promoted to the typed shape or reached throughraw.