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.1")
}Maven:
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
<dependency>
<groupId>com.github.jochris</groupId>
<artifactId>JaWa</artifactId>
<version>v0.0.1</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
- Receiving Messages
- WhatsApp IDs / JIDs Explained
- User & Device Queries
- Groups
- Low-level APIs
- Status — what works today
- Gotchas
- Docs
- 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 stanza after handshake/pairing (everything not handled internally). */
default void onStanza(BinaryNode node) {}
/** Fatal client error. */
default void onError(Throwable t) {}
}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();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();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();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.
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
) {}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());
}
});Note
Group create / add-remove / promote-demote / subject change are not yet implemented. Group send + receive + list works; lifecycle ops land in a future milestone.
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
- 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 (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)
- M10 — Reconnect, error handling, ban detection
- 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)
- 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.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.
docs/protocol/01-transport-noise.md— Noise handshake + WebSocket specdocs/protocol/02-binary-node.md— WA binary encoding spec- More specs land as features ship.
For protocol details not yet formalised here, the source of truth is the upstream
code in references/:
- WhiskeySockets/Baileys (TypeScript)
- tulir/whatsmeow (Go)
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.