Types Reference
Shared TypeScript contracts: discriminated-union domain types for inbound WhatsApp objects.
active · 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). Optional fields:
wa_id?(snake_case retained for compatibility; see note below)profile?: { name? }name?: WhatsAppContactName(carries both snake_case wire fields such asfirst_name/formattedand camelCase mirrorsfirstName/formattedNamefor forward compatibility)phones?: ContactPhone[]emails?: ContactEmail[]addresses?: ContactAddress[]org?: ContactOrgurls?: ContactUrl[]birthday?: stringraw?: unknown
The snake_case sub-fields (first_name, formatted, country_code, ...) are slated to migrate to camelCase-only as the typed update normalizer extends coverage. Until then both casings are accepted.
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
}Parsed update event types
:::note[Deprecated]
The untyped parseWebhookUpdate parser and the createUpdateRouter /
UpdateRouter / ParsedUpdateEvent / UpdateFilter legacy surfaces
documented in this section are now marked @deprecated at the type
level. They are superseded by normalizeWebhookEnvelope (which emits the
discriminated TypedUpdate union — see
webhook-normalizer) and the typed
TypedRouter /
filtersTyped surfaces. The legacy exports
remain in the @wats/core barrel for now but are scheduled for removal
in the next minor release; new code should use the typed path.
:::
@wats/core provides normalized parsed-event contracts for handler routing.
ParsedUpdateEventobject: string(validated top-level webhookobjectvalue)discriminatorfield: stringsubtype?: string(from changeeventwhen present)eventType: string(fieldor${field}.${subtype})
entryindex: numberid?: stringtime?: number
changeindex: numbervalue: Record<string, unknown>(reference to rawchange.value)
rawentry: Record<string, unknown>change: Record<string, unknown>
parseWebhookUpdate(rawEnvelope, options?) options:
maxEntries?: numbermaxChangesPerEntry?: numbermaxTotalEvents?: numbersupportedObjects?: readonly string[](default allowswhatsapp_business_account)
parseWebhookUpdate result union:
- success:
{ ok: true, events: ParsedUpdateEvent[], skippedEntries: number, skippedChanges: number } - failure:
{ ok: false, events: [], error: { code, message } }
Parser error codes:
invalid_envelopeunsupported_objectentries_limit_exceededchanges_limit_exceededevents_limit_exceeded
createUpdateRouter(options?) dispatch summary includes:
- base counters:
totalEvents,matchedHandlers,executedHandlers,failedHandlers,unmatchedEvents,errors - safety metadata:
capped: boolean,aborted: boolean,limitError?: { code, message, eventIndex } - limit error codes:
handlers_per_event_limit_exceeded,dispatches_limit_exceeded
This preserves best-effort nested malformed skipping while surfacing explicit degradation counters and hard-limit failures.
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.