Webhook Normalizer
normalizeWebhookEnvelope: raw Meta webhook envelope in, TypedUpdate discriminated union out.
active
Feed a synthetic envelope to the normalizer in the playground.
normalizeWebhookEnvelope(envelope, options?) consumes a loose (already-JSON-parsed) Meta webhook envelope and emits a flat array of TypedUpdate discriminated-union values, paired with a skipped[] accumulator and a limitError? surface. It translates Meta's snake_case wire envelope into WATS-native typed objects with stable updateId, wabaId, phoneNumberId, and receivedAt fields — the exact surface handlers and listeners consume downstream.
Contract at a glance:
- Does not throw for entry / change / field malformations. Every such failure lands in
skipped[]with a reason code and a dottedpathpointer. Envelope-level failures (shape violations above the entry array) throwWebhookNormalizationError. - Soft-truncates at
maxEventsPerEnvelope(default 1000). Excess updates do not crash the caller; the overflow count is reported vialimitError. - Dedups within a single envelope on
(kind, updateId). First wins, duplicates go toskipped[]with reasonduplicate_update_id. - Rejects control characters on id-bearing fields: all control codepoints < 0x20 (NUL, TAB, CR, LF, …), 0x7F (DEL), and U+2028 / U+2029 (line / paragraph separators) are rejected on
entry.id,metadata.phone_number_id,messages[].id,statuses[].id. Whitespace-only strings are also rejected as non-meaningful ids. - Preserves payload fidelity: content fields like
text.bodykeep wire bytes verbatim; sanitizing user content is not this module's job. The original wire change is always attached on the typed update asrawChange.
TypedUpdate catalog
TypedUpdate is a discriminated union keyed by kind:
TypedMessageUpdate—kind: "message". Carries the normalized message plusphoneNumberId/wabaId/updateId(= message id) /receivedAt(ms; derived frommessage.timestampwhen present, elseclockNow()).TypedStatusUpdate—kind: "status". Same scope fields;recipient_idis normalized torecipientId,playedis accepted, andconversationis optional because v24+ omitsstatus.conversationby default outside special conversation windows.TypedAccountUpdate—kind: "account". Produced by account-scoped webhook fields:account_update,account_review_update,account_alerts,message_template_status_update,message_template_quality_update,message_template_components_update,phone_number_quality_update,phone_number_name_update,business_status_update,business_capability_update,security,template_category_update,account_offboarded,account_reconnected.wabaIdonly; nophoneNumberId(most account updates are WABA-scoped). An optionaltemplatehelper object appears on template status/quality/category/components account updates when the payload includes safemessage_template_id,message_template_name, andmessage_template_languagestrings.account.eventand Coexistenceaccount.disconnectionInfoare populated forPARTNER_REMOVED, plus typedaccount_offboardedandaccount_reconnectedclassification.account.phoneNumberQualityis populated forphone_number_quality_updateevents such asTHROUGHPUT_UPGRADEandTIER_UNLIMITED, andaccount.alertforaccount_alertsvalues such asPROFILE_PICTURE_LOST. Marketing Messages status webhooks stay visible throughpricing.category = "marketing_lite"andconversation.origin.type = "marketing_lite", andaccount_updateonboarding fields are promoted intoaccount.marketingMessagesfor events such asMM_LITE_TERMS_SIGNED.TypedGroupLifecycleUpdate—kind: "groupLifecycle". Produced bygroup_lifecycle_update; normalizesgroup_createsuccess/failure andgroup_deleteoutcomes intogroup.groupId,requestId,inviteLink,joinApprovalMode, anderrors.TypedGroupParticipantsUpdate—kind: "groupParticipants". Produced bygroup_participants_update; normalizesadded_participants,removed_participants,failed_participants,join_request_id,wa_id, andinitiated_byinto camelCase group payload fields.TypedGroupSettingsUpdate—kind: "groupSettings". Produced bygroup_settings_update; normalizesprofile_picture,group_subject, andgroup_descriptionupdate outcomes, includingupdateSuccessfulanderrors.TypedGroupStatusUpdate—kind: "groupStatus". Produced bygroup_status_update; carriesgroup_suspendandgroup_suspend_clearedmoderation lifecycle events.TypedUserPreferencesUpdate—kind: "userPreferences". Produced byuser_preferences; normalizes marketing opt-in/opt-out entries intopreference.waId,category,preference, andtimestamp.TypedSystemUpdate—kind: "system". Produced bysystem; normalizesphone_number_changeassystem.phoneNumberChangeandidentity_changeassystem.identityChange.TypedChatOpenedUpdate—kind: "chatOpened". Produced bychat_opened; normalizesREQUEST_WELCOMEfirst-contact hooks intochatOpened.type,from,timestamp, and matching contact details when Meta includes them.TypedUnknownUpdate—kind: "unknown". Catch-all for webhook field names Meta has not yet published a typed shape for. Preservesfield+rawChangeso the consumer can inspect.
All variants carry rawChange (the wire WhatsAppWebhookChange).
Narrowing recipe
import { normalizeWebhookEnvelope, type TypedUpdate } from "@wats/core";
const { updates, skipped, limitError } = normalizeWebhookEnvelope(req.body);
for (const u of updates) {
switch (u.kind) {
case "message":
// u is TypedMessageUpdate — u.message is typed WhatsAppMessage.
console.log("msg", u.updateId, u.phoneNumberId, u.message);
break;
case "status":
// u is TypedStatusUpdate — u.status is typed WhatsAppMessageStatus.
console.log("status", u.updateId, u.status.status);
break;
case "account":
console.log("account", u.eventName, u.payload);
break;
case "groupLifecycle":
case "groupParticipants":
case "groupSettings":
case "groupStatus":
console.log("group", u.kind, u.group.groupId);
break;
case "userPreferences":
console.log("preference", u.preference.waId, u.preference.preference);
break;
case "system":
console.log("system", u.system.type);
break;
case "chatOpened":
console.log("chat opened", u.chatOpened.from, u.chatOpened.type);
break;
case "unknown":
console.log("unknown field", u.field);
break;
}
}v24/v25 webhook deltas
The normalizer tracks recent WhatsApp Cloud API webhook changes:
WhatsAppMessageStatusKindincludesplayed, used for voice-message playback receipts.status.conversationremains optional; in v24+ it is absent by default except when Meta includes a conversation object for special conversation windows.- Media references include
media.urlwhen incoming media webhook payloads carry a Meta lookaside download URL. - Unsupported messages carry
unsupported.type,unsupported.title,unsupported.description, andunsupported.raw, so removed/unsupported shapes such asrequest_welcomeare preserved without pretending WATS supports them. - Account/coexistence updates include
account.event,account.disconnectionInfoforPARTNER_REMOVED, and typedaccount_offboarded/account_reconnectedclassification.
All of these are credential-free synthetic-envelope checks in CI; live Meta validation stays in the credential-gated campaign.
Groups webhook normalization
Groups normalization is credential-free. Meta sends each group field entry under value.groups[]; WATS emits one typed update per group item:
group_lifecycle_update:group_create(success orerrors[]) andgroup_delete; successful creates surfacegroupId,subject,inviteLink,joinApprovalMode, andrequestId.group_participants_update:group_participants_add,group_join_request_created,group_join_request_revoked, andgroup_participants_remove; participant arrays are camelCased (waId,addedParticipants,removedParticipants,failedParticipants,initiatedBy).group_settings_update:profilePicture,groupSubject, andgroupDescriptionupdate outcomes, includingupdateSuccessfulanderrors.group_status_update:group_suspendandgroup_suspend_cleared.- Group inbound
messagescarrymessage.groupId. Group status webhooks carrystatus.recipientType === "group"andstatus.recipientParticipantIdwhen Meta includesrecipient_participant_id.
Unknown future group fields still become TypedUnknownUpdate. Missing or unsafe metadata.phone_number_id / group_id values are reported through skipped[]; they do not throw.
User preferences, system, and chat-opened updates
Three webhook families are promoted out of the unknown bucket. These remain credential-free synthetic-envelope checks in CI; live webhook delivery still belongs to the gated campaign.
user_preferencesemits oneTypedUserPreferencesUpdatepervalue.user_preferences[]row. Public fields are camelCase:preference.waId,preference.category,preference.preference("opt_in" | "opt_out"), andpreference.timestamp.systememits oneTypedSystemUpdatepervalue.system[]row.phone_number_changenormalizes tosystem.type === "phoneNumberChange"withsystem.phoneNumberChange.newPhoneNumber,oldPhoneNumber, andmobileDisplayName;identity_changenormalizes tosystem.type === "identityChange"withwaId,acknowledged,createdTimestamp, and optionalhash.chat_openedemitsTypedChatOpenedUpdatefor first-contact hooks such asREQUEST_WELCOME, exposingchatOpened.from,chatOpened.type, optionaltimestamp, and the matching contact when present.
Malformed rows in these families are reported through skipped[] with malformed_field; they do not fall back to TypedUnknownUpdate and do not throw host errors for accessor-backed fields.
Calling webhook normalization
Calling normalization is credential-free and synthetic-only. Meta delivers
calls webhook changes containing value.calls[] and value.statuses[]
arrays. WATS emits one typed update per call/status entry:
TypedCallUpdate—kind: "callConnect"(eventconnect) orkind: "callTerminate"(eventterminate). The normalizedcallpayload surfaces camelCase public fields only:id,event,from,to,direction("USER_INITIATED" | "BUSINESS_INITIATED"),timestamp,session.- terminate-call outcome fields:
status(Meta documents"FAILED"/"COMPLETED"; any string is accepted for forward-compat),startTime,endTime,duration(number),bizOpaqueCallbackData. - connect/terminate identity fields:
toUserId,toParentUserId, andcontacts— a readonly array of{ name?, username?, waId?, userId?, parentUserId?, raw }. Each contact preserves its original record asraw.
TypedCallStatusUpdate—kind: "callStatus". The normalizedcallStatuspayload surfacesid,status("RINGING" | "ACCEPTED" | "REJECTED"),recipientId,timestamp, plusrecipientUserId,recipientParentUserId, andbizOpaqueCallbackData.call_permission_replyinbound messages (aninteractivemessage whose interactive sub-type iscall_permission_reply) surfacemessage.fromUserIdandmessage.fromParentUserIdwhen Meta includes the caller identity fields.
Wire snake_case keys (biz_opaque_callback_data, start_time, end_time,
to_user_id, to_parent_user_id, recipient_user_id,
recipient_parent_user_id, from_user_id, from_parent_user_id,
wa_id, user_id, parent_user_id, recipient_id) are translated to
camelCase on the public payload and never leak outside raw. The original
record is preserved unchanged as raw.
Malformed optional fields are omitted rather than making an otherwise-valid
update malformed: non-string status/startTime/endTime/bizOpaqueCallbackData,
non-finite duration, unsafe (whitespace-only, control-bearing, or oversize)
id-bearing fields, non-array contacts, and non-record contact entries are
all skipped. Accessor-backed getters on call/status/contact records are never
executed. These remain credential-free synthetic-envelope checks in CI; live
calling webhook delivery still belongs to the gated campaign.
Input contract
normalizeWebhookEnvelope(
envelope: unknown,
options?: NormalizeWebhookOptions
): NormalizedWebhookResultinterface NormalizeWebhookOptions {
readonly maxEventsPerEnvelope?: number; // default 1000
readonly clockNow?: () => number; // default Date.now
}
interface NormalizedWebhookResult {
readonly updates: readonly TypedUpdate[];
readonly skipped: readonly SkippedUpdate[];
readonly limitError?: LimitExceededDetail;
}envelope— the HTTP JSON body already parsed by the consumer (or by whatever framework sits in front of@wats/http). Must be a plain object with the canonical Meta shape. Bad shapes throw.maxEventsPerEnvelope— positive finite integer cap. Invalid values (NaN / Infinity / 0 / negative / non-integer) throwWebhookNormalizationErrorwith codeinvalid_option. This validation runs FIRST, before any envelope shape check, so caller misuse cannot be silently swallowed.undefinedfalls back toDEFAULT_MAX_EVENTS_PER_ENVELOPE(exported, currently1000).clockNow— test seam. Used only when an inner update lacks a usabletimestamp.
Options validation
Invalid maxEventsPerEnvelope values throw explicitly — no silent fallback to the default:
normalizeWebhookEnvelope(envelope, { maxEventsPerEnvelope: 0 });
// throws WebhookNormalizationError { code: "invalid_option" }Envelope-level error taxonomy
WebhookNormalizationError extends Error and carries a machine-readable .code + optional .path.
| Condition | code |
|---|---|
| Envelope is not a plain object (null, undefined, array, primitive) | invalid_envelope |
Envelope is missing the object string field | missing_object_field |
Envelope object is not "whatsapp_business_account" | unsupported_object |
Envelope entry is not an array (null / string / object) | invalid_entry_array |
options.maxEventsPerEnvelope is not a positive finite integer (0, negative, NaN, Infinity, non-integer) | invalid_option |
These are the ONLY cases that throw. Everything else lands in skipped[].
skipped[] reason taxonomy
| Reason | Emitted when |
|---|---|
malformed_entry | Entry slot is null / non-object / missing id / has non-array changes, OR entry.id fails the id-safety gate (control chars, DEL, U+2028/U+2029, whitespace-only, or length > MAX_ID_LENGTH). |
malformed_change | Change slot is null / non-object / missing/empty field / non-string field / has non-object value. |
malformed_field | A field-level problem inside a valid change: e.g. a messages[i] object missing id, a statuses[i] object whose id fails the id-safety gate, a metadata.phone_number_id that is missing or fails the id-safety gate (rejects all control chars < 0x20, 0x7F, U+2028/U+2029, whitespace-only, or length > MAX_ID_LENGTH). |
duplicate_update_id | A second update with the same (kind, updateId) was seen in the same envelope. First wins. |
unsupported_field | Reserved. Not currently emitted — unknown fields become TypedUnknownUpdate rather than skips. |
Every SkippedUpdate carries a path (dotted indexed path such as entry[0].changes[2].value.messages[1]) and an optional short detail describing the sub-condition.
Soft-truncate: maxEventsPerEnvelope / limitError
The default limit is DEFAULT_MAX_EVENTS_PER_ENVELOPE = 1000.
updates.length === 0throughlimit→limitErrorisundefined.- When the normalizer would produce a
(limit + 1)th update, it stops pushing toupdates, counts remaining would-be updates inlimitError.count, and setslimitError.limit. The normalizer NEVER throws on size, never drops existing work, and always surfaces the overflow count so the caller can report or bisect.
const result = normalizeWebhookEnvelope(envelope, { maxEventsPerEnvelope: 100 });
if (result.limitError) {
logger.warn("webhook envelope exceeded limit", {
count: result.limitError.count, // total would-be updates
limit: result.limitError.limit // 100
});
}Control-character defense on id-bearing fields
Id-bearing fields flow into downstream URL path segments, headers, and log lines. The normalizer enforces a byte-level safety check on:
entry.id(-> TypedUpdate.wabaId)metadata.phone_number_id(-> TypedMessageUpdate.phoneNumberId / TypedStatusUpdate.phoneNumberId)messages[].id(-> TypedMessageUpdate.updateId)statuses[].id(-> TypedStatusUpdate.updateId)
All control characters < 0x20 (including CR / LF / NUL / TAB — the classic CRLF / NUL injection bytes — written as \r, \n, \u0000, \t), plus 0x7F (DEL) and U+2028 / U+2029 (line / paragraph separators) are rejected. Whitespace-only strings are rejected as non-meaningful ids. Any violation → the offending record is skipped with reason malformed_entry (for entry.id) or malformed_field (for the rest). The skip path points at the exact location.
Content fields (for example text.body) intentionally preserve these bytes; content sanitization is out of scope here.
Maximum ID length
All four id-bearing fields are capped at MAX_ID_LENGTH = 256 characters. Inputs exceeding the cap are skipped with the same reason codes as other invalid ids. The cap is exported as MAX_ID_LENGTH for consumer inspection.
Timestamp sanity cap
receivedAt parsing rejects values whose computed unix-ms result exceeds the end of year 9999 (253_402_300_799_999). Absurd timestamps like "9999999999999999999" — which would otherwise multiply to ~1e22 — fall back to clockNow() instead of propagating. Negative and zero timestamps also fall back.
Within-envelope duplicate-id dedup
Dedup key: (kind, updateId) — a message and a status that happen to share an id are both kept (the kinds are different). A second message with the same updateId as a prior message in the same envelope is dropped with reason duplicate_update_id.
The normalizer has no persistent state. Cross-envelope dedup is the caller's responsibility — typically a short-lived Redis SETNX or in-memory LRU keyed by updateId. Normalization is deliberately not coupled to a dedup store.
receivedAt semantics
- For message / status updates: parsed from the inner
timestampstring (Meta encodes unix seconds as a decimal string). Multiplied by 1000 to produce unix milliseconds. - If the inner timestamp is missing or malformed, the normalizer falls back to the injected
clockNow()(defaultDate.now). - For account / unknown updates: derived from
entry.time(unix seconds) if present, elseclockNow().
receivedAt is therefore deterministic for well-formed payloads and test-seam-friendly for payloads that omit a timestamp.
Deep message normalization
The most common inbound message body families are promoted from Meta's wire snake_case into WATS camelCase public fields while keeping the top-level update kind as "message":
| Wire family | Normalized public shape |
|---|---|
image / video / audio / document / sticker | media references expose mimeType, sha256, optional caption / filename, plus safe ids. |
interactive.button_reply | interactive: { type: "button_reply", buttonReply: { id, title } } |
interactive.list_reply | interactive: { type: "list_reply", listReply: { id, title, description? } } |
interactive.nfm_reply | interactive: { type: "nfm_reply", nfmReply: { responseJson?, body?, name? } } |
location | location: { latitude, longitude, name?, address? } |
reaction.message_id | reaction: { messageId, emoji } |
button quick replies | button: { text, payload? } |
context | context: { messageId, from?, forwarded?, frequentlyForwarded?, referredProduct? } |
The normalizer reads these fields descriptor-safely. Accessor-backed nested properties, sparse/accessor array slots, cycles, unsafe prototype keys (__proto__, constructor, prototype), custom prototypes, and toJSON hooks are treated as malformed data: the affected nested helper is omitted or the record is skipped according to the existing skipped[] taxonomy. Host TypeError / getter-thrown errors do not escape expected malformed payloads.
rawChange passthrough
Every TypedUpdate carries rawChange: WhatsAppWebhookChange — the original webhook change object untouched. This is the authoritative wire snapshot for:
- audit logging (preserve exact bytes Meta sent)
- advanced consumers that need fields the typed surface has not promoted yet
- round-trip fidelity (store + replay)
WATS filters inspect normalized public fields, not rawChange.
Non-goals
The normalizer does NOT:
- Deep-normalize
statusand arbitraryaccountpayload internals beyond the existing template helpers; deep normalization is message-body focused. - Register handlers — that's the router.
- Provide Graph API calls — no
@wats/graphcoupling. - Persist dedup state — caller's responsibility.
- Modify the legacy
parseWebhookUpdate— the normalizer is an independent producer above it. - Verify webhook signatures — that is
@wats/http's job. - Talk to live Meta endpoints or require credentials.
Exported surface
From @wats/core (and mirrored at @wats/core/webhookNormalizer):
normalizeWebhookEnvelopeWebhookNormalizationError(class; extendsError)DEFAULT_MAX_EVENTS_PER_ENVELOPE(number constant)MAX_ID_LENGTH(number constant; currently256)- Types:
TypedUpdate,TypedUpdateKind,TypedMessageUpdate,TypedStatusUpdate,TypedAccountUpdate,TypedUnknownUpdate,TypedUserPreferencesUpdate,TypedSystemUpdate,TypedChatOpenedUpdate,SkippedUpdate,SkippedReason,LimitExceededDetail,NormalizeWebhookOptions,NormalizedWebhookResult,WebhookNormalizationErrorCode
End-to-end usage sample
import {
normalizeWebhookEnvelope,
WebhookNormalizationError,
DEFAULT_MAX_EVENTS_PER_ENVELOPE,
type TypedUpdate
} from "@wats/core";
async function handleWebhook(rawBody: string) {
let envelope: unknown;
try {
envelope = JSON.parse(rawBody);
} catch {
return { status: 400 };
}
let result;
try {
result = normalizeWebhookEnvelope(envelope, {
maxEventsPerEnvelope: DEFAULT_MAX_EVENTS_PER_ENVELOPE
});
} catch (err) {
if (err instanceof WebhookNormalizationError) {
// err.code ∈ { invalid_envelope, missing_object_field,
// unsupported_object, invalid_entry_array,
// invalid_option }
return { status: 400, error: err.code };
}
throw err;
}
if (result.limitError) {
logger.warn("envelope soft-truncated", result.limitError);
}
for (const skip of result.skipped) {
logger.debug("skipped update", skip);
}
for (const update of result.updates as readonly TypedUpdate[]) {
await dispatch(update);
}
return { status: 200 };
}Related
- Webhook primitives — low-level envelope parser.
- Types Reference — the discriminated-union domain types that flow through
TypedUpdate.message/TypedUpdate.status.