Documentation

Everything you need to send and receive real SMS from Node.js through your own Android phone. Five minutes from install to a delivered text.

Install & pair

You need Node.js 18+, an Android 8+ phone with the simwire app, and both devices on the same Wi-Fi.

npm install simwire
npx simwire pair       # shows a QR code in your terminal

Scan the QR from the app. The phone generates a token, hands it to your machine over the LAN, and the CLI stores it in ~/.simwire/config.json. You never create an account and the token never crosses the internet.

Send an SMS

import { connect } from "simwire";

const sms = await connect();          // finds your phone on the LAN

const msg = await sms.send({
  to: "+15550142834",               // one number or an array
  text: "Your code is 4821",
  simSlot: 0,                        // optional: pick the SIM (0 or 1)
});

await msg.waitForDelivery();         // resolves on the carrier's report
sms.close();

send() resolves once the phone has durably queued the message: even if the app is killed right after, the SMS goes out when it restarts. Statuses then stream in as the radio reports them.

Receive SMS

Every SMS arriving on the phone's SIM is pushed to your machine over the LAN. No public webhook, no tunnel.

sms.on("message", (m) => {
  console.log(m.from, m.text, m.simSlot, m.receivedAt);
});

Messages received while nothing is connected are buffered on the phone and replayed the next time you connect. From the CLI, you can also pipe them straight into a local server:

npx simwire listen --forward http://localhost:3000/sms

Each SMS becomes a JSON POST: { event, from, text, simSlot, receivedAt }.

The mock device

Same API, no hardware. Perfect for unit tests and CI.

import { mock } from "simwire";

const sms = mock();                  // or mock({ latencyMs: 1 }) in tests

await sms.send({ to: "+15550100000", text: "OTP: 1234" });
expect(sms.outbox).toHaveLength(1);
expect(sms.outbox[0].status).toBe("queued");

sms.simulateIncoming({ from: "+15550100001", text: "STOP" });
expect(sms.inbox).toHaveLength(1);

sms.failNext("no signal");          // next send fails after "queued"
MemberWhat it does
outbox / inboxArrays of everything sent and received, for assertions.
simulateIncoming({from, text, simSlot?})Injects an SMS as if the SIM had received it.
failNext(reason?)Arms a failure for the next send.
waitForMessage(predicate?, timeoutMs?)Resolves on the next matching incoming message.
mock({ latencyMs, autoDeliver })Tune status timing; autoDeliver: false stops at "sent".

connect()

const sms = await connect({
  host: "192.168.1.42",   // optional: skip mDNS discovery
  port: 4650,              // optional, default 4650
  token: "…",             // optional: defaults to the paired token
  timeoutMs: 10_000,
});

Without options, connect() uses the token saved by simwire pair and finds the phone with mDNS (_simwire._tcp). Pass host when discovery is blocked (guest Wi-Fi, VPN, Docker).

SentMessage

The handle returned by send().

MemberWhat it does
id, to, textWhat was sent, with the client-generated id echoed in every status.
statuspending → queued → sent → delivered, or failed.
waitForStatus("sent", timeoutMs?)Resolves when the status is reached; rejects with MessageFailedError on failure.
waitForDelivery(timeoutMs?)Shortcut for waitForStatus("delivered").
on("status", fn)Event per transition, with error when failed.
Delivery reports are carrier-dependent. Some networks never send them. Treat sent as success after a timeout rather than blocking forever on delivered.

Events

EventPayload
message{ from, text, simSlot, receivedAt: Date } — an SMS arrived on the SIM.
status{ message: SentMessage } — an outgoing message changed state.
state{ battery, charging, network } — phone heartbeat every 30s.
disconnect{ reason, willRetry } — the socket dropped.
reconnecting{ attempt, delayMs } — a retry is scheduled.
reconnected{ device, downtimeMs } — the session is back.

CLI

CommandWhat it does
simwire pairShows the pairing QR and waits for the phone.
simwire send <to> <text>Sends an SMS and streams statuses. Flags: --sim 1|2, --no-wait, --host, --port.
simwire listenPrints incoming SMS live. --forward <url> POSTs each one to your server.
simwire doctorDiagnoses pairing, discovery, reachability and SIM state, with fixes.
simwire unpairForgets the paired phone on this machine. Revoke it on the phone too from the app's Health screen.

Every command accepts --mock to run against the built-in mock device — try the whole CLI without a phone.

Remote servers

simwire is LAN-first: your server must be able to reach the phone. For a backend running in the cloud, put both on the same private network with Tailscale (or WireGuard):

# 1. Install Tailscale on the phone (Play Store) and on your server
# 2. Pair once from the server (the QR renders over SSH)
npx simwire pair
# 3. Connect using the phone's Tailscale IP
const sms = await connect({ host: "100.101.23.45" });

Traffic is end-to-end encrypted by WireGuard, and the phone stays reachable behind any NAT. A native relay mode is on the roadmap.

Losing the connection

Wi-Fi drops, phones sleep, laptops move between networks. The session retries on its own with a growing delay, rediscovering the phone each time in case its address changed.

sms.on("disconnect", ({ reason, willRetry }) => log(reason));
sms.on("reconnecting", ({ attempt }) => log(`attempt ${attempt}`));
sms.on("reconnected", ({ downtimeMs }) => log(`back after ${downtimeMs}ms`));

A send() issued while the session is down waits for it to come back, within the same queueTimeoutMs budget, instead of failing immediately. sms.connected tells you the current state.

const sms = await connect({
  reconnect: { initialDelayMs: 500, maxDelayMs: 15_000, maxAttempts: 10 },
});

await connect({ reconnect: false });   // opt out entirely

Test your own handling with the mock: sms.simulateDrop() takes the session down exactly like a lost network.

Unpairing

Pairing is two-sided, so revoking it is too.

npx simwire unpair   # forgets the phone on this machine

On the phone, open the app, go to Health and tap Unpair. That stops the gateway and revokes the token, so the computer can no longer send or receive anything through it.

Troubleshooting

npx simwire doctor

runs the whole checklist with fixes. The classics:

SymptomFix
connect() times outPhone and computer on different networks, or mDNS blocked. Pass connect({ host }) with the phone's IP.
Worked, then stoppedAndroid killed the app. Open Health in the app and disable battery optimization.
Stuck on "sent"Your carrier doesn't send delivery reports. Use waitForStatus("sent") instead.
send() fails instantlySMS permission missing on the phone: Health → SMS permissions.
Fair use: carriers throttle and ban SIMs that spam. simwire is for development, testing and small transactional volume — not bulk campaigns.