Skip to Content

Quickstart

One file, one dependency install, and a phone that sends a message on command. Everything on this page is copy-pasteable and uses only the published @luno-oss/* packages.

This page is the compressed path. Installation, Pairing and First message cover the same ground with the reasoning attached.

1. Install

npm install @luno-oss/core @luno-oss/hono hono @hono/node-server @hono/node-ws

@luno-oss/core is the engine; the adapter mounts it on the framework you run. Swap @luno-oss/hono for express, fastify, nestjs or cloudflare — the rest of this page is unchanged.

2. The server

server.ts
import { serve } from '@hono/node-server' import { createNodeWebSocket } from '@hono/node-ws' import { Hono } from 'hono' import { createLuno, memoryStore } from '@luno-oss/core' import { registerLuno } from '@luno-oss/hono' // The URL the phone will reach this server on. Must be HTTPS in production. const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000' const luno = createLuno({ store: memoryStore(), // swap for postgresStore(pool) in production secret: process.env.LUNO_SECRET!, // at least 16 characters, durable wsUrl: `${PUBLIC_URL.replace(/^http/, 'ws')}/ws` }) const app = new Hono() const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) // Mounts the node-facing surface: POST /enroll, POST /enroll/status, WS /ws registerLuno(app, { luno, upgradeWebSocket }) // ── Your own operator routes. Guard these with your own auth. ─────────────── app.post('/admin/pair', async c => { const { session, code, qrUri } = await luno.pairing.createSession({ label: 'Warehouse phone 1', backendUrl: PUBLIC_URL }) // `code` is plaintext and returned exactly once — only its hash is stored. return c.json({ sessionId: session.id, code, qrUri }) }) app.get('/admin/devices', async c => c.json(await luno.devices.list())) app.post('/admin/send', async c => { const { deviceId, to, body } = await c.req.json() const message = await luno.sms.send({ deviceId, to, body, ref: 'quickstart' }) return c.json({ id: message.id, status: message.status }) }) // ── React to what the node reports ────────────────────────────────────────── luno.on('device.online', ({ deviceId }) => console.log('online:', deviceId)) luno.on('sms.status', ({ message }) => console.log(`message ${message.id} → ${message.status}`) ) luno.on('sms.received', async ({ from, body }) => { console.log(`inbound from ${from}: ${body}`) }) const server = serve({ fetch: app.fetch, port: 3000 }) injectWebSocket(server)
LUNO_SECRET=please-change-this-secret npx tsx server.ts

memoryStore() loses everything on restart — including device credentials, which means every paired phone has to be re-paired. It is for development only. Use postgresStore for anything real.

3. Expose it over HTTPS

The node refuses plaintext transport, so localhost will not do unless you are running a debug build on the same LAN. For development, tunnel it:

cloudflared tunnel --url http://localhost:3000

Restart the server with PUBLIC_URL set to the tunnel’s HTTPS address, so the QR payload and wsUrl point where the phone can actually reach you.

4. Register the device

Mint a pairing session

curl -X POST https://your-tunnel.example.com/admin/pair
{ "sessionId": "ses_9f3", "code": "ABCD-1234", "qrUri": "luno://pair?v=1&u=https%3A%2F%2F…&c=ABCD-1234&s=ses_9f3&l=Warehouse+phone+1" }

Submit it from the phone

Open the Luno app, tap Pair device, and either scan qrUri rendered as a QR code or type ABCD-1234 by hand. Both carry identical authority.

Confirm

The app flips to Connected, and the server logs online: dev_….

curl https://your-tunnel.example.com/admin/devices

With the default policy the session is now spent: one device, ten-minute expiry, no replacement. Every one of those rules is server config, never an app release.

5. Send a message

curl -X POST https://your-tunnel.example.com/admin/send \ -H 'content-type: application/json' \ -d '{"deviceId":"dev_9","to":"+9779800000000","body":"Hello from Luno"}'

The server log follows the message through the radio:

message msg_1 → dispatched message msg_1 → accepted # written to the node's durable outbox message msg_1 → sent # left the device message msg_1 → delivered # carrier confirmed handset delivery

delivered depends on the carrier returning a delivery report — many routes never do. Treat sent as the last guaranteed transition and see Delivery reports.

If the device is offline the send still succeeds. The command is persisted and dispatched when the node reconnects.

6. Receive a message

Text the phone’s SIM from another handset. The sms.received handler fires:

inbound from +9779811111111: hi there

This needs the full build flavor, which declares RECEIVE_SMS. The sendOnly flavor cannot capture inbound messages — see Play Protect for why both exist.

Before you call it production

  • Guard the /admin/* routes. The engine authenticates nodes, never your operators. luno.sms.send sends to whoever asks — see the two axes.
  • Swap the store. memoryStore()postgresStore.
  • Keep secret durable. Rotating it invalidates every issued pairing code and device credential.
  • Set rate limits and an allowlist with luno.devices.updateConfig — a consumer SIM sending bulk traffic gets blocked by the carrier. Read Security.
  • Exempt the app from battery optimisation on the handset, or an OEM skin will eventually kill the agent. See OEM reliability.

Next

  • Testing — run a fake node against this server in CI, no phone required
  • Protocol — what is actually on the wire
  • Operations — running more than one device