A thin, self-hostable bridge between the world's most popular green chat app π’ and HTTP. Inbound messages become webhooks. An HTTP call sends one back out. Many numbers, one small Node process, one SQLite file.
(You know the one. We won't say it. Rhymes with "WhatsUpp". Powered by Baileys.)
π’ the app Relay (your VPS) Your stuff
βββββββββββββ ββββββββββββββββββββββββ ββββββββββββββββββ
β π± phone β ββ messages ββΆ β Baileys socket(s) β ββ webhook ββΆ β n8n / your API β
β account β βββ send βββββ β REST + queue + UI β βββ POST /send β automations β
βββββββββββββ β SQLite (sessions) β ββββββββββββββββββ
ββββββββββββββββββββββββ
Relay wraps Baileys (the web protocol of that green messenger) in a tiny Fastify REST layer. It runs as a single long-lived process β perfect next to an n8n instance on the same box β and ships with a built-in control panel so you never touch the terminal to link a number or send a test message.
- Multiple connections, one process β create / start / stop / delete chat links at runtime, each its own number.
- Two ways to link β scan a QR, or punch in a phone number and get an 8-char pairing code. No screenshots of terminals.
- Inbound β webhook β every message POSTs a clean JSON payload to a URL you choose (global, or per connection).
- Outbound β one HTTP call β
POST /sessions/:id/send, rate-limited by a per-connection queue so you never burst. - Survives restarts β sessions, auth state, and a message log live in SQLite; connections reconnect on boot with no re-scan.
- Built-in control UI β served by the bridge itself, same origin, no build step.
- Private by default β binds to
127.0.0.1, mutating routes need a secret.
Needs Node.js 22+ (Relay uses the built-in
node:sqliteβ no native build).
git clone https://github.com/davesheffer/Relay.git relay && cd relay
npm install
cp .env.example .env # set API_SECRET + WEBHOOK_SECRET (any long random strings)
npm run build
npm startOpen http://127.0.0.1:3100, paste your API_SECRET (top-right), and Add a
connection. That's it.
Deploying to a server? β DEPLOY.md (any Ubuntu VPS; tunnel or domain+HTTPS).
The bridge serves the UI at / β just open its address. Same origin, so no CORS,
no separate static server.
-
Top-right β set the base URL (auto-filled to wherever the page is served) and your
x-api-secret. Saved locally in your browser. -
Add a connection β give it a name. Then link it one of two ways:
Leave phone empty Enter a phone number A QR appears β open the app β Linked devices β Link a device β scan You get an 8-char code β open the app β Linked devices β Link with phone number instead β type it -
Status flips to connected β the Send box activates.
-
Watch Recent messages stream in, set a per-connection webhook, or Stop / Start / Delete β all live.
The QR is rendered in your browser from the raw string (vendored, no CDN). Read views are open (localhost bind); anything that changes state sends the secret.
Base: http://127.0.0.1:3100 Β· π = needs header X-Api-Secret
| Method | Path | Does | |
|---|---|---|---|
GET |
/health |
{ ok, sessions } |
|
GET |
/sessions |
list connections + live status | |
GET |
/sessions/:id |
one connection | |
GET |
/sessions/:id/qr |
{ qr } (string or null) |
|
GET |
/sessions/:id/messages |
recent 50 from the log | |
POST |
/sessions |
π | create + start Β· { name, phone?, webhookUrl?, webhookSecret? } |
POST |
/sessions/:id/pair |
π | link by number Β· { phone } |
POST |
/sessions/:id/webhook |
π | set its webhook Β· { url, secret? } |
POST |
/sessions/:id/start Β· /stop |
π | start / stop |
POST |
/sessions/:id/send |
π | { jid, text } β { id } |
DELETE |
/sessions/:id |
π | stop + purge auth, messages, row |
# create a connection
curl -X POST http://127.0.0.1:3100/sessions \
-H "content-type: application/json" -H "X-Api-Secret: $API_SECRET" \
-d '{"name":"Main line"}'
# -> { "id": "main-line-ab12cd", "status": "connecting", ... }
# send a message (bare numbers get @s.whatsapp.net appended)
curl -X POST http://127.0.0.1:3100/sessions/main-line-ab12cd/send \
-H "content-type: application/json" -H "X-Api-Secret: $API_SECRET" \
-d '{"jid":"972501234567","text":"hello from Relay"}'
# -> { "id": "3EB0..." }
# link by phone instead of QR -> status "pairing", code in pairingCode
curl -X POST http://127.0.0.1:3100/sessions/main-line-ab12cd/pair \
-H "content-type: application/json" -H "X-Api-Secret: $API_SECRET" \
-d '{"phone":"972501234567"}'status β connecting Β· qr Β· pairing Β· open Β· logged_out Β· closed.
Send errors: 400 bad body Β· 401 bad secret Β· 404 unknown session / number not
registered Β· 503 not connected. The recipient is verified to exist before sending;
the send is queued with SEND_DELAY_MS spacing.
Each connection can have its own webhook (set at create, in the UI, or via
POST /sessions/:id/webhook). No per-connection URL? It falls back to the global
WEBHOOK_URL. For every non-fromMe message, Relay fires:
{
"session": "main-line-ab12cd",
"from": "972501234567",
"jid": "972501234567@s.whatsapp.net",
"text": "incoming text",
"timestamp": 1735500000000,
"messageId": "3EB0XXXXXXXXXXXX",
"pushName": "Alice"
}Sent with header X-Webhook-Secret. Fire-and-forget with a short timeout β
failures are logged, never crash the socket.
Run n8n on the same box (localhost). See deploy/n8n.service for a systemd unit.
- Receive β Webhook trigger node, method
POST, pathwhatsapp-in. Point each connection's webhook athttp://127.0.0.1:5678/webhook/whatsapp-in. ValidateX-Webhook-Secret. Branch on{{$json.body.session}}if you run several numbers. - Send β HTTP Request node β
POST http://127.0.0.1:3100/sessions/<id>/send, headerX-Api-Secret, body{ "jid": "...", "text": "..." }.
Baileys keeps a long-lived socket plus an auth state (device creds + a signal
key store it mutates constantly). The stock useMultiFileAuthState dumps that to a
folder per connection β fine for one bot, not a datastore.
Relay implements a custom auth state (src/auth-state.ts) backed
by SQLite, scoped per sessionId:
- creds β one row, rewritten on every
creds.update - signal keys β one row each (
category,doc_id), upserted/deleted as Baileys reads & writes them β serialized with Baileys'BufferJSON
On boot, SessionManager.restoreAll() loads every row and
auto-starts the flagged ones β they reconnect without a new QR. On
loggedOut, that connection's auth is purged so the next start shows a fresh code.
| table | holds |
|---|---|
sessions |
id, name, status, jid, phone, webhook, autostart, timestamps |
auth_state |
creds + signal keys, scoped by session |
messages |
inbound/outbound log (jid, message id, text, ts) |
It's all one folder β back up data/ + .env and you've backed up everything.
All via .env (see .env.example):
| var | default | what |
|---|---|---|
PORT / HOST |
3100 / 127.0.0.1 |
bind address (keep localhost) |
API_SECRET |
β | required; gates mutating routes |
WEBHOOK_URL / WEBHOOK_SECRET |
β | global/fallback webhook |
SEND_DELAY_MS |
1500 |
min gap between sends, per connection |
WEBHOOK_TIMEOUT_MS |
5000 |
inbound delivery timeout |
DB_PATH |
./data/relay.db |
SQLite file |
WEB_DIR |
./web |
static UI dir (empty to disable) |
CORS_ORIGINS |
β | extra browser origins (localhost always allowed) |
LOG_LEVEL |
info |
pino level (message bodies never logged at info) |
- Relay binds localhost. The read endpoints (
/qr,/sessions,/messages) are intentionally open for local use β never expose port 3100 to the internet directly. A public/qrlets a stranger link your account. - To reach it remotely: an SSH tunnel, or a reverse proxy with auth over the
whole site (see
deploy/Caddyfile). Both covered in DEPLOY.md. - Secrets live only in your
.env(gitignored). TheX-Api-Secretcheck is constant-time.
TypeScript (ESM) Β· Fastify Β· Baileys Β· pino Β· node:sqlite (Node 22+) Β· zero
native dependencies.
Relay is an independent, unofficial project. It is not affiliated with, endorsed by, or connected to Meta Platforms, Inc. or the messaging app it talks to. All trademarks belong to their respective owners.
It builds on Baileys, an unofficial client. Automating a personal messaging account can violate that app's Terms of Service and may get the account restricted or banned. You are solely responsible for how you use this software, for the number(s) you connect, and for complying with all applicable terms and laws. Intended for personal, educational, and development use.
Provided "as is", without warranty of any kind (see LICENSE). The authors are not liable for any damages, bans, or losses arising from its use. This is not legal advice.
MIT β see LICENSE. Self-host it, fork it, ship it.