what-up-sdk
v0.0.2
Published
Single-session WhatsApp host SDK — Baileys socket lifecycle, provisioning, resilient reconnect, normalized inbound, and per-session send behind one object. One process drives one number.
Maintainers
Readme
what-up-sdk
Single-session WhatsApp host SDK. One process drives one number: socket
lifecycle, provisioning (QR + pairing code), resilient reconnect, normalized
inbound, and per-session send — behind one Session object, with no "brain"
logic.
Why one process per number? WhatsApp allows exactly one live linked-device connection per number — two processes on the same session collide (
conflict: replaced, disconnect code 440). One-process-per-number makes that invariant structural and gives each number crash/CPU isolation.
The same package also ships official-API transports — WhatsApp Business
(Cloud API) and Twilio — that share the SDK's InboundRouter, so one
routing layer works across all three.
Install
npm install what-up-sdk baileysbaileys is a peer dependency — you own its version (currently
7.0.0-rc13). The Cloud API and Twilio surfaces don't need it at runtime, but
your package manager will still want the peer satisfied.
⚠️ The
Sessionsurface drives WhatsApp through Baileys, an unofficial WhatsApp Web client. Numbers can be banned; the SDK ships ban-risk detection (onBanRisk), rate limiting, and human-like send pacing, but use it at your own risk. The Cloud API and Twilio transports are official channels with no ban risk.
Usage
import { Session, FileAuthStore } from 'what-up-sdk'
import P from 'pino'
const session = new Session('my-bot', {
authStore: new FileAuthStore('./auth'),
logger: P({ level: 'info' }),
// Outbound caps (host concern — the brain no longer tracks this):
rateLimit: { perRecipientPerHour: 30 }, // over → send throws RateLimitError
// Send queue: serialized, paced, offline-buffered. Sending is the priority path.
sendQueue: {
minDelayMs: 1000, // human-like spacing (jittered up to maxDelayMs)
maxDelayMs: 3000,
maxConcurrent: 1, // serialize — never fire a burst at WhatsApp
ttlMs: 60_000, // a send waits up to 60s for reconnect, then rejects
},
})
session.onQR((qr) => console.log('scan this:', qr))
session.onStatus((s) => console.log('status:', s))
// Ban-risk warnings (on by default). Fires on rapid reconnects, disconnect
// churn, frequent QR re-scans, high send-error rate, rate limits, or a reachout
// timelock. Wire it to your alerting / pause the session on 'critical'.
session.onBanRisk((e) => {
console.warn(`[${e.riskLevel}] ${e.signals.join(', ')} — ${e.recommendation}`)
})
session.onMessage(async (m) => {
// m is a normalized InboundMessage — ephemeral/view-once already unwrapped,
// sender resolved into both LID and phone-number forms, mentions + reply-to-self
// resolved against the hosted number's identity. Groups AND DMs are delivered;
// the SDK applies no chat-type filter — that policy is yours.
const forBot = m.isGroup ? m.mentionsMe || m.isReplyToSelf : true
if (!forBot) return
// Peek the rate limit BEFORE expensive work (e.g. an LLM call) to avoid waste.
if (!session.rateLimit.check(m.chatJid)) return // over cap — drop silently
await session.sendText(m.chatJid, `You said: ${m.text}`, { quoted: m.raw })
})
await session.connect()Route one number by incoming phone number
One number, different responses per caller: InboundRouter is a per-sender
gateway over the number's single inbound stream. String patterns match the
sender's phone-number form digit-wise (exact numbers or 27*-style prefixes);
regexes test the phone digits; predicates see the full sender surface.
Precedence is fixed — exact → longest prefix → regex/predicate (registration
order) → fallback:
import { InboundRouter } from 'what-up-sdk'
const router = new InboundRouter()
.route('+27 82 123 4567', (m) => session.sendText(m.chatJid, 'concierge 🥂'))
.route(['14155550100', '14155550101'], handleOpsAllowlist) // one handler, many numbers
.route('27*', (m) => session.sendText(m.chatJid, 'SA desk 🇿🇦'))
.route(/^1\d{10}$/, handleNorthAmerica)
.fallback((m) => session.sendText(m.chatJid, 'main line'))
router.attach(session) // same router attaches to a HostClient for hosted numbersrouter.resolve(m) peeks at which route would win without invoking it, and
router.dispatch(m) routes one message by hand.
Same router, official API: the Cloud API intake
For the one-number/many-users surface specifically, the WhatsApp Business (Cloud API) is often the better transport: it's Meta's official channel, so there is no ban risk, no QR pairing, no reconnect/lease machinery — inbound arrives on a webhook and outbound goes through the Graph API. The trade-offs are structural: 1:1 business messaging only (no groups), and free-form replies are allowed only within 24h of the user's last message (outside it you must send an approved template).
CloudApiIntake is the webhook half — framework-free (feed it the GET
handshake and each raw POST body); it verifies X-Hub-Signature-256 and emits
CloudInboundMessages that satisfy RoutableMessage, so the same
InboundRouter attaches unchanged. CloudApiNumber is the send half, shaped
like Session's send surface:
import { CloudApiIntake, CloudApiNumber, InboundRouter, type CloudInboundMessage } from 'what-up-sdk'
const intake = new CloudApiIntake({ verifyToken, appSecret })
const number = new CloudApiNumber({ accessToken, phoneNumberId })
new InboundRouter<CloudInboundMessage>()
.route('+27 82 123 4567', (m) => number.sendText(m.chatJid, 'concierge 🥂', { quotedId: m.id! }))
.route('27*', (m) => number.sendText(m.chatJid, 'SA desk 🇿🇦'))
.fallback((m) => number.sendText(m.chatJid, 'main line'))
.attach(intake)
// In your HTTP server:
// GET /webhook → intake.handshake(searchParams) → echo the challenge (or 403)
// POST /webhook → intake.ingest(rawBody, req.headers['x-hub-signature-256']) → always 200On Twilio instead? If your WhatsApp number is hosted through Twilio's
Business API integration, the same surface exists as TwilioIntake +
TwilioNumber — Twilio's dialect differs (form-encoded webhooks signed with
X-Twilio-Signature over the URL+params, REST sends with whatsapp:+…
addressing, templates via Content SIDs, window violations as error 63016),
but TwilioInboundMessage satisfies RoutableMessage too, so the router
attaches identically.
Provisioning
Provision from a browser (mount in any HTTP server):
import { renderProvisioningPage } from 'what-up-sdk'
// GET /qr → res.end(await renderProvisioningPage(session))Or programmatically, with a pairing code instead of a QR:
await session.connect()
const code = await session.requestPairingCode('27821234567') // → "ABCD-1234"connect() returns once the socket is wired, not once it is online — but
requestPairingCode() awaits socket readiness itself, so calling it straight after
connect() is safe. If the request fails, it rolls back the partial credentials Baileys
writes up front, so a failed attempt can't poison the next connect.
Pairing-code linking is sensitive to the browser option: WhatsApp shows browser[0] to
the operator as the linking device, and an unrecognized value there can fail the link. It
defaults to Browsers.macOS('Chrome') — override it with a Browsers.* helper rather
than a made-up tuple.
Surface
| Member | Purpose |
|---|---|
| new Session(id, { authStore, ... }) | one number, one socket |
| connect() / logout() | lifecycle; logout() clears auth |
| onMessage / onQR / onStatus / onBanRisk | subscriptions (multiple allowed) |
| banRisk | the BanRiskDetector — check() on demand, or tune via the banRisk option |
| requestPairingCode(phone) | code pairing (no QR) |
| sendText / sendImage / sendFile | per-session send → queued, paced, cap-checked, SessionError-retried; opts.priority jumps the queue |
| sendQueue | the SendQueue — serialization, pacing, priority, offline buffer + flush on reconnect, backpressure |
| rateLimit | the RateLimiter — check(jid) to peek; per-recipient/per-session caps enforced on every send |
| InboundRouter | per-sender gateway: route one number's inbound to different handlers by incoming phone number (exact / prefix / regex / predicate / fallback); attach() to a Session or HostClient |
| CloudApiIntake | WhatsApp Business (Cloud API) webhook intake: GET handshake, X-Hub-Signature-256 verification, normalized CloudInboundMessage fan-out; router-attachable |
| CloudApiNumber | Cloud API send handle (Graph API): sendText / sendImageUrl / sendTemplate / markRead / downloadMedia; surfaces the 24h-window rejection as CloudApiError code 131047 |
| TwilioIntake | Twilio WhatsApp webhook intake: X-Twilio-Signature verification, form-param normalization to TwilioInboundMessage, status callbacks via onStatus; router-attachable |
| TwilioNumber | Twilio send handle (Messages REST API): sendText / sendMediaUrl / sendContent (Content templates) / downloadMedia; 24h-window rejection surfaces as TwilioError code 63016 |
| sendPresence / updateProfileName | presence + profile |
| listGroups() | { jid, subject }[] |
| downloadMedia(m) / downloadMediaRaw(key, content) | media, rebuilds expired keys |
| getSocket() | escape hatch to the raw Baileys socket |
Subpath exports
| Import | What it is |
|---|---|
| what-up-sdk | the full surface: Session, InboundRouter, Cloud API + Twilio transports, auth stores |
| what-up-sdk/host | HostClient — consume a number hosted by a what-up control plane |
| what-up-sdk/cloud-api | Cloud API transport only |
| what-up-sdk/twilio | Twilio transport only |
| what-up-sdk/webhook | webhook intake primitives |
More
Runnable examples, the hosting control planes, and the architecture diagram live in the monorepo: github.com/edumame/what-up-sdk.
License
MIT — extracted from the MentorMates bot's transport layer and hardened with patterns from WaSP (MIT).
