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" | Member | What it does |
|---|---|
| outbox / inbox | Arrays 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().
| Member | What it does |
|---|---|
| id, to, text | What was sent, with the client-generated id echoed in every status. |
| status | pending → 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. |
sent as success after a timeout
rather than blocking forever on delivered.Events
| Event | Payload |
|---|---|
| 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
| Command | What it does |
|---|---|
| simwire pair | Shows 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 listen | Prints incoming SMS live. --forward <url> POSTs each one to your server. |
| simwire doctor | Diagnoses pairing, discovery, reachability and SIM state, with fixes. |
| simwire unpair | Forgets 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:
| Symptom | Fix |
|---|---|
| connect() times out | Phone and computer on different networks, or mDNS blocked. Pass connect({ host }) with the phone's IP. |
| Worked, then stopped | Android 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 instantly | SMS permission missing on the phone: Health → SMS permissions. |