Sending WhatsApp premium (Lottie) stickers
How to send an animated WhatsApp sticker — including a premium one, the kind
with the full-bleed secondary animation — and how to send a whole user-created
pack, over the WhatsApp multi-device protocol. This documents the method and the
exact message layout; the builders are in rust/media_stickers.rs
and a runnable sender in rust/sendsticker.rs.
It assumes you already have a linked-device session (a working WhatsApp client). Nothing here forges a signature or pulls a paid asset — it reposts stickers you already have, byte for byte.
The .was file
An animated sticker is a zip (mime application/was):
animation/animation.json the sticker — a 512×512 Lottie
animation/animation_secondary.json the premium effect — a 2048×2048 Lottie, drawn at 2× over it
animation/animation.json.trust_token ES256 JWT (kid 196) over animation.json's sha256
animation/animation_secondary.json.trust_token the same, over the secondary
animation/animation.json.overridden_metadata unsigned: pack id, emojis, accessibility text
Each .trust_token is a JWT whose payload is
{"sticker_file_type":"lottie_json","sticker_file_trusted_origin":"whatsapp","sticker_file_sha256":"<b64url sha256 of that one file>"},
signed by Meta with a P‑256 key the client has baked in (key id 196). The token
covers only its own file's bytes, and nothing ties the two tokens together —
so the main layer of one sticker and the secondary (effect) of another can be
combined into a new .was, and both tokens still verify. That is all "mixing" is:
new .was = A/animation.json + B/animation_secondary.json + both original tokens + A's metadata
See python/mix.py. No json is ever rewritten.
What makes it premium
Two independent things:
- The effect itself is the signed
animation_secondary.json. The client validates it locally (JWT signature +sticker_file_sha256+trusted_origin) and plays it. It needs no server call. - Whether the client plays it is gated on the sticker being marked premium.
For a single sticker that is the
stickerMessage.premiumfield (= 1). It is also carried inside the.was:animation.json's ownmetadatablock has"premium": 1, which the client reads when a sticker is loaded from the tray or a pack. So a sticker whose.wassays premium, with a valid signed secondary, animates whether it is sent singly or inside a user-made pack.
Sending a single sticker
Message { stickerMessage = 26 }, fields (WhatsApp Android 2.26.35.71):
| field | # | value |
|---|---|---|
| url | 1 | upload URL |
| fileSha256 | 2 | sha256 of the .was |
| fileEncSha256 | 3 | sha256 of the encrypted blob |
| mediaKey | 4 | 32 random bytes |
| mimetype | 5 | application/was |
| height / width | 6 / 7 | 512 / 512 |
| directPath | 8 | from the upload response |
| fileLength | 9 | plaintext length |
| mediaKeyTimestamp | 10 | now |
| isAnimated | 13 | 1 |
| stickerSentTs | 18 | now (ms) |
| isLottie | 21 | 1 |
| premium | 24 | 1 |
Flow (see sendsticker.rs): encrypt the .was with the image media key
(HKDF-SHA256 of a random mediaKey under info "WhatsApp Image Keys",
AES‑256‑CBC + a 10‑byte truncated HMAC), request media_conn, POST the encrypted
blob to https://<host>/mms/sticker/<token>?auth=…, build the stickerMessage,
Signal‑encrypt it to each of the recipient's devices (your own other devices get
it wrapped in a deviceSentMessage), and send the <message type="media">
stanza with enc … mediatype="sticker".
Sending a pack
Message { stickerPackMessage = 86 }. The stickers travel as a zip
(<sha>.was entries + a tray png), uploaded as mms type sticker-pack, with a
252×252 preview uploaded as thumbnail-sticker-pack under the same media key.
The pack message has no premium field — it does not need one; each sticker's
premium-ness comes from its own .was (above). Key fields: stickerPackId(1),
name(2), publisher(3), stickers(4, each: fileName/isAnimated/emojis/label/isLottie/mimetype),
fileLength(5), fileSha256(6), fileEncSha256(7), mediaKey(8), directPath(9),
trayIconFileName(14), thumbnail{DirectPath,Sha256,EncSha256,Height,Width}(15–19),
imageDataHash(20), stickerPackSize(21), stickerPackOrigin(22 = 2, USER_CREATED).
Build the zip + manifest with python/build_pack.py; the
same sendsticker path sends it with mediatype="sticker_pack".
Files
rust/media_stickers.rs— media encryption, the mms upload, and thestickerMessage/stickerPackMessagebuilders.rust/sendsticker.rs— a runnable sender (single sticker or--pack <dir>), over a saved session.python/mix.py— combine one sticker's layer with another's effect.python/build_pack.py— assemble a sendable pack folder (zip + tray + 252px preview + manifest) from a list of.wasfiles.