C O B A L T

Standalone, unofficial, fully-featured WhatsApp Web, Desktop, Mobile and Cloud API for JVM languages


Java License Maven Central Javadoc

Disclaimer

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.

What is Cobalt

Cobalt lets a JVM application be a WhatsApp client. It supports two completely different ways of reaching WhatsApp, behind one shared message model:

IMPORTANT: Cobalt is pre-1.0: expect breaking changes between releases until the API is frozen.

Donations

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.

How to contribute

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 contributions

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.

Tooling

Codegen

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:

Every extractor is a Node project: npm install, then npm run build, then npm start.

MCP Server

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 build

After the build, you can run it:

cd tools/web/mcp-server
npm start

Your agent will automatically recognize the MCP server if your working directory is Cobalt's.

Agentic Validator

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:

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.

Install

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.

Native libraries

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'

Quickstart

Linked, log in with a QR code

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 spaces

Cloud API

For 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 quickstart, explained

The QR example above is a complete client in one expression. Walking it line by line touches every concept Cobalt relies on.

WhatsAppClient.builder()

WhatsAppClient is a sealed interface with exactly two implementations, so this is the fork in the road:

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.

.webClient()

Picks the flavour of the Linked client and, implicitly, where the session lives:

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);

.createConnection()

A connection is one registered session, and this stage decides where it comes from:

This stage is why a persisted client never shows the QR twice.

.name("Cobalt Bot")

The options stage configures the session before registration:

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.

.unregistered(QrCode.toTerminal())

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:

.addNewMessageListener(...)

Everything WhatsApp pushes to the client surfaces as an event, and there are two ways to subscribe:

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.

message.key()

Inside the listener you meet the message model, three types that appear in every messaging call:

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.

api.sendMessage(chat, MessageContainer.of("..."))

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: sendMessage is a plain blocking call, like every operation in Cobalt: it returns the result or throws, and there is no CompletableFuture anywhere 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.

.connect()

Opens the encrypted socket, runs the Noise handshake, and starts processing traffic; it returns as soon as the connection is live.

.waitForDisconnection()

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:

The Cloud variant

The Cloud quickstart follows the same shape with a different first stage. There is no QR and no store:

Listeners register exactly as on the Linked client, and sendMessage takes the same MessageContainer, so everything from the message model onward carries over unchanged.

Documentation

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.