Extract WhatsApp Web's protocol surface from its JavaScript bundle and emit a language-neutral IR (plus reference Rust code) — for any client/library to build on.
WhatsApp Web ships its whole protocol (IQ stanzas, protobuf schemas, GraphQL operations, app-state actions, feature flags, wire enums) compiled into a large minified JS bundle. whatspec parses that bundle with the oxc AST and writes a clean, versioned, deterministic intermediate representation under generated/. The IR is the contract — consume it from Rust, Go, TypeScript, Python, anything. The committed Rust modules are a reference consumer, not the point.
| Domain | generated/… |
What it is |
|---|---|---|
| iq | iq/index.json (+ Rust) |
<iq> request builders & response parsers, per namespace |
| proto | proto/WAProto.proto |
the protobuf schemas, as a .proto file |
| mex | mex/index.json (+ Rust) |
Relay/GraphQL persisted operations (doc id, kind, typed variables/response) |
| appstate | appstate/index.json (+ Rust) |
app-state (syncd) action schemas + indexing |
| abprops | abprops/index.json (+ Rust) |
the A/B-props feature-flag registry (~1.7k flags: code, type, default) |
| enums | enums/index.json (+ Rust) |
the wire-enum catalog (nack codes, chat/receipt types, …) |
| wam | wam/index.json (+ Rust) |
the client-telemetry surface: 436 events (code, typed fields, channel, sampling weights) plus the buffer they are written into — the globals its header carries with the channels each is legal on, the private-stats rotation groups, the protocol/flush constants — and, per event, the places WA Web constructs it |
| notif | notif/index.json (+ Rust) |
the incoming stanza-dispatch catalog: <notification type="…"> kinds + handlers + typed content shapes + the payload action unions (w:gp2's 40+ group actions) |
| tokens | tokens/index.json (+ tokens.json) |
the binary-protocol token dictionaries (single-byte + 4 double-byte), wire-indexable |
| wasm | wasm/index.json |
the WebAssembly surface: emscripten payload names (liboqs_wasm_wrapper.wasm, …) + the bootloader (bx) handles the JS resolves their bytes through, with consuming modules |
Every domain ships a JSON Schema under generated/schema/, and a top-level generated/manifest.json stamps the WhatsApp version, per-domain counts, content hashes, and extraction diagnostics.
Field shapes tell you how to read a stanza. They are not enough to produce one, nor to explain why a real client rejected the one you produced. So the IR also carries the rules WA Web's own parsers enforce — symmetric by construction, so they serve a parser and an emitter equally:
- Echo rules (
assertions[].kind == "reference", andreferencePathon a field) — an answer'sfrommust equal the request'sto, itsidthe request'sid.referencePathis the argument list of WA'sattrStringFromReference, so["to"]means "the request'sto" and["account","action"]means "theactionattribute of the request's<account>child" — no name-matching required. An emitter that hardcodesfrom="s.whatsapp.net"makes every answer to ag.usrequest unparseable. - Pinned values (
literalValueon a field) —type="admin"on a successful promote,matched="true"/"false"on a blocklist update,code=429on a rate-limit error.parserRequiredseparates the two forms: a required pin is a hard discriminator (must be present and exact); an optional one is pinned only when present (may be omitted, must never be contradicted). - The per-RPC error vocabulary (
errorArms, pluserrorEnvelopefor a two-level error) — a closed set, and it differs per RPC:BatchGetGroupInfoaccepts400 bad-requestand429 rate-overlimitand rejects404 item-not-found, even though that mixin exists and other RPCs use it. Each arm pairs thecodewith thetextthat must accompany it, so an emitter cannot combine one arm's code with another's text and produce a stanza no branch accepts; an arm that range-checks instead of pinning carriescodeMin/codeMax. The variant'skindsays which side is at fault (client_error/server_error), derived from the codes rather than from the parser's name. - Response enums (
enumRefon a field) — the legal values behind anattrStringEnum, resolved the same way the request side already resolves them, instead of a bare"type": "enum". - The accessor's decoded type, kept faithful: every
attr*/content*spelling WA's parsers use is classified (amaybeAttrXderives fromattrX, so a flavour cannot be covered for one spelling and missed for the other), and the JID flavours stay distinct — a PN user JID and a LID user JID are different identities for the same person and must never collapse into onestring. - Notification action unions (
notifications[].actions) — the payload inside the envelope. ThewireTag → actionTypemapping is many-to-one (not_ephemeralnormalises intoephemeralwithduration: 0, so branching onnot_ephemeralis dead code) and field names are rebound (the disappearing-message timer arrives inexpiration, but the action field isduration). Neither is derivable from the wire. - The out-of-set policy (
unknownValueon a field) — WA writes two things into one accessor name, and only the decoded type was ever published.<meta polltype>is read withattrEnumOrNullIfUnknownand<message type>withattrEnum: same"type": "enum", sameparserRequired, and opposite behaviour on a value the enum does not list — one yields null and the parse continues, the other rejects the stanza. That is the difference between an enum a consumer may close and one that needs a fallback variant, and it used to be recoverable only by reading the WhatsApp method name in English. - What
parserRequireddoes not say. It means the accessor isattrXrather thanmaybeAttrX— the parser rejects the node without the value at the point it reads it. It is not "the wire always carries this":polltypeisparserRequiredand the client only reads it when the envelope'stypeispoll, 1 of 7 values. No domain models the branch condition, so the field is an upper bound on presence; a consumer that validated on the old name (required) rejected legitimate traffic. - One index for every enum —
enums/index.jsonis keyed by(module, name)and contains every enum anyenumRefin the IR names, whether or not it is an$InternalEnum.nameis not a key:ENUM_FALSE_TRUEis defined by eleven different modules, andEventType's two definitions disagree onvalueKind. Those generated names are flaggedsyntheticName— WA spells them out of their own members, so they are not type names.bitPositionmarks an int enum whose values are shift distances (1 << 2, not2), recovered from the bundle shifting by a member. - What the extractor could not resolve says so.
targetseparates the group server (g.us) from one group's own JID (group_jid) — WA writes them from differently-named mixins and 26 of the 33w:g2requests take the second — and saysunknownwhen atoresolves to no fixed server,unsetwhen nothing writes one, instead of all four readings.whatsapp.net; a field with no accessor whose content is its children istype: "node"instead ofstring. Both counted in the manifest, both guarded.
Note the contract version: this raised schemaVersion to 2.0.0, and it is a real major bump rather than a cautious one. Four changes need action from a 1.x consumer:
AssertionKindgained areferencevariant, widening the value space of an existing field — a closed-enum consumer rejects the document rather than ignoring it (validating the currentiq/index.jsonagainst the 1.0 schema fails 579 times).ResponseVariantKindgainedclient_errorandserver_error; a variant that waserrormay now be either. Match on all three, or use theis_error()grouping.- A response variant's
errorCodes/errorTexts/errorCodeMin/errorCodeMax/errorClassare gone, replaced byerrorArms(+errorEnvelope). The flat lists were removed rather than kept alongside because they were unsound: two independent lists cannot say which code goes with which text, and 117 variants admitted combinations the parser rejects. ContentTypegainedinteger, the same closed-enum widening as the first item: a<registration>whose body is a number used to be reported asstring. Live in the response children that read a big-endian integer content (contentUint).
schemaVersion is now 3.0.0. That release turned the same rule on this repository's own output: three fields were asserting things the extractor had not established, and each is fixed by widening or renaming an existing field rather than by adding an optional one. Three changes need action from a 2.x consumer:
IqTargetgainedgroup_jid,unsetandunknown, widening the value space ofrequest.target/target. All 143 stanzas used to reads.whatsapp.net— the enum had nowhere to put "not resolved", and the rule that filled it keyed on a literalto="g.us"the builders had stopped writing. They now read 106s.whatsapp.net, 26group_jidand 6g.us— thew:g2requests, which the IR addressed to the server — and 5unknown: the four newsletter requests whosetoisWAWap.JID(newsletterId), plusGetGroupProfilePictures, which folds in a runtime router addressing either a group's JID or the group server. Migration: match the three new values.g.usis unchanged on the wire and still means the literal group server (create, leave, list); what moved out of it isgroup_jid, one group's own<group>@g.us, which you must supply — sending the bare server there answers nothing.unknownmeans the addressee is a parameter of the call and the IR does not know it;unsetthat nothing writes atoat all and neither should you. A closed-enum consumer rejects the document until it handles them.ParsedFieldTypegainednode, and 617 fields that declaredstringwhile carrying children now declare it —wAMOSubMixin,groupAddressingModeMixinand the other folded-in payload mixins. Migration: anodeis a container, not a value; read itschildrenand generate no scalar for it. A consumer switching ontypegets a value it has no arm for, which is better than the arm it had.- A response field's
requiredis nowparserRequired. A rename rather than a doc fix, because the old name stated a wire fact the field never carried (see the note above). Migration: rename the key. Reading the old one yieldsundefined, which fails loudly instead of defaulting to "optional".
Additive in 3.0.0, so a 2.x consumer can ignore them: unknownValue on enum-accessor fields; syntheticName / bitPosition in the enum catalog; and the catalog itself growing from 328 to 403 entries so every enumRef resolves against it — 75 of the 87 referenced (module, name) pairs were in no catalog before.
Everything above describes the wire. That serves a client that encodes the stanza itself — it needs to know a group create carries a <participant jid=…> of type user_jid. It does not serve a client that runs WhatsApp's own modules, which needs the other half: that the value goes in args.participantArgs[].participantJid. Neither half implies the other — WA picks the argument key independently of the attribute it lands in (subjectElementValue becomes the text of <subject>) — and almost every request is composed out of mixins, so the argument key is usually defined in a different module from the tag it fills. So the request side also carries the builder:
- Argument paths (
argPathon a request node, an attribute, or an element content) — the absolute path from the builder's argument object, as segments.listmarks each segment a repeated combinator iterates:REPEATED_CHILD(template, list, min, max)calls the template once per element, whileOPTIONAL_CHILD/HAS_OPTIONAL_CHILDhand the object over whole. Nested repeats mark more than one —userArgs[] → deviceArgs[] → deviceIdis a key read off an element of an element. The same suffix in the wrong place writes the value where the vendor builder never reads it, and the stanza goes out without it. Recovered structurally — from the function's single argument parameter and thevar x = <param>.<key>destructure — never from a name:…Args,has…andany…are WA conventions that make the IR readable, not evidence. A path that isn't structurally recoverable is absent and counted undermanifest.diagnostics.iq.builder, never guessed. The legacyWAWeb*Jobbuilders take positional parameters rather than one options object, so they get no path at all and are counted as such. - Cardinality of a request child (
presence, plusrepeatMin/repeatMax) — the three statesWASmaxChildrendistinguishes, of which the wire shows one.<locked/>on a group create is a presence marker: its template takes no arguments, its whole meaning is being there, and a consumer can model it as abool— which it must not do for an optional child and cannot do for an empty required one, all three of which are the same empty element on the wire. The repeat bounds are the ones a server enforces (add/participant1..1024,query/group1..10000,media_list0..10); arepeatMinwith norepeatMaxis WA's explicit1/0, i.e. unbounded above, which stays distinguishable from a child that states no bound at all and has neither. - The addressee's argument key (
targetArgPathon a request) —targetsays a request is sent to one group's own JID rather than to a server, and a consumer running the vendor builder still has to know which argument supplies it. 30 of the 31 runtime-addressed requests carry it (iqTo, and one composed through a mixin group asbaseGetGroupOrServerMixinGroupArgs → baseGetGroup → iqTo); the one that does not is a legacyWAWeb*Jobbuilder, which takes positional parameters and so has no argument object for a path to point into. - Element values that survive the mixin boundary (
content) —smax("subject", null, subjectElementValue)is the entire payload of a group rename. WA's builders bind that payload to a local before writing it, and a bare local used to be ignored outright in case it was a node variable. It is now told apart structurally: a local that resolves to an argument path is content, one that resolves to asmax(…)call is a child.
schemaVersion is now 4.0.0. Nearly all of the above is additive — every new property is optional and skipped at its default, and each committed */index.json validates clean against its own 3.0.0 schema (0 errors across all 12 domains). One change is not, and it is the whole reason for the major: value on a request attribute was documented as present only for kind: "const", and now also carries the fixed literal of a WASmaxAttrs.OPTIONAL_LITERAL(lit, flag) attribute, whose kind is optional. Migration: read value as "what this attribute says when it is written" rather than as an unconditional constant — on an optional attribute the builder writes it only when its boolean gate is set, which is the attribute analogue of a presence marker. Eight committed IQ attributes are in that state, and the old JSON Schema accepts every one of them, which is precisely why the version has to say what the schema cannot. Additive alongside it: argPath, presence, repeatMin/repeatMax, targetArgPath, and content populated on 45 request nodes that previously carried none.
The WAM catalog says what a metric contains. It does not say what carries it, and a consumer that has only the catalog is holding the contents of a message it cannot assemble or schedule. WA Web states the rest declaratively, in three modules next to the one the catalog comes from, and the IR now carries them:
- The buffer's globals (
globals) —defineGlobal({name: [id, type, channels]}), the values written ahead of the events they apply to. Same id space and same type vocabulary as an event field, plus one axis an event field has no analogue for: the channels the global may be written on. That axis is not decorative — WA's own writer mapsrealtimeontoregularand then skips any global whose list does not contain the buffer's channel, so puttingabKey2(regularonly) into aprivatebuffer produces one no client ever sends. 46 of them, and the reference codec's private-stats field id now comes frompsIdinstead of being a literal in hand-written Rust. - The private-stats groups (
privateStatsIds) — the table an event'sprivateStatsIdis a foreign key into. Everyprivate-channel event has one, and until now it was an integer that resolved against nothing: a consumer knew the event belonged to a rotation group without knowing which, or for how long. Nine entries: the eightPrivateStatsAllIdspublishes, and thenonegroup (id0) thatWAWebWamPrivateStatsadds on top of it — the one 21 of the 50 private events name, so the published table alone leaves two fifths of the channel unresolvable. Each entry says which module it came from, androtationPeriodDayskeeps WA's-1sentinel rather than normalising "never rotates" into a period. - The buffer constants (
constants) — the six literals ofWAWebWamConstants:WAM_PROTOCOL_VERSION(byte 4 of every buffer, previously hardcoded in the generated codec with nothing saying where it came from), the two size caps, the batch size, and the in-memory/rotation intervals. The line drawn is the module: these are literals a module exists to export, whereas the 1 % beaconing roll inWAWebWamBeaconingis a step of an algorithm, and a number lifted out of an algorithm is not something a consumer can act on.
Where each event is emitted (callSites) is the fourth, and it corrects a claim rather than adding one. consumers was documented as "modules that construct + .commit() this event" while holding the dependency graph — every module that imports the event's module, whatever it does with it, so a module that only reads the type is indistinguishable from one that emits, and the worker's router (WAWebWamProcessWorkerData) is on 63 events for routing them rather than sending them. The doc now says what the field is, and beside it callSites says what was actually found: 807 sites, out of 883 constructions of new (o("WAWeb…WamEvent").<Export>)(…) seen in the bundle, each with the fields written there — in the constructor's object, by a later event.field = …, or through event.set({…}), which are three spellings of one mechanism (set is for (k in obj) this[k] = obj[k], and the constructor calls it). 3293 (site, field) pairs, 784 of them with a value that is fixed at extraction time: a literal, or an enum member named rather than resolved to its integer.
That turns parity from a reading exercise into a mechanical one: the fields a consumer's own emitter fills must be a subset of the union of the real sites, and a field no site writes is a question to answer. Two things it deliberately does not say. Not when: the guard a construction sits under is control flow, so a call site is a place the client can emit the event. Not exhaustively, without saying so: a site whose argument is merged (babelHelpers.extends) or built elsewhere carries partial: true, and its field list is a lower bound — 121 of the 807. A site that only overrides the sampling weight is not partial: weight is not a field, so nothing is missing from its list; that override is counted on its own. The constructions that yielded no field set at all are counted in manifest.diagnostics.wam by the form that resisted (104 built into a variable first, 41 of the generic RawWamEvent whose schema is supplied at runtime), never omitted — and the block closes on itself: 883 constructions = 807 published sites + 35 collapsed as identical to one already published + 41 with no catalog entry.
Two smaller corrections came out of the same pass, both visible in the numbers: the catalog gained 23 fields it had been dropping, because the minifier writes a repeatedly used enum module as (e = o("WAWebWamEnum…")).X once and e.X afterwards, and only the first spelling was read — one event published a single field while WA declares five; and weights is now documented as the default it is, since the client's own writer lets a runtime sampling lookup override it and four call sites assign weight directly.
schemaVersion is now 4.1.0, a minor because all of the above is additive: every new property is optional, the new lists are skipped when empty, and the committed wam/index.json validates against the 4.0.0 schema with 0 errors. The one thing a 4.x consumer must re-read is consumers, whose data did not change and whose documentation did. scripts/lint-ir.py gained the three invariants the JSON Schema cannot state — a global with no channel, a privateStatsId that resolves against no group, a call site naming a field its event does not declare — and diagnostics.wam.{globals,privateStatsIds,constants,callSites} are floor-guarded like every other coverage number.
Anything the extractor sees but cannot resolve structurally is counted under manifest.diagnostics.iq.dropsByReason rather than omitted, so "no constraint here" and "a constraint we failed to extract" never look alike. manifest.diagnostics.iq.constraints, diagnostics.iq.targets.resolved, diagnostics.iq.builder and diagnostics.notif.actions are floor-guarded: a WA refactor that hides one of these constructs fails the update instead of silently emptying a field. The unresolved states are guarded the other way — scripts/lint-ir.py pins the count of unaddressed requests and of unjudged accessors to an exact baseline. A rise means a constraint is being lost; a fall means extraction improved and the baseline owes an update. Either way the lint fails, so neither direction passes unnoticed.
# Fetch the current web.whatsapp.com bundle and (re)generate everything:
cargo run --release -p whatspec -- update
# …or process bundles you already have (offline):
cargo run --release -p whatspec -- update --bundles ./my-bundles
# Version-keyed cache: skip the download when the remote version is unchanged:
cargo run --release -p whatspec -- update --cache .wa-cache
# Seed that same cache from the lock-pinned GitHub Release (no live bundle fetch):
cargo run --release -p whatspec -- restore --from-lock generated/bundles.lock.json --cache .wa-cache
# Also resolve, download and store the client's wasm payloads (~41 MB), as <url-hash>.wasm
# (a cache filename is a location label, not a content address — see the note below):
cargo run --release -p whatspec -- update --cache .wa-cache --wasm-out ./wasm
# Restore a locked wasm set (its own lock + its own content-addressed release asset):
cargo run --release -p whatspec -- restore --wasm --from-lock generated/wasm.lock.json --out ./wasm
# Compare two generated outputs (e.g. across a WhatsApp version bump):
cargo run --release -p whatspec -- diff old-generated/ generated/
# Deterministically reproduce & verify generated/ from its pinned inputs (no live fetch):
./scripts/regen.shupdate is safe by default: it refuses to overwrite the committed output if any domain's coverage shrinks (pass --allow-shrink to accept a genuine reduction), and fails loudly if a domain extracts nothing.
WhatsApp only serves the current bundle version — old bundle URLs 404 — so the inputs that produced a past generated/ can't be re-fetched from source. To keep the "same bundle → byte-identical output" promise checkable by anyone at any time, each generation pins and preserves its exact inputs:
generated/bundles.lock.jsonrecords the content SHA-256 (+ size, and origin URL when known) of every bundle that produced the committedgenerated/, plus a one-line, order-invariantsetHashfingerprint of the whole set.- The bytes live in a durable, WhatsApp-independent store: a rolling
bundle-storeGitHub Release whose assets are content-addressed —bundles-<version>-<setHash>.tar.xz(pure-Rust xz, roughly half the size of gzip; legacy.tar.gzassets are still read) — so a set is never overwritten with different bytes and every past commit's lock keeps resolving the exact archive it pins. Published automatically by the update workflow. Note the two different hashes: a release asset is named after its contents (setHash), while a cache filename is named after its URL (the JS last segment, orsha256(url)for wasm) — there, the content hash lives in the cache'smanifest.jsonand is what integrity is verified against. A cache filename never proves what is inside the file. whatspec restore --from-lock generated/bundles.lock.json --out <dir>pulls that archive, verifies every bundle's SHA-256 against the lock, and writes a directory ready forupdate --bundles. Use--cache <dir>instead to seed the exact version directly into the reusableupdate --cachelayout; cache metadata and integrity files are written by the sameBundleCacheimplementation as a live fetch.scripts/regen.shwraps restore +update --checkinto a one-shot, offline determinism check — also run in CI, so every commit's committed IR (eachindex.json+WAProto.proto) is proven reproducible from its pinned inputs.
Bootstrap: the lock and the first archive are created by the first run of the update workflow (or a manual
update --save-bundles <dir>followed byscripts/publish-bundles.sh <dir>). Until then the CI reproducibility gate stays dormant.
The client's heavy lifting — the VoIP engine, media codecs (mozjpeg, WebP, MP4), the post-quantum liboqs wrapper, VOPRF — ships as WebAssembly, and none of those URLs appear in the JS. The glue asks the bootloader for a numeric handle (r("bx").getURL(r("bx")("33861"))) and the server maps handles to content-hashed URLs. So the two halves live in different places:
generated/wasm/index.json(committed, deterministic, offline-reproducible): the payload names the glue declares and everybxhandle with the modules that consume it, plus the static evidence that a handle addresses wasm (wasmBinaryLiteral/moduleName/wasmModuleCacheDep).generated/wasm.lock.json(written only by a--wasmfetch): what those handles actually resolved to —bxId,fileName,url,sha256,size— fingerprinted aswasmSetHash. ThebxIdis the join key back to the IR.
update --wasm resolves them fully headless: the entry page inlines only ~3 handles, so whatspec additionally asks /ajax/bootloader-endpoint/ for the components the page deferred — which is how it reaches the full set (9 payloads / ~41 MB at the time of writing) with no browser. The endpoint answers the same request with different subsets, so both request forms are merged over repeated rounds until a round adds nothing new; the resolved set is a best-effort superset and is reported as such.
A run that resolves or downloads only part of the set is cached as incomplete, so the next run resolves again instead of freezing one sample of a varying endpoint as that version's answer. --wasm-out sweeps payloads that left the set, so the directory is always exactly the lock's, and restore --wasm writes each payload under the name the lock records for its content hash rather than the archive's own label.
Wasm is deliberately outside the reproducibility chain above: no generated/ artifact depends on the bytes, the resolved set isn't closed, and the JS setHash (which names the published bundles archive) must not move because a payload changed. It therefore gets its own lock and its own release asset, wasm-<wasmSetHash>.tar.xz — content-addressed with no version, because the payload set survives many rollouts and would otherwise be re-uploaded on every update run.
--wasm-out <dir> writes each payload under its content-hashed URL segment — COs9e0Kj0ic.wasm, the same identity WhatsApp serves it under — not under its bx handle. That is what a wasm runner keys on, so it can be pointed straight at the directory:
cargo run --release -p whatspec -- update --cache .wa-cache --wasm-out ./wasm
WA_WASM_DIR=./wasm oracle list # e.g. wa-wasm-oracle, which runs the modulesThe neutral artifact is generated/<domain>/index.json, validated by generated/schema/<domain>.schema.json. Point your own codegen at those — the schemas are stable across WhatsApp rollouts (schemaVersion), independent of the ever-changing waVersion.
A Rust consumer can instead use the committed reference modules directly; they depend only on serde and are tree-shakeable (you pay only for what you reference).
- Deterministic: the same bundle always produces byte-identical output (stable sort keys, no incidental ordering).
- C-free: pure-Rust throughout (TLS via rustls + RustCrypto, hashing via
sha2), enforced in CI. - WASM-friendly: the IR crate and the bundle-discovery layer compile to
wasm32, so a browser-based fetcher can reuse them.
whatspec is an independent project and is not affiliated with, endorsed by, or sponsored by WhatsApp LLC or Meta. "WhatsApp" is a trademark of its respective owner and is used here only descriptively, to identify the protocol this tool interoperates with.
MIT © 2025 João Lucas de Oliveira Lopes — see LICENSE.