Standalone, unofficial, fully-featured WhatsApp Web, Desktop, Mobile and Cloud API for JVM languages
Cobalt is an independent, unofficial project. It is not affiliated with, authorized, maintained, sponsored, or endorsed by WhatsApp LLC or Meta Platforms, Inc., or any of their affiliates. All product and company names are trademarks of their respective holders.
The reverse-engineered Linked transport talks to WhatsApp's private protocol. WhatsApp does not support third-party clients, so this transport may breach its Terms of Service, lead to account restrictions, or stop working at any time. Good-faith use of your own account carries little risk, but sending unsolicited or automated bulk messages, especially from new numbers, is exactly what WhatsApp's anti-spam systems act on. Use Cobalt responsibly and at your own risk. For commercial messaging, prefer the official Cloud API.
Cobalt lets a JVM application be a WhatsApp client. It supports two completely different ways of reaching WhatsApp, behind one shared message model:
- The Linked client is a clean-room reimplementation of the real WhatsApp Web, Desktop and Mobile apps. No browser, no Selenium, no bridge process.
- The Cloud client drives Meta's official, hosted WhatsApp Cloud API over plain HTTPS, with a built-in webhook server for inbound traffic.
IMPORTANT: Cobalt is pre-
1.0: expect breaking changes between releases until the API is frozen.
Maintaining Cobalt is an enormous amount of work which I do in my free time because I really like reverse engineering.
If you like my work, you can support me through GitHub Sponsors.
Cobalt welcomes contributions from anyone. Reimplementing WhatsApp across Web, Desktop, Mobile and the Cloud API is an enormous job: there are always features to add, behaviours to verify against the real clients, and platforms to keep up with. You do not need to be a reverse engineer to help. Performance is an absolute priority for Cobalt; full feature coverage and stability are priorities as well.
AI-generated code is permitted, but the person opening the PR is accountable for every line in it, and it will be reviewed in depth against Cobalt's priorities like any other change.
No AI-generated commit messages, issue bodies, PR descriptions, discussion posts, or review comments. Human-to-human interactions must remain in natural language: I prefer to talk with a person, and I would rather read confused human text than an LLM's. It's also recommended to not translate your messages into other languages using translators or LLMs, as there is a concrete chance the person on the other end understands your language, even if it's not their first language, and if they don't or prefer a translation, they can pick their own software to translate the message.
Some parts of Cobalt are auto-generated by deterministic extractors that run against the real WhatsApp Web client, so they can be regenerated whenever WhatsApp ships a new revision:
- Protobuf definitions: tools/web/proto-extractor extracts every protobuf message and enum from WhatsApp Web's JavaScript chunks and WASM binaries into a single
whatsapp.proto, the reference spec used to keep the hand-written domain classes in modules/model complete and up to date. - AB Props: tools/web/ab-props-codegen extracts the A/B feature-flag definitions (name, type, default value) from a live bundle and regenerates the
ABPropconstants in modules/model. - GraphQL request/response models: tools/web/graphql-extractor dumps the spec of every GraphQL operation (id, kind, variables, transport) from the persisted-query layer; the typed request and response models in modules/lib are validated against it.
- WAM (WhatsApp Metrics): tools/web/wam-codegen extracts the telemetry event and enum schemas from the WAM runtime and regenerates the per-event Java classes consumed by the modules/wam-core annotation processor.
Every extractor is a Node project: npm install, then npm run build, then npm start.
I understand that very few people have in-depth reverse-engineering skills across so many platforms, but I'd still want people who have good problem-solving abilities to be able to contribute. That is why I built the Cobalt MCP (tools/web/mcp-server).
The first time you want to use the MCP, you need to build it:
cd tools/web/mcp-server
npm install
npx playwright install chromium
npm run buildAfter the build, you can run it:
cd tools/web/mcp-server
npm startYour agent will automatically recognize the MCP server if your working directory is Cobalt's.
Cobalt also ships a /validate command for agentic coding tools (.claude/commands/validate.md). It orchestrates a fleet of agents that try to prove Cobalt implements every WhatsApp feature correctly, on two levels:
- Source parity: each Cobalt module is checked against the behaviour of its WhatsApp counterpart.
- Observable parity: the stanzas, events and requests Cobalt produces for a given input are diffed against what the real WhatsApp runtime produces for the same input, captured live through the MCP.
To be transparent, this is highly experimental, something I built because I like trying things out and wanted to see whether it could help us maintain a repository of this size long term. Treat it as an experiment, not as a required part of the workflow, and consider it has never been run end to end (it's incredibly expensive). If you want to design something better, feel free to try to do so.
Cobalt targets Java 25. Depend on com.github.auties00:cobalt-lib.
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
</dependency>Gradle (Kotlin DSL)
implementation("com.github.auties00:cobalt-lib:0.1.0")Gradle (Groovy DSL)
implementation 'com.github.auties00:cobalt-lib:0.1.0'Snapshots and per-commit builds are available through JitPack.
Calls and media use native components.
By default, nothing ships in the jar: the right binary for the host is downloaded on first use, SHA-256 verified, and cached under ~/.cobalt/natives/. That is all a normal setup needs.
If you want to have the native libraries bundled in the JAR, add the vendored bundle for each platform you target. The bundles are classified artifacts of cobalt-lib, one per target. Expand the block for your platform to get the Maven and Gradle snippets:
Windows x86-64
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-windows-x86_64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-windows-x86_64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-windows-x86_64'Windows ARM64
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-windows-aarch64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-windows-aarch64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-windows-aarch64'Linux x86-64
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-linux-x86_64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-linux-x86_64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-linux-x86_64'Linux ARM64
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-linux-aarch64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-linux-aarch64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-linux-aarch64'macOS Intel
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-darwin-x86_64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-darwin-x86_64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-darwin-x86_64'macOS Apple Silicon
Maven
<dependency>
<groupId>com.github.auties00</groupId>
<artifactId>cobalt-lib</artifactId>
<version>0.1.0</version>
<classifier>natives-darwin-aarch64</classifier>
<scope>runtime</scope>
</dependency>Gradle (Kotlin DSL)
runtimeOnly("com.github.auties00:cobalt-lib:0.1.0:natives-darwin-aarch64")Gradle (Groovy DSL)
runtimeOnly 'com.github.auties00:cobalt-lib:0.1.0:natives-darwin-aarch64'Scan the printed QR from your phone (WhatsApp, then Linked devices), then send and receive in real time.
import com.github.auties00.cobalt.client.WhatsAppClient;
import com.github.auties00.cobalt.client.linked.LinkedWhatsAppClientVerificationHandler.Web.QrCode;
import com.github.auties00.cobalt.model.message.MessageContainer;
void main() throws Exception {
WhatsAppClient.builder()
.linkedApi()
.webClient() // in-memory session
.createConnection() // a fresh connection
.name("Cobalt Bot") // the linked-device name shown in WhatsApp
.unregistered(QrCode.toTerminal()) // print the QR to scan
.addLoggedInListener(api -> System.out.println("Connected"))
.addNewMessageListener((api, message) -> {
if (!message.key().fromMe()) {
message.key()
.parentJid()
.ifPresent(chat -> api.sendMessage(chat, MessageContainer.of("Got your message")));
}
})
.connect() // returns once the socket is live
.waitForDisconnection(); // park this thread for the session
}Prefer a pairing code over a QR? Swap the terminal:
.unregistered(<your_phone_number>, PairingCode.toTerminal()) // phone with country code, no + or spacesFor a Business Platform number, supply your credentials and let Cobalt run the webhook server for inbound traffic.
import com.github.auties00.cobalt.client.WhatsAppClient;
import com.github.auties00.cobalt.model.message.MessageContainer;
void main() {
WhatsAppClient.builder()
.cloudApi()
.loadConnection("EAAB...", "123456789") // system-user token + phone number id (required)
.appSecret("...") // verifies inbound webhook signatures
.webhook("my-verify-token", 8080) // start the webhook server on :8080
.build()
.addLoggedInListener(api -> System.out.println("Connected"))
.addNewMessageListener((api, message) -> {
message.key()
.parentJid()
.ifPresent(chat -> api.sendMessage(chat, MessageContainer.of("Got your message")));
})
.connect() // validates the token, starts the webhook
.waitForDisconnection(); // park this thread for the session
}The QR example above is a complete client in one expression. Walking it line by line touches every concept Cobalt relies on.
WhatsAppClient is a sealed interface with exactly two implementations, so this is the fork in the road:
.linkedApi()leads to the reverse-engineered transport..cloudApi()leads to the official Cloud API.
Operations that exist on both transports (connect, send, react, mark read, block) live on the shared type, so code written against WhatsAppClient works with either.
Picks the flavour of the Linked client and, implicitly, where the session lives:
webClient()pairs as a companion device, like WhatsApp Web. With no argument the session is held in memory and lost on exit; pass aWhatsAppStoreFactoryto persist it to disk instead.mobileClient()registers as a standalone phone (iOS or Android) rather than a companion.customClient()lets you supply your own store implementation.
The store is the session's single source of truth: Signal keys, your account identity, contacts, chats, messages, sync state, settings. The persistent variant snapshots metadata as protobuf and keeps messages in an embedded MDBX database. You query it at runtime through typed sub-stores:
Optional<Chat> chat = client.store().chatStore().findChatByJid(someJid);A connection is one registered session, and this stage decides where it comes from:
createConnection()starts a fresh, unregistered session.loadLatestConnection(),loadConnection(uuid)andloadConnection(phoneNumber)reopen a persisted one; each has a...OrCreatevariant.createConnection(sixParts)imports credentials from a portable six-part key string.
This stage is why a persisted client never shows the QR twice.
The options stage configures the session before registration:
name(...)is the device name shown under Linked devices.proxy(...)routes the connection through a proxy.device(...)is the device Cobalt presents itself as.clientVersion(...)pins the WhatsApp client version.releaseChannel(...)selects RELEASE or BETA; BETA unlocks Web calling.historySetting(...)controls how much history the phone syncs.errorHandler(...)sets the recovery policy.
The error handler is Cobalt's whole recovery policy: every failure is a typed exception in a sealed WhatsAppException hierarchy, and your handler maps it to one of DISCARD, DISCONNECT, RECONNECT, LOG_OUT, or BAN. Nothing is hardcoded; the default handler logs and keeps the client alive.
The terminal stage: it declares how this session proves itself to WhatsApp and produces the actual LinkedWhatsAppClient. A verification handler decides how the proof reaches the user:
unregistered(QrCode.toTerminal())prints the QR to scan;QrCode.toFile(...)writes it to disk instead.unregistered(phone, PairingCode.toTerminal())prints a pairing code to type into the phone.registered()skips verification entirely by reusing the persisted credentials.- On the mobile flavour,
register(phone, Mobile.sms(...))runs the OTP ceremony, with handlers for SMS, call, and in-app codes.
Everything WhatsApp pushes to the client surfaces as an event, and there are two ways to subscribe:
- One chainable registrar per event:
addLoggedInListener,addNewMessageListener,addCallListener,addDisconnectedListener, and dozens more. - One class implementing
LinkedWhatsAppClientListener, which has a no-op default for every event, registered once withaddListener(...).
Each listener invocation runs on its own virtual thread, so a slow listener never stalls the connection; every lambda also receives the client instance (api), so listeners need no outside references.
Inside the listener you meet the message model, three types that appear in every messaging call:
MessageInfois a message as it exists in a chat: content plus metadata (sender, timestamp, status).MessageKeyis its identity: the chat (parentJid()), the author (senderJid()), the id, andfromMe(), which the example checks to avoid replying to itself. Keys are what you pass to react, edit, delete, star, and pin.MessageContaineris the content itself, a one-of where exactly one message type (text, image, poll, ...) is set.
parentJid() returns a Jid, the address of any entity: a user, a group, a newsletter, a broadcast list. Build one with Jid.of("15551234567"). Most methods actually accept a JidProvider, implemented by Jid, Chat, Contact, GroupMetadata and Newsletter alike, so a resolved object can be passed wherever an address is expected.
MessageContainer.of(...) wraps content for sending; the String overload is plain text. Richer content is built first, because every model in Cobalt follows the same convention: a generated <TypeName>Builder constructs it, fields are read with field() accessors (not getField()), absent fields return Optional, and lists are unmodifiable.
var location = new LocationMessageBuilder()
.latitude(37.386051).longitude(-122.083855).caption("Meet here")
.build();
api.sendMessage(chat, MessageContainer.of(location));sendMessage returns the MessageKey of what was sent, closing the loop: hold on to it to edit, delete, or react later.
IMPORTANT:
sendMessageis a plain blocking call, like every operation in Cobalt: it returns the result or throws, and there is noCompletableFutureanywhere in the API. Blocking is cheap because everything runs on virtual threads; this listener is already on its own, so a slow send never stalls the connection. To run operations in parallel, start your own virtual threads.
Opens the encrypted socket, runs the Noise handshake, and starts processing traffic; it returns as soon as the connection is live.
connect() does not block for the session's lifetime, so without this line the main thread would fall through and the JVM could exit. waitForDisconnection() parks the calling thread until a terminal disconnect: transient network drops are reconnected automatically and do not wake it. The reason is reported to addDisconnectedListener as one of DISCONNECTED, RECONNECTING, LOGGED_OUT, or BANNED. Deliberate teardown:
disconnect()closes the connection; the session stays valid.reconnect()tears down and reconnects in one call.logout()closes the connection and invalidates the session.
The Cloud quickstart follows the same shape with a different first stage. There is no QR and no store:
loadConnection(accessToken, phoneNumberId)collects the required credentials and opens the configuration stage.appSecret(...)lets Cobalt sign requests and verify webhook payloads.webhook(verifyToken, port)starts the built-in webhook server when you connect; it answers Meta's verification handshake and validates theX-Hub-Signature-256of every delivery before dispatching events.build()produces theCloudWhatsAppClient.
Listeners register exactly as on the Linked client, and sendMessage takes the same MessageContainer, so everything from the message model onward carries over unchanged.
Every member of the library, public and internal alike, carries extensive Javadocs, so this README only covers the essentials. For anything not spelled out here, or any doubt about how a type or method behaves, refer to the Javadoc. The goal of this library to allow you to do anything you can do in WhatsApp, on any client, so if your use case is not covered, feel free to open an issue about it.