Standalone HTTP bridge for nullclaw WhatsApp integrations built on top of
whatsmeow.
This repository is intentionally separate from nullclaw core. It owns the
actual WhatsApp login session, QR and pairing-code UX, websocket lifecycle, and
simple HTTP endpoints that an external nullclaw channel plugin can talk to.
The clean split is:
nullclawgeneric ExternalChannel hostnullclaw-channel-whatsmeow-bridgeWhatsApp session and bridge API- optional adapter plugin a tiny stdio JSON-RPC layer if you want to keep the bridge HTTP-based
That keeps Go code out of the main nullclaw repository while still allowing
WhatsApp to be integrated cleanly.
GET /healthGET /qrPOST /pair-codePOST /pollPOST /sendPOST /editPOST /deletePOST /reactionPOST /read
Environment variables:
NULLCLAW_WHATSMEOW_BRIDGE_LISTENdefault127.0.0.1:3301NULLCLAW_WHATSMEOW_BRIDGE_STATE_DIRdefault./stateNULLCLAW_WHATSMEOW_BRIDGE_TOKENoptional Bearer token required on every request when setNULLCLAW_WHATSMEOW_BRIDGE_DISPLAY_NAMEdefaultChrome (Linux)
Build:
go build ./...Run locally:
NULLCLAW_WHATSMEOW_BRIDGE_LISTEN=127.0.0.1:3301 \
NULLCLAW_WHATSMEOW_BRIDGE_STATE_DIR=./state \
./nullclaw-channel-whatsmeow-bridgeRun with bridge auth enabled:
NULLCLAW_WHATSMEOW_BRIDGE_LISTEN=127.0.0.1:3301 \
NULLCLAW_WHATSMEOW_BRIDGE_STATE_DIR=./state \
NULLCLAW_WHATSMEOW_BRIDGE_TOKEN=change-me \
./nullclaw-channel-whatsmeow-bridgeDocker Compose example:
cd deploy
docker compose up -dsystemd example:
- Start the bridge.
- Open
GET /qruntil it returnsevent=code. - Render or copy the QR code value into your preferred UI.
- Link the device from the WhatsApp phone app.
GET /healthbegins returninglogged_in=true.
Example:
curl http://127.0.0.1:3301/qr- Start the bridge.
- Call
POST /pair-codewithphone_number. - Show the returned pairing code to the operator.
- Complete linked-device flow on the phone.
GET /healthbegins returninglogged_in=true.
Example:
curl \
-X POST \
-H 'Content-Type: application/json' \
http://127.0.0.1:3301/pair-code \
-d '{"phone_number":"551199999999","display_name":"Chrome (Linux)"}'If NULLCLAW_WHATSMEOW_BRIDGE_TOKEN is set, add:
-H 'Authorization: Bearer change-me'The bridge persists its real WhatsApp session in state/whatsmeow.db.
For system hardening and deploy notes, see:
Included assets:
- Dockerfile
- deploy/docker-compose.yml
- deploy/systemd/nullclaw-channel-whatsmeow-bridge.service
- .env.example
- Start the bridge with a persistent
NULLCLAW_WHATSMEOW_BRIDGE_STATE_DIR. - Complete QR or pairing-code login.
- Verify health:
curl http://127.0.0.1:3301/healthExpected shape:
{
"ok": true,
"connected": true,
"logged_in": true
}- Validate inbound queue:
curl \
-X POST \
-H 'Content-Type: application/json' \
http://127.0.0.1:3301/poll \
-d '{"account_id":"wa-main","cursor":"0"}'- Validate outbound:
curl \
-X POST \
-H 'Content-Type: application/json' \
http://127.0.0.1:3301/send \
-d '{"account_id":"wa-main","to":"551199999999@s.whatsapp.net","text":"hello from bridge"}'- Once bridge traffic is healthy, wire it into
nullclaw.
This bridge is designed to be used behind a small external-channel plugin.
The existing example contract from nullclaw expects:
GET /healthPOST /pollPOST /send
This bridge implements those directly and also exposes edit/delete/reaction/read endpoints for future richer adapters.
Example nullclaw config when used behind the existing HTTP adapter:
{
"channels": {
"external": {
"accounts": {
"wa-web": {
"runtime_name": "whatsapp_web",
"transport": {
"command": "/opt/nullclaw/plugins/nullclaw-plugin-whatsapp-web",
"timeout_ms": 10000
},
"config": {
"bridge_url": "http://127.0.0.1:3301",
"api_key": "change-me",
"allow_from": ["*"]
}
}
}
}
}
}Auth split:
- bridge auth is
Authorization: Bearer <token>on HTTP requests - WhatsApp auth is QR or pairing-code login handled inside this bridge
nullclawnever sees the real WhatsApp linked-device credentials
Request:
{
"account_id": "wa-main",
"cursor": "0"
}Response:
{
"next_cursor": "12",
"messages": [
{
"id": "base64url-message-ref",
"from": "551199999999@s.whatsapp.net",
"text": "hello",
"chat_id": "551199999999@s.whatsapp.net",
"is_group": false
}
]
}Request:
{
"account_id": "wa-main",
"to": "551199999999@s.whatsapp.net",
"text": "hello"
}{
"message_id": "base64url-message-ref",
"text": "edited text"
}{
"message_id": "base64url-message-ref"
}{
"message_id": "base64url-message-ref",
"emoji": "✅"
}{
"message_id": "base64url-message-ref"
}Request:
{
"phone_number": "551199999999",
"display_name": "Chrome (Linux)"
}Response:
{
"pairing_code": "ABCD-EFGH"
}Inbound messages[].id is a base64url-encoded JSON envelope containing:
- WhatsApp message ID
- chat JID
- sender JID
- whether the message was sent by the current account
- timestamp
That allows later edit/delete/reaction/read actions to target the right message without maintaining a separate lookup table in the plugin.
go build ./...go test ./...
go build ./...logged_instays false: complete QR or pair-code flow first; the bridge is correct to stay unhealthy before that.- Empty
/pollresponses: make sure a real inbound text message reached the linked device after login. - 401 responses:
verify the Bearer token matches
NULLCLAW_WHATSMEOW_BRIDGE_TOKEN. - Lost session after restart:
confirm
NULLCLAW_WHATSMEOW_BRIDGE_STATE_DIRis persistent and writable.