wats.sh
Reference

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
  • 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). Optional fields:

  • wa_id? (snake_case retained for compatibility; see note below)
  • profile?: { name? }
  • name?: WhatsAppContactName (carries both snake_case wire fields such as first_name / formatted and camelCase mirrors firstName / formattedName for forward compatibility)
  • phones?: ContactPhone[]
  • emails?: ContactEmail[]
  • addresses?: ContactAddress[]
  • org?: ContactOrg
  • urls?: ContactUrl[]
  • birthday?: string
  • raw?: 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?).
  • 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
}

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.

  • ParsedUpdateEvent
    • object: string (validated top-level webhook object value)
    • discriminator
      • field: string
      • subtype?: string (from change event when present)
      • eventType: string (field or ${field}.${subtype})
    • entry
      • index: number
      • id?: string
      • time?: number
    • change
      • index: number
      • value: Record<string, unknown> (reference to raw change.value)
    • raw
      • entry: Record<string, unknown>
      • change: Record<string, unknown>

parseWebhookUpdate(rawEnvelope, options?) options:

  • maxEntries?: number
  • maxChangesPerEntry?: number
  • maxTotalEvents?: number
  • supportedObjects?: readonly string[] (default allows whatsapp_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_envelope
  • unsupported_object
  • entries_limit_exceeded
  • changes_limit_exceeded
  • events_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/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