wats.sh
Reference

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
  • WhatsAppClientRuntimeConfig
    • normalized runtime shape for async client initialization paths

WhatsAppMessage (discriminated union)

WhatsAppMessage is a closed union keyed by a string-literal type:

  • TextMessagetype: "text", text: { body: string }
  • ImageMessagetype: "image", image: MediaReference
  • VideoMessagetype: "video", video: MediaReference
  • AudioMessagetype: "audio", audio: MediaReference
  • DocumentMessagetype: "document", document: DocumentReference (filename required)
  • StickerMessagetype: "sticker", sticker: MediaReference
  • LocationMessagetype: "location", location: { latitude, longitude, name?, address? }
  • ContactsMessagetype: "contacts", contacts: WhatsAppContact[]
  • ReactionMessagetype: "reaction", reaction: { messageId, emoji }
  • OrderMessagetype: "order", order: OrderPayload
  • SystemMessagetype: "system", system: SystemNotification
  • UnsupportedMessagetype: "unsupported", errors?: WhatsAppError[], raw: unknown
  • InteractiveMessagetype: "interactive", interactive: InteractiveReply (nested discriminated union)
  • ButtonMessagetype: "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?: string
  • raw?: unknown

Inbound contact normalization

The normalizer (normalizeWebhookEnvelope) maps two inbound contact surfaces to camelCase:

  1. Field-value contacts[] — sender profile data accompanying inbound messages (value.contacts[]). Mapped from Meta wire { wa_id, profile: { name } } to NormalizedContact (waId, profile.name, raw) and surfaced on each TypedMessageUpdate.contacts. Malformed entries (null, primitive, array, garbage records with no waId or valid profile) are skipped without throwing.
  2. Contacts-message entries — a contact card sent by a user (messages[].contacts[] when type: "contacts"). Wire sub-fields are mapped to camelCase on the normalized message payload: formatted_nameformattedName, first_namefirstName, wa_idwaId on phones, country_codecountryCode on addresses, etc. Malformed entries are skipped; the original wire is preserved via raw on 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?).
  • WhatsAppTemplateStatusUpdateValuemessageTemplateId, messageTemplateName, messageTemplateLanguage, event: "APPROVED" | "REJECTED" | "FLAGGED" | "PAUSED" | "DISABLED" | "PENDING_DELETION", reason?.
  • WhatsAppAccountReviewUpdateValuedecision.
  • WhatsAppUserMarketingPreferencesValuewaId, category, preference: "opt_in" | "opt_out", timestamp.
  • WhatsAppPhoneNumberChangeValuemobileDisplayName?, oldPhoneNumber?, newPhoneNumber.
  • WhatsAppIdentityChangeValuewaId, acknowledged, createdTimestamp, hash?.
  • WhatsAppRawWebhookValue — catch-all for unrecognized fields; exposes raw: unknown and optional errors?.

The outer envelope remains WhatsAppWebhookEnvelopeentry[]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, and receivedAt fields 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/configWhatsAppClientConfig, 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_EXPORTS
  • WATS_TYPES_WEBHOOK_EXPORTS
  • WATS_TYPES_ENTITIES_EXPORTS
  • WATS_TYPES_MESSAGES_EXPORTS
  • WATS_TYPES_STATUSES_EXPORTS
  • WATS_TYPES_CONTACTS_EXPORTS
  • WATS_TYPES_ERRORS_EXPORTS

Usage Notes

  • Prefer camelCase property names in all public contracts. Wire-level snake_case survives only inside WhatsAppContact / SystemNotification sub-fields, pending camelCase migration.
  • Keep async-first ergonomics in calling layers by passing WhatsAppClientConfig through initialization boundaries and normalizing once.
  • Never rely on [key: string]: unknown on the typed surface: every new field Meta introduces must either be promoted to the typed shape or reached through raw.

On this page