Unofficial Java 21 library for the WhatsApp Web multi-device protocol, ported from Baileys (TypeScript) and whatsmeow (Go).
Caution
Status: pre-alpha. Multi-month build in progress — protocol surface incomplete (no media, no app-state sync, no persistent Signal store yet). Do not use against your primary WhatsApp account. WhatsApp explicitly forbids unofficial clients; using this library carries account-suspension risk.
This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with WhatsApp or any of its subsidiaries or its affiliates. "WhatsApp" and related marks are registered trademarks of their respective owners.
The maintainers do not condone use of this project in ways that violate WhatsApp's Terms of Service. Use a dedicated test number, never your primary account. No bulk messaging, no stalkerware, no spam.
- JaWa talks to WhatsApp Web directly over WebSocket + Noise XX + libsignal. No Selenium, no Chromium — saves you ~500 MB of RAM.
- Java 21+ only. Built on records, sealed classes, virtual threads, pattern matching.
- Source-of-truth references (Baileys + whatsmeow) live in-tree under
references/for protocol disambiguation.
- JDK 21 (records, sealed classes, virtual threads, pattern matching)
- Gradle (Kotlin DSL)
- BouncyCastle — Curve25519 ECDH, AES-GCM, HKDF-SHA256, HMAC
- signal-protocol-java — XEdDSA sign/verify, X3DH, Double Ratchet, Sender Keys
- protobuf-java (pinned to
3.10.0— see Gotchas) - nv-websocket-client — WebSocket transport
- ZXing — terminal-rendered pairing QR
id.jawa.binary — WhatsApp binary node encoder/decoder
id.jawa.noise — Noise_XX_25519_AESGCM_SHA256 handshake + framed AEAD transport
id.jawa.signal — Signal Protocol integration (pre-keys, sessions, sender keys)
id.jawa.pair — Multi-device pairing (QR + phone-number code)
id.jawa.message — Message stanza send/receive, encoder/decoder, group sender
id.jawa.media — Media upload/download (HKDF-AES-CBC + HMAC) — TODO
id.jawa.appstate — App-state sync (LT-Hash, mutations) — TODO
id.jawa.store — Pluggable session/key persistence
id.jawa.proto — Generated protobuf classes
id.jawa.event — Event listener API (folded into core.JaWaClient.Listener)
id.jawa.core — Client facade + public API
id.jawa.util — JID, base64url, hex, crypto helpers
JaWa is published via JitPack. Every non-SNAPSHOT version bump on
main auto-creates a matching git tag + GitHub Release; JitPack resolves the tag on demand.
Gradle (Kotlin DSL):
repositories {
mavenCentral()
maven("https://jitpack.io")
}
dependencies {
implementation("com.github.jochris:JaWa:v0.0.2")
}Maven:
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
<dependency>
<groupId>com.github.jochris</groupId>
<artifactId>JaWa</artifactId>
<version>v0.0.2</version>
</dependency>Note
logback-classic is runtimeOnly in JaWa's build. Supply your own SLF4J binding
(logback, log4j-slf4j, etc.) in the consumer project.
# QR pairing
./gradlew run -PsessionFile=sessions/myphone.session --console=plain
# Phone-number pairing code (preferred when testing)
./gradlew run -PsessionFile=sessions/myphone.session -Djawa.phone=628xxxxxxxxx --console=plainbuild.gradle.kts forwards every -Djawa.* system property on the Gradle CLI through to
the application JVM. Useful demo knobs: jawa.session, jawa.phone, jawa.target,
jawa.text, jawa.target_group, jawa.list_groups.
- Connecting Account
- Handling Events
- Sending Messages
- Modify Messages
- Media
- Receipts
- Receiving Messages
- WhatsApp IDs / JIDs Explained
- User & Device Queries
- Groups
- Low-level APIs
- Status — what works today
- Gotchas
- Contributing
- License
WhatsApp's multi-device API lets JaWa authenticate as a linked device alongside your phone. Two flows are supported.
Important
Sessions are persisted to a single file via FileAuthStore. The file holds the
Noise static key, Signal identity key, and (post-pair) the signed device identity.
Treat it as a secret. The path sessions/*.session is gitignored by default;
never check one in.
import id.jawa.core.JaWaClient;
import id.jawa.store.FileAuthStore;
import id.jawa.util.QrTerminal;
import java.nio.file.Path;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
FileAuthStore store = new FileAuthStore(Path.of("sessions/mydev.session"));
JaWaClient client = new JaWaClient(store);
client.listener(new JaWaClient.Listener() {
@Override public void onQr(List<String> qrs) {
// Each entry is "ref,noisePub,identityPub,advSecret" — render as QR.
// JaWa ships QrTerminal for ANSI half-block rendering.
System.out.print(QrTerminal.render(qrs.get(0)));
}
@Override public void onPaired(String jid, String pushName, String platform) {
System.out.println("Paired as " + jid);
}
@Override public void onConnected() {
System.out.println("Steady state");
}
});
client.connect();
client.join();
}
}Refs rotate every ~30 s. If the first ref expires before the user scans, the next
onQr callback delivers fresh refs automatically.
Phone-number pairing skips the QR entirely. The user enters an 8-char code in WhatsApp → Settings → Linked Devices → Link with phone number.
Important
Phone number must be in E.164 form without +, spaces, or dashes.
E.g. 628123456789, not +62 812-345-6789.
client.listener(new JaWaClient.Listener() {
@Override public void onQr(List<String> qrs) {
// Server pushed QR refs, but we want pair-code instead — pivot now.
client.requestPairingCode("628123456789", null).whenComplete((code, err) -> {
if (err != null) { err.printStackTrace(); return; }
// Format as XXXX-XXXX for display (the dash is cosmetic).
String pretty = code.substring(0, 4) + "-" + code.substring(4);
System.out.println("Enter on phone: " + pretty);
});
}
@Override public void onPaired(String jid, String pushName, String platform) {
System.out.println("Paired as " + jid);
}
});
client.connect();
client.join();The second argument to requestPairingCode is an optional fixed code (8 Crockford
chars); pass null for a server-generated random one.
You don't want to re-pair every run. FileAuthStore persists creds to a file. On
subsequent connect(), JaWa loads them and skips QR/pair-code entirely.
Path session = Path.of("sessions/mydev.session");
Path signal = Path.of("sessions/mydev.signal"); // optional, see below
FileAuthStore store = new FileAuthStore(session);
JaWaClient client = new JaWaClient(store, signal);
client.connect(); // first run: fresh keys + QR/pair-code
client.connect(); // second run: loaded creds → straight to loginThe second constructor argument is an optional directory for persistent Signal
state. When set, libsignal sessions and one-time pre-keys survive process
restart, so the first message from a previously-paired peer no longer triggers
NoSessionException → retry-receipt round trip. Pass null (or use the
single-arg overload) to keep Signal state in memory.
sessions/
├── mydev.session # AuthCreds: Noise + identity keys
└── mydev.signal/
├── sessions/<base64name>__<dev>.session # one libsignal SessionRecord per peer device
├── prekeys/<id>.prekey # one 64-byte priv||pub per one-time pre-key
└── sender-keys/<b64group>__<b64sender>__<dev>.senderkey # one SenderKeyRecord per (group, sender)
Implement your own AuthStore for SQLite / encrypted keystore / cloud-backed
storage:
public interface AuthStore {
AuthCreds load() throws Exception;
void save(AuthCreds creds) throws Exception;
}JaWa uses a callback Listener (not an event bus). One listener per client; multiple
listeners aren't supported.
public interface Listener {
/** Server sent QR refs. Each entry is "ref,noisePub,identityPub,advSecret". */
default void onQr(List<String> qrStrings) {}
/** Pairing completed; creds were persisted via AuthStore. */
default void onPaired(String jid, String pushName, String platform) {}
/** Steady-state connection up — handshake done, login complete. */
default void onConnected() {}
/** A <message> stanza was successfully decrypted. */
default void onMessage(MessageReceiver.Decoded decoded) {}
/** Inbound <receipt> already parsed (delivery / read / played / retry / sender). */
default void onReceipt(Receipt receipt) {}
/** Inbound stanza after handshake/pairing (everything not handled internally). */
default void onStanza(BinaryNode node) {}
/**
* Server tore the session down. {@code permanent=true} means re-pair is required
* (REVOKED / BANNED / VERSION_OBSOLETE / REPLACED); auto-reconnect is suppressed.
* {@code permanent=false} means transient — auto-reconnect (if enabled) will retry.
*/
default void onTerminated(TerminationReason reason, String detail, boolean permanent) {}
/** Fatal client error. */
default void onError(Throwable t) {}
}TerminationReason values:
| Value | Trigger | Action |
|---|---|---|
REVOKED |
<failure reason="401"> (user unlinked device) |
re-pair |
BANNED |
<failure reason="403"> / co_block |
account-level — appeal to WA |
VERSION_OBSOLETE |
<failure reason="405"> |
bump WaConstants.WA_VERSION |
REPLACED |
<stream:error><conflict type="replaced"/> |
another session took over; restart only one |
TRANSIENT |
5xx or generic stream:error | auto-reconnect retries automatically |
UNKNOWN |
unrecognized reason | treated as permanent (safe default) |
Caution
All listener callbacks fire on JaWa's reader thread. Long work in any callback stalls all inbound stanzas. Offload to a virtual thread:
@Override public void onMessage(MessageReceiver.Decoded d) {
Thread.startVirtualThread(() -> handleHeavyWork(d));
}sendIq / sendText themselves are safe from any thread — they enqueue.
import id.jawa.core.JaWaClient;
import id.jawa.message.MessageReceiver.Decoded;
import id.jawa.store.FileAuthStore;
import java.nio.file.Path;
public class EchoBot {
public static void main(String[] args) throws Exception {
FileAuthStore store = new FileAuthStore(Path.of("sessions/echo.session"));
JaWaClient client = new JaWaClient(store);
client.listener(new JaWaClient.Listener() {
@Override public void onConnected() {
System.out.println("Echo bot online");
}
@Override public void onMessage(Decoded d) {
if (d.text() == null) return; // skip non-text
Thread.startVirtualThread(() -> {
// Reply target: group if present, else bare DM JID.
String to = d.groupJid() != null
? d.groupJid()
: stripDevice(d.senderJid());
String reply = "echo: " + d.text();
if (d.groupJid() != null) {
client.sendGroupText(to, reply);
} else {
client.sendText(to, reply);
}
});
}
});
client.connect();
client.join();
}
/** "628xxx:7@s.whatsapp.net" → "628xxx@s.whatsapp.net". */
static String stripDevice(String jid) {
int at = jid.indexOf('@'), colon = jid.indexOf(':');
return (colon < 0 || colon > at) ? jid
: jid.substring(0, colon) + jid.substring(at);
}
}A more polished base lives in jawa-bot (sibling repo) with command
dispatch, config file, and ping/menu/exec commands.
All send APIs return a CompletableFuture<String> resolving to the outbound message
id (uppercase hex).
// toUser must be a bare JID (no device suffix).
String msgId = client.sendText("628xxxxxxxxx@s.whatsapp.net", "hello from JaWa").join();
System.out.println("sent id=" + msgId);What happens under the hood:
- USync query for the recipient's device list (
client.queryDevicesis reused). - Pre-key bundle fetch for any device we don't yet have a Signal session with.
SessionBuilder(libsignal X3DH) installs sessions for each device.- Per-device encrypt via
SessionCipher→ one<enc>per device. - DSM (
DeviceSentMessage) wrap + fan-out to your own companion devices so the message appears in your own chat history (M5.D.1 + M5.D.2).
String groupJid = "120363xxxxxxxxxxxx@g.us";
String msgId = client.sendGroupText(groupJid, "halo grup").join();Group send is a Sender Keys protocol:
- Plain text is encrypted once with a
GroupCipherkeyed by your sender-key for the group → single<enc type="skmsg">. - Your
SenderKeyDistributionMessage(SKDM) is wrapped in a regularWa.Messageand encrypted per-participant-device withSessionCipher(one<enc type="pkmsg|msg">each) inside<participants>. - Members that don't yet have your sender-key process the SKDM to derive the key for
subsequent
skmsgtraffic.
sendText / sendGroupText are thin wrappers over the lower-level
sendDmMessage / sendGroupMessage overloads that accept any Wa.Message protobuf
directly. The reaction / reply / edit / revoke helpers below use these internally;
they're also useful for proto types JaWa doesn't yet expose a named helper for.
import id.jawa.proto.Wa;
Wa.Message custom = Wa.Message.newBuilder()
.setExtendedTextMessage(Wa.Message.ExtendedTextMessage.newBuilder()
.setText("**bold** isn't a thing, but extendedText carries metadata"))
.build();
client.sendDmMessage("628xxx@s.whatsapp.net", custom).join();
client.sendGroupMessage("120363...@g.us", custom).join();All four helpers route DM vs group automatically based on the chat JID suffix
(@g.us → group, otherwise DM) and return the new outbound message's id.
Attach an emoji to an existing message.
String reactionId = client.sendReaction(
"120363...@g.us", // chat where the target lives
"ACD57CB93B82719FD44D1F231C89F352", // target message id
"224983875903488@lid", // target sender device JID (group only; null for DMs)
/* fromMe = */ false, // true if the target was your own message
"🔥" // emoji (empty string removes)
).join();Native poll bubble with single-select or multi-select voting. Voters' choices
are end-to-end encrypted with a per-poll random 32-byte key (messageSecret
on messageContextInfo) — even the server can't read individual votes.
client.sendPoll(
"120363...@g.us",
"Test poll dari JaWa 🚀", // question
List.of("Yes ✅", "No ❌", "Maybe 🤔"), // 1-12 options
/* selectableCount = */ 1 // 1 = single-select, >1 = multi, 0 = unlimited
).join();Note
{@code selectableCount=1} produces a single-select poll ({@code pollCreationMessageV3}); higher values produce multi-select ({@code pollCreationMessage} V1). Receiver app renders proper radio / checkbox UI accordingly.
Decode incoming votes — a voter's pollUpdateMessage is end-to-end encrypted with
a key derived from the original poll's messageSecret, so the bot has to remember
the secret it sent the poll with:
import id.jawa.message.PollDecoder;
byte[] originalSecret = /* the messageSecret returned when building the poll */;
List<String> originalOptions = List.of("Baileys", "whatsmeow", "JaWaaaa");
@Override public void onMessage(Decoded d) {
var pm = d.message();
if (!pm.hasPollUpdateMessage()) return;
var vote = pm.getPollUpdateMessage().getVote();
List<String> picks = PollDecoder.decryptVote(
vote.getEncPayload().toByteArray(),
vote.getEncIv().toByteArray(),
originalSecret,
d.msgId(), // poll's id (matches vote.pollCreationMessageKey.id)
myBareJid, // bare JID of the poll author (us)
stripDevice(d.senderJid()), // bare JID of the voter
originalOptions
);
System.out.println(d.senderJid() + " voted: " + picks);
}Static map pin at the given coordinates. Optional name / address render as a
caption below the pin.
client.sendLocation(
"628xxx@s.whatsapp.net",
-6.1753924, // latitude
106.8271528, // longitude
"Monas Jakarta", // name — nullable
"Gambir, Central Jakarta" // address — nullable
).join();Share a vCard. Caller supplies the vCard string — minimal shape:
String vcard = """
BEGIN:VCARD
VERSION:3.0
FN:Jane Doe
TEL;type=CELL;type=VOICE;waid=628xxx:+62 8xxx
END:VCARD
""";
client.sendContact("628yyy@s.whatsapp.net", "Jane Doe", vcard).join();The waid= segment in the TEL line is what WhatsApp uses to render the
"Message" button on the contact card — must match the number's WA registration.
Tag one or more users in a text. The receiver app renders each @<number> as
a tappable handle that opens the mentioned user's profile and, in groups, pings
that user.
client.sendTextWithMentions(
"120363...@g.us", // chat (DM bare JID or group @g.us)
"halo @628aaa @628bbb dah pada bangun? 👋", // body — must contain @<number> for each mention
List.of(
"628aaa@s.whatsapp.net", // full bare JIDs, in matching order
"628bbb@s.whatsapp.net"
)
).join();Important
The @<number> in the body and the mentionedBareJids list are independent
wires — WhatsApp uses the list to know who to notify, the body to know where
to highlight. Forget either and the mention silently degrades to plain text.
Send a text reply that quotes an existing message. The recipient's UI renders a
block-quote preview using quotedText.
String replyId = client.sendReply(
"120363...@g.us",
"ok di-reaction 🔥", // new reply text
"ACD57CB93B82719FD44D1F231C89F352", // quoted message id
"224983875903488@lid", // quoted sender (group); null for DM
"test reaction" // preview of the quoted text
).join();Replace the text of a message you previously sent. Subject to WhatsApp's ~15-minute edit window — older messages are rejected server-side.
String editId = client.sendEdit(
"120363...@g.us",
"E1DC8330D43C02D9", // the original message's id
"hello from jawa (edited)"
).join();Modern call-to-action buttons via interactiveMessage.nativeFlowMessage. Up to
three buttons render below the body; mix and match types freely. Receiver app
needs the <biz> companion stanza (JaWa adds it automatically).
import id.jawa.message.MessageEncoder.CtaButton;
import id.jawa.message.MessageEncoder.ListSection;
import id.jawa.message.MessageEncoder.ListRow;
client.sendCtaButtons(
"628xxx@s.whatsapp.net",
"Pick an action:", // body
"JaWa v0.0.3", // footer — nullable
List.of(
CtaButton.url("🌐 Open repo", "https://github.com/jochris/JaWa"),
CtaButton.copy("📋 Copy code", "JAWA-2026"),
CtaButton.call("📞 Call WA", "+62895416602000"),
CtaButton.quickReply("✨ Hello", "qr_hello"), // fires inbound text "qr_hello"
CtaButton.singleSelect("📋 Pick command", List.of(
new ListSection("Commands", List.of(
new ListRow("ping", "Ping bot", "Cek bot hidup"),
new ListRow("info", "Server info", "Show server status")
)),
new ListSection("Owner", List.of(
new ListRow("exec", "Exec shell", "Owner-only")
))
))
)
).join();Note
cta_url opens the URL in the user's browser, cta_copy copies a code to
clipboard, cta_call dials the number, quick_reply fires an inbound text
whose body is the id you set, and single_select opens a sheet listing the
sections / rows you pass (acts like a mini ListMessage that can be mixed with
other CTA buttons in the same bubble). All five are confirmed working on
regular (non-business) accounts as of June 2026.
Up to 3 buttons rendered below a body text. When the receiver taps one, you'll
get back an inbound buttonsResponseMessage carrying the matching buttonId.
import id.jawa.message.MessageEncoder.QuickReplyButton;
client.sendButtonsMessage(
"628xxx@s.whatsapp.net",
"Pick one:", // body
"JaWa v0.0.3", // footer — nullable
List.of(
new QuickReplyButton("yes", "Yes ✅"),
new QuickReplyButton("no", "No ❌"),
new QuickReplyButton("idk", "Maybe 🤔")
)
).join();Note
JaWa automatically appends the <biz> companion stanza WhatsApp's server
expects alongside any buttons / list / interactive payload. Without that node
the receiver silently drops the interactive surface and the message renders
blank — so JaWa does it for you. Implementation lives in id.jawa.message.BizNode.
Horizontally-scrollable cards, each with its own image header, caption, footer, and button set. JaWa uploads every card's media in parallel, then assembles the proto in one shot.
import id.jawa.core.JaWaClient.CarouselCardInput;
byte[] img1 = Files.readAllBytes(Path.of("card1.jpg"));
byte[] img2 = Files.readAllBytes(Path.of("card2.jpg"));
byte[] img3 = Files.readAllBytes(Path.of("card3.jpg"));
client.sendCarousel("628xxx@s.whatsapp.net", null, null, List.of(
new CarouselCardInput("Card 1", "Caption 1", img1, "image/jpeg", null, List.of(
CtaButton.url("🌐 Visit", "https://github.com/jochris/JaWa")
)),
new CarouselCardInput("Card 2", "Caption 2", img2, "image/jpeg", null, List.of(
CtaButton.copy("📋 Copy", "JAWA-CARD2")
)),
new CarouselCardInput("Card 3", "Caption 3", img3, "image/jpeg", null, List.of(
CtaButton.quickReply("⭐ Star", "qr_star")
))
)).join();Each card requires non-empty mediaBytes — WhatsApp rejects cards without a media
header. Use mimetype starting with "video/" to upload a video card instead.
A dropdown of selectable rows grouped into sections. Receiver gets a body bubble with a "tap to open" target that opens a sheet listing each section's rows.
import id.jawa.message.MessageEncoder.ListSection;
import id.jawa.message.MessageEncoder.ListRow;
client.sendListMessage(
"628xxx@s.whatsapp.net",
"JaWa Menu", // title
"Pilih command:", // body
"JaWa v0.0.3", // footer — nullable
"📋 Open menu", // buttonText (the tap target)
List.of(
new ListSection("Commands", List.of(
new ListRow("ping", "Ping bot", "Cek bot hidup"),
new ListRow("info", "Server info", "Show server status"),
new ListRow("menu", "Show menu", null)
)),
new ListSection("Owner", List.of(
new ListRow("exec", "Exec shell", "Owner-only command")
))
)
).join();When the user picks a row, you'll get back a listResponseMessage carrying the
chosen rowId ("ping" / "info" / "menu" / "exec" in the example above).
Replaces the original with WhatsApp's "This message was deleted" placeholder for every participant.
// revoke your own message
client.sendRevoke(
"120363...@g.us",
"E1DC8330D43C02D9",
/* targetParticipant = */ null,
/* fromMe = */ true
).join();
// admin revoking someone else's message in a group you administer
client.sendRevoke(
"120363...@g.us",
"ACD57CB93B82719FD44D1F231C89F352",
"224983875903488@lid",
/* fromMe = */ false
).join();WhatsApp media (images, videos, audio, documents) is end-to-end encrypted with a
per-message random 32-byte mediaKey. JaWa handles the crypto, the
{@code media_conn} auth refresh, and the HTTPS upload; the {@code mediaKey} rides
inside the Signal-encrypted Wa.Message envelope so only the recipient device can
derive the AES-CBC + HMAC keys.
byte[] jpeg = Files.readAllBytes(Path.of("photo.jpg"));
String msgId = client.sendImage(
"120363...@g.us", // chat (DM bare JID or group @g.us)
jpeg,
"image/jpeg",
"look at this 📸" // caption — nullable
).join();byte[] mp4 = Files.readAllBytes(Path.of("clip.mp4"));
client.sendVideo(
"120363...@g.us",
mp4,
"video/mp4",
"first JaWa video send", // caption — nullable
/* seconds = */ 0, // 0 if unknown — proto field stays unset
/* width = */ 0,
/* height = */ 0
).join();byte[] opus = Files.readAllBytes(Path.of("voice.ogg"));
client.sendAudio(
"120363...@g.us",
opus,
"audio/ogg; codecs=opus",
/* seconds = */ 5,
/* ptt = */ true // true = voice-note bubble, false = regular audio
).join();byte[] pdf = Files.readAllBytes(Path.of("invoice.pdf"));
client.sendDocument(
"628xxx@s.whatsapp.net",
pdf,
"application/pdf",
"invoice.pdf", // fileName — what the recipient sees as the label
"Invoice June 2026" // title — optional richer display
).join();Under the hood:
- Generate a fresh random 32-byte
mediaKey. MediaCrypto.encrypt— HKDF-expand the key into iv/cipherKey/macKey, AES-CBC encrypt the bytes, append a 10-byte truncated HMAC.refreshMediaConn—<iq xmlns="w:m" type="set"><media_conn/></iq>to get the auth token + host list (cached until TTL expires).MediaUploader.upload— HTTPS POST<ciphertext>||<mac10>tohttps://<host>/mms/image/<token>?auth=...&token=....- Build
Wa.Message{imageMessage{url, directPath, mediaKey, fileSha256, fileEncSha256, fileLength, mimetype, caption}}. - Route through
sendDmMessage/sendGroupMessage— Signal-encrypted per recipient device, same pipeline as text.
For experimentation or sending media types that don't have a named helper yet
(video / audio / document — they share the same crypto + upload, only the
MediaType info string and the Wa.Message field differ):
import id.jawa.media.MediaCrypto;
import id.jawa.media.MediaUploader;
byte[] mediaKey = id.jawa.util.Bytes.random(32);
var enc = MediaCrypto.encrypt(rawBytes, mediaKey, MediaCrypto.MediaType.VIDEO);
var mediaConn = client.refreshMediaConn().join();
var upload = MediaUploader.upload(mediaConn, enc, MediaCrypto.MediaType.VIDEO);
// Build your own Wa.Message.VideoMessage with upload.url() + upload.directPath(),
// mediaKey, enc.fileSha256(), enc.fileEncSha256(), etc., then:
client.sendGroupMessage(groupJid, customWaMessage).join();Image / video / audio that the recipient can open exactly once before WhatsApp
purges the media from their chat. Same upload pipeline as the regular sendImage
/ sendVideo / sendAudio; the inner message is wrapped in
viewOnceMessageV2 (image / video) or viewOnceMessageV2Extension (audio).
client.sendImageViewOnce(chatJid, jpeg, "image/jpeg", "look — once 👁️").join();
client.sendVideoViewOnce(chatJid, mp4, "video/mp4", null, 0, 0, 0).join();
client.sendAudioViewOnce(chatJid, opus, "audio/ogg; codecs=opus", 5, /* ptt = */ true).join();Wa.Message.imageMessage (and the video / audio / document siblings) carries a
url, directPath, mediaKey, and fileEncSha256. Pass those to
downloadMedia and JaWa handles HTTPS GET, envelope integrity check, MAC
verification, and AES-CBC decrypt:
@Override public void onMessage(MessageReceiver.Decoded d) {
if (d.message() == null || !d.message().hasImageMessage()) return;
var img = d.message().getImageMessage();
client.downloadMedia(
img.getUrl(), // prefer this when set
img.getDirectPath(), // fallback via mediaConn host
img.getMediaKey().toByteArray(),
img.getFileEncSha256().toByteArray(),
MediaCrypto.MediaType.IMAGE
).thenAccept(plaintext ->
Files.write(Path.of("downloads/" + d.msgId() + ".jpg"), plaintext)
);
}downloadByUrl is preferred when url is set (one round-trip); downloadByDirectPath
fetches a fresh mediaConn then tries each host until one returns 200.
WhatsApp's lifecycle ticks (delivered, read, played) ride on <receipt> stanzas.
JaWa auto-acks every inbound receipt to keep the offline queue clear; consumers
that care about peer-side state hook into them via Listener.onReceipt.
// DM: a peer sent us a message we just rendered
client.sendReadReceipt("628xxx@s.whatsapp.net", msgId, /* senderJid = */ null);
// Group: someone in the group sent a message — senderJid is the participant device JID
client.sendReadReceipt("120363...@g.us", msgId, "224983875903488@lid");
// Voice note we just played back
client.sendPlayedReceipt(chatJid, msgId, senderJid);
// Batched — first id in the receipt attr, the rest under <list><item id=.../>...
client.sendReadReceiptBatch(chatJid, List.of(id1, id2, id3), senderJid);@Override public void onReceipt(Receipt r) {
String kind = r.type() == null ? "delivered" : r.type(); // null = delivery (one tick)
System.out.println(kind + " for " + r.msgIds() + " in " + r.chatJid()
+ (r.senderJid() != null ? " by " + r.senderJid() : ""));
}Possible Receipt.type() values: null (delivery), "read", "played",
"retry" (peer couldn't decrypt — JaWa already re-encrypts automatically),
"server-error", "sender" (server confirming our group fan-out).
Implement Listener.onMessage(MessageReceiver.Decoded):
public record Decoded(
String senderJid, // sender's device-specific JID (or participant for groups)
String groupJid, // group JID for group messages, null for DMs
String msgId, // <message id=> value
String encType, // "pkmsg" | "msg" | "skmsg"
Wa.Message message, // decrypted, DSM-unwrapped Wa.Message protobuf
String text, // .conversation or .extendedTextMessage.text, else null
InteractiveResponse interactive // populated when the user tapped a button/row, else null
) {}
public record InteractiveResponse(
String kind, // "buttons" | "list" | "native_flow"
String selectedId, // buttonId / rowId / native-flow button name
String paramsJson, // raw paramsJson for native_flow taps (parse for the user's id), else null
String displayText // visible echo of what was tapped
) {}Quick dispatch from a bot:
@Override public void onMessage(Decoded d) {
if (d.interactive() != null) {
var ir = d.interactive();
switch (ir.kind()) {
case "buttons" -> route(d.senderJid(), "button:" + ir.selectedId());
case "list" -> route(d.senderJid(), "row:" + ir.selectedId());
case "native_flow" -> {
// e.g. quick_reply → paramsJson = {"id":"qr_yes"}
route(d.senderJid(), "flow:" + ir.selectedId() + " " + ir.paramsJson());
}
}
return;
}
if (d.text() != null) {
route(d.senderJid(), d.text());
}
}JaWa handles decrypt + ack + delivery receipt automatically. On decrypt failure,
a <receipt type="retry"> is sent (M5.E.2) so the peer re-encrypts with fresh keys.
@Override public void onMessage(Decoded d) {
if (d.text() != null) {
System.out.println("DM/group text from " + d.senderJid() + ": " + d.text());
} else {
System.out.println("Non-text message from " + d.senderJid()
+ " (encType=" + d.encType() + ")");
}
}JaWa exposes id.jawa.util.Jid for parsing.
| JID form | Use |
|---|---|
[country][number]@s.whatsapp.net |
Bare DM user (no device) |
[country][number]:[device]@s.whatsapp.net |
Specific device of a user |
[number]-[ts]@g.us |
Legacy group |
120363[xx]...@g.us |
Modern (Community-era) group |
[number]@lid / [number]:[device]@lid |
Linked-ID (LID, alternate identity space) |
status@broadcast |
Status / stories |
client.sendText(...)expects a bare DM JID (no:devicesuffix). Strip it withJid.parse(s).bare()or the inline helper from the echo bot above.client.sendGroupText(...)expects a@g.usJID.
USync query — what devices does this user have?
String userBare = "628xxxxxxxxx@s.whatsapp.net";
client.queryDevices(List.of(userBare)).thenAccept(map -> {
var devices = map.getOrDefault(userBare, List.of());
for (var d : devices) {
System.out.println(" device id=" + d.id() + " keyIndex=" + d.keyIndex()
+ (d.hosted() ? " (hosted)" : ""));
}
});Pre-warm libsignal sessions to every device of a user. Useful before a send so the first message doesn't pay the X3DH cost.
client.bootstrapSessions("628xxxxxxxxx@s.whatsapp.net").thenAccept(addresses -> {
System.out.println("Installed " + addresses.size() + " Signal session(s)");
addresses.forEach(System.out::println);
});Returns groups you participate in (<iq xmlns="w:g2"><participating/></iq>).
client.queryJoinedGroups().thenAccept(groups -> {
for (var g : groups) {
System.out.println(g.jid()
+ " subject=\"" + g.subject() + "\""
+ " participants=" + g.participantJids().size());
}
});String newGroupJid = client.createGroup(
"My Cool Group",
List.of("628aaa@s.whatsapp.net", "628bbb@s.whatsapp.net")
).join();Don't include your own JID in the participants — the server adds it implicitly.
Subject limit is 25 characters server-side; longer will reject with 406 not acceptable.
client.leaveGroup("120363...@g.us").join();All four actions go through the same API; pass the right ParticipantChange enum.
Requires admin rights on the target group.
import id.jawa.message.GroupAction.ParticipantChange;
client.updateGroupParticipants(groupJid, ParticipantChange.ADD, List.of("628xxx@s.whatsapp.net")).join();
client.updateGroupParticipants(groupJid, ParticipantChange.REMOVE, List.of("628yyy@s.whatsapp.net")).join();
client.updateGroupParticipants(groupJid, ParticipantChange.PROMOTE, List.of("628zzz@s.whatsapp.net")).join();
client.updateGroupParticipants(groupJid, ParticipantChange.DEMOTE, List.of("628zzz@s.whatsapp.net")).join();client.setGroupSubject(groupJid, "New group name").join();// set
client.setGroupDescription(groupJid, "Welcome to the chat", /* previousId = */ null).join();
// clear
client.setGroupDescription(groupJid, null, /* previousId = */ "<current-topic-id>").join();previousId is the current description's id (you track it from a prior setGroupDescription
call or fetch from group metadata). Pass null when there is no prior description.
For protocol experimentation or features JaWa doesn't expose yet.
import id.jawa.binary.BinaryNode;
// <chatstate from="..."><composing/></chatstate>
client.send(new BinaryNode("chatstate",
Map.of("to", "628xxx@s.whatsapp.net"),
List.of(BinaryNode.of("composing"))));Note
<presence type="available"> is emitted automatically after every successful
login (with creds.pushName as the display name), so peers see the device as
online and the server delivers new <message> stanzas. You don't need to send
it manually.
sendIqAsync returns a future that completes when the matching <iq type="result|error">
arrives. IQ IDs are 16-hex-char random; JaWa correlates the response by id.
BinaryNode iq = new BinaryNode("iq", Map.of(
"id", "1234567890abcdef",
"type", "get",
"xmlns", "w:profile:picture",
"to", "628xxxxxxxxx@s.whatsapp.net"
), List.of(BinaryNode.of("picture", Map.of("type", "image"))));
client.sendIqAsync(iq).thenAccept(response -> {
System.out.println("got response: " + response);
});Warning
IQ callbacks fire on the reader thread. Don't block in them.
- M0 — Gradle skeleton, JDK 21 toolchain, full dep graph
- M1 — Binary Node codec (encode/decode, 4 JID variants, packed nibble/hex, token dictionary) — 19 unit tests
- M2 — Noise XX handshake + WebSocket transport, server CertChain validation
- M3 — ClientPayload (register + login)
- M4 — QR pairing (live-verified end-to-end: scan → ADV chain verify → creds persist → login)
- M4.5 — Phone-number pairing code (PBKDF2 + AES-CTR wrap, X25519 × 2 + HKDF advSecret derivation; live-verified)
- M4.5.1 —
companion_hellowire-value fix (platform_id="1", canonical display, nibble-packed nonce) + steady-state hardening (w:p keepalive, per-stanza error containment) - M4.5.2 — Post-pair auto-reconnect to login mode (
FrameSocketdisconnect sentinel +JaWaClientreconnect handler; closes the 401-revoke window)
- M4.5.1 —
- M5 — Send + receive text 1-on-1 (live-verified end-to-end against a real account: send, decode inbound text, ack + delivery receipt)
- Pre-key upload (
<iq xmlns=encrypt>) - USync device list query
- Signal session bootstrap (libsignal X3DH)
- Encrypt + send
<message> - M5.D.1 —
DeviceSentMessagewrap for own-companion devices (fixes silent-drop on send-to-self) - M5.D.2 — Fan out outgoing message to own companion devices on non-self send (sender's own phone now sees the outgoing message in chat history)
- M5.E — Receive + decrypt incoming
<enc>(MessageReceiver+<ack>+ delivery<receipt>+ active-mode IQ on login) - M5.E.1 — Seed
creds.signedPreKeyintoJaWaProtocolStore(unblocks first-contactpkmsgdecrypt) - M5.E.2 — Retry receipt with
<retry count>+<registration>reg-id so peer re-encrypts on decrypt failure - M5.E.3 — Mirror generated one-time pre-keys into the libsignal
protocolStore(was only in the rawSignalKeyStore)
- Pre-key upload (
- M6 — Receipts, retries, ack flow
- auto-ack inbound
<notification>and<receipt>(core fix, prevents offline queue stall) -
<receipt type="retry">for inbound decrypt failures (M5.E.2) -
sendReadReceipt/sendPlayedReceipt/sendReadReceiptBatchpublic APIs -
Listener.onReceipt(Receipt)callback so consumers see peer-side delivery / read / played lifecycle
- auto-ack inbound
- M7 — Group messaging (Sender Keys distribution + skmsg)
- M7 (recv) — group
skmsgdecrypt +SenderKeyDistributionMessageprocessing on inbound - M7.G1 — query joined groups via
<iq xmlns="w:g2"><participating/></iq> - M7.G2 — send text message to a group (per-device SKDM fan-out + single
<enc type=skmsg>) - M7.G3 — lifecycle ops via
<iq xmlns="w:g2" type="set">:createGroup,leaveGroup,updateGroupParticipants(add / remove / promote / demote),setGroupSubject,setGroupDescription
- M7 (recv) — group
- M8 — Media upload/download (HKDF-AES-CBC + HMAC, mediaConn)
- M8.A — media crypto primitives (AES-CBC + HKDF expand → iv/cipherKey/macKey + truncated HMAC, plus type-isolated info strings)
- M8.B —
<iq xmlns="w:m"><media_conn/></iq>query + TTL-cachedMediaConnrecord - M8.C — HTTPS upload to
https://<host>/mms/<type>/<token>via JDK HttpClient - M8.D —
imageMessageproto +sendImage(chatJid, bytes, mimetype, caption)API (DM + group) - M8.E —
videoMessage/audioMessage/documentMessageproto builders +sendVideo/sendAudio/sendDocumentAPIs (all reuse the M8.A-D crypto + upload) - M8.F — receive-side
MediaDownloader(URL or directPath via mediaConn host fan-out, envelope SHA-256 check, MAC verify, AES-CBC decrypt) +JaWaClient.downloadMediaasync API
- M9 — App-state sync (LT-Hash, mutations, contact list, chat sync)
-
id.jawa.appstate.LtHash— pointwise summing 128-byte state, add / subtract digest pairs via HKDF-expanded 64-byte chunks; theWhatsApp Patch Integrityinstance is what verifies a collection's mutations agree with the server's view (4 unit tests) -
AppStateKey+Expanded— 32-byte shared key + HKDF-SHA256 expansion into the 5 sub-keys (index / valueEnc / valueMAC / snapshotMAC / patchMAC) -
PatchNameenum — every WhatsApp collection (critical_block,critical_unblock_low,regular_low,regular_high,regular) -
FileAppStateKeyStorage— per-key file under<signalDir>/appstate-keys/, atomic write-through. Companion devices only receive these once on first online; persistence is non-optional. - Inbound capture —
JaWaClient.captureAppStateKeysFromwatches every decoded inbound message for aprotocolMessage.appStateSyncKeyShareand persists each key. FiresListener.onAppStateKeysReceived(count). -
AppStateSyncQuery.buildSnapshotRequest/buildPatchRequest—<iq xmlns="w:sync:app:state" type="set"><sync><collection name=... return_snapshot=... version=.../></sync></iq>;parseResponsereturnsPatchList(name, hasMore, snapshot, patches, externalSnapshot). -
AppStateProcessor.decode(SyncdMutation)— pulls the matching key from storage, splits the value blob into iv + ciphertext + valueMAC, optionally verifies the MAC (HMAC-SHA512[:32] overop || keyId || ct || keyIdLen), AES-CBC decrypts, and unmarshals intoWa.SyncActionData.decodeSnapshot/decodePatchiterate the corresponding container. -
JaWaClient.requestAppStateSync(PatchName, fromVersion)— fires the IQ, decodes the response end-to-end, dispatchesListener.onAppStateMutations(name, mutations). - Open follow-up: auto-request missing keys via
protocolMessage.appStateSyncKeyRequestwhen a sync surfaces a key id we don't have; download external snapshot blobs via mediaConn; persist per-collection LT-Hash state to feed back into the IQ. Until then, freshly-paired devices may surface "missing app-state key id=..." warnings on the first sync until the primary phone shares the keys naturally.
-
- M10 — Reconnect, error handling, ban detection
- M10.A — auto-reconnect on unexpected WS close with exponential back-off (2s → 60s cap);
<failure>stanzas (e.g.reason=401) flag the session terminal so a revoked device doesn't loop forever.client.autoReconnect(false)opts out. - M10.B — categorised
Listener.onTerminated(TerminationReason, detail, permanent):REVOKED(401),BANNED(403/co_block),VERSION_OBSOLETE(405),REPLACED(<stream:error><conflict type="replaced"/>),TRANSIENT(5xx, generic stream:error),UNKNOWN. Permanent reasons suppress auto-reconnect; transient ones let the back-off ladder keep trying.
- M10.A — auto-reconnect on unexpected WS close with exponential back-off (2s → 60s cap);
- M11 — Misc message types (reactions, edits, polls, replies, lists)
- M11.A — send reaction to a message (DM + group)
- M11.B — send quoted reply (DM + group)
- M11.C — edit a previously-sent message (DM + group)
- M11.D — revoke (delete-for-everyone) a message (DM + group)
- M11.E.A —
sendListMessage(dropdown of selectable rows, multi-section); receiver renders proper list sheet - M11.E.B —
sendButtonsMessage(quick-reply buttons, max 3); receiver renders proper button bubbles - M11.E.biz — append
<biz>stanza node alongside<message>when payload is buttons/list/interactive/template — without this the receiver app silently drops the interactive surface - M11.E.C — CTA buttons via
interactiveMessage.nativeFlowMessage:CtaButton.url,.copy,.call,.quickReply,.singleSelect— confirmed rendering on regular accounts - M11.F —
sendTextWithMentions— tag users viaextendedTextMessage.contextInfo.mentionedJid; recipient renders each@<number>in the body as a tappable handle and pings the mentioned user in groups - M11.E.D —
sendPoll— native poll bubble viapollCreationMessageV3(single-select) orpollCreationMessageV1 (multi); per-poll random 32-byte vote encryption key ridden inmessageContextInfo.messageSecret - M11.E.D.recv —
PollDecoder.decryptVote— HKDF-derived AES-256-GCM decrypt of an inboundpollUpdateMessage.vote.encPayload; matches the 32-byte SHA-256 hashes in the plaintext back to the original option names - M11.G — view-once:
sendImageViewOnce/sendVideoViewOnce/sendAudioViewOncewrap the inner media inviewOnceMessageV2(image/video) orviewOnceMessageV2Extension(audio); receiver can open the media exactly once before it's purged - M11.H — receive interactive responses:
Decoded.interactivecarries the parsedbuttonsResponseMessage/listResponseMessage/interactiveResponseMessageso bots can react to button / row / native-flow taps - M11.I —
sendLocation(static map pin vialocationMessage) +sendContact(vCard viacontactMessage) - M11.E.G — carousel with media header:
sendCarousel(chatJid, body?, footer?, List<CarouselCardInput>)uploads every card's image / video in parallel, builds the innerimageMessage/videoMessagefor each card header, and ships the wholeinteractiveMessage.carouselMessagein one go. Live-verified rendering on regular accounts with three JPEG cards.
- M12 — Pluggable storage backends (in-memory, file, SQLite)
- M12.A — file-backed libsignal
SessionStore(sessions survive restart, noNoSessionException/retry-receipt churn for previously-paired peers) - M12.B — file-backed JaWa pre-key store (one-time pre-keys survive restart, re-mirrored into libsignal on connect)
- M12.C — file-backed sender-key store (group sender-chain state survives restart, no SKDM re-distribution on first outbound group message after reconnect)
- M12.D —
FilePreKeyStorage.pruneKeepHighest(n); JaWaClient auto-prunes to the 600 highest pre-key ids on connect, capping disk usage at ~20 sessions worth and stopping the startup-delay-then-server-timeout failure seen at ~7000+ accumulated keys
- M12.A — file-backed libsignal
- core —
<presence type="available">on login + ack<notification>/<receipt>(without these the server treats the device as offline and stops delivering<message>stanzas)
- Protobuf is pinned to 3.10.0 via
resolutionStrategy.signal-protocol-java:2.8.1shipsprotobuf-javalite:3.10.0, which is incompatible with newer protobuf-java schema parsing. Do not bump protobuf without end-to-end testing libsignal. - License is GPL-3.0-or-later, non-negotiable — cascades from
signal-protocol-java. Every new source file must start with// SPDX-License-Identifier: GPL-3.0-or-later. WA_VERSION— if the server starts rejecting with stream errors mentioning an obsolete client, bump it inWaConstantsfrom the latest BaileysDefaults/baileys-version.jsonor whatsmeowstore.WAVersion.- Reader-thread reentrancy —
sendText/sendIqare safe from any thread (they enqueue), butListener.onMessage/onStanza/IQ-callbacks run on the reader. Long work there stalls all inbound traffic — offload to a virtual thread. creds.account == nullis the "is pairing" signal — used byconnect()to choose register vs login payload, and byrequestPairingCodeto refuse if already paired. Don't repurpose that field.
Issues / PRs / architectural feedback welcome. Run ./gradlew build before
submitting; CI will block merges with failing tests.
If you're porting a protocol feature, always read the upstream first. Baileys and whatsmeow disagree about details often enough that picking the right reference matters. The pattern that's worked so far: implement against whatsmeow's Go (cleanest API), cross-check Baileys for any field the Go side omits.
GPL-3.0-or-later. See LICENSE.
Copyleft is inherited from signal-protocol-java. Any project that depends on JaWa
must comply with GPL-3.0-or-later terms.