SMSTunnel for Node.js

Send texts from your own Node.js apps, from your own number

npm package · Node.js 18+ · MIT
npm install @narcisbodea/smstunnel-sdk

Your app writes, your phone sends

Your app hands the text to your SMSTunnel account and your own Android phone sends it from its own SIM, so people see a number they know and can reply. You can cut an app off at any time by deleting its API key in your dashboard.

What it does

Send a text
One text to one number, up to 1600 characters, from the phone you choose or one of yours.
Sign-in codes
A code of 4 to 10 digits, on its own daily allowance.
Delivery status
Pending, sent, delivered or failed, unconfirmed when the phone got no confirmation, and why.
Received texts
The texts your phone receives, only with a key you allowed to read them.
Phones and usage
Which of your phones are online, your plan and what is left today.
End-to-end encryption
One call encrypts a text with your phone's key, so only your phone can read the words.
Express and NestJS
The client in your routes, and SmsTunnelService to inject in NestJS.
Requirements
Node.js 18 or newer, ESM or CommonJS, with TypeScript types.

1. Install

npm install @narcisbodea/smstunnel-sdk

Node.js 18 or newer. The client is @narcisbodea/smstunnel-sdk/node: it calls SMSTunnel with Node's own fetch, from ESM or CommonJS.

The TypeScript types come with the package. Since 2.2.0 they are found with any moduleResolution, the older "node" of NestJS 10 projects included (2.1.1 reported TS2307 there).

2. Get your key

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick Node.js, name it (for example "My Node.js app") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Node.js app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with Node.js picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

The key can send texts and look up whether they went out. Only if your app reads the texts your phone receives: also tick Can also read received texts, and turn on syncing of received texts in the SMSTunnel app. It's off by default, and only texts that arrive after you turn it on are synced. Keep the key out of your code: put it in an environment variable such as SMSTUNNEL_API_KEY.

3. Send a text

Put the key in a .env file next to your code, and keep the file out of your repository:

SMSTUNNEL_API_KEY=sk_live_...
// send.mjs. Run it: node --env-file=.env send.mjs
import { SmsTunnelNodeClient } from '@narcisbodea/smstunnel-sdk/node';

const client = new SmsTunnelNodeClient({ apiKey: process.env.SMSTUNNEL_API_KEY });

const result = await client.sendSms('+14155550123', 'Your order #1042 has shipped.');

console.log(result.messageId); // keep it to look up the status later

--env-file needs Node.js 20.6 or newer; on Node.js 18, set the variable in your shell instead. In CommonJS, the same client comes with require:

// send.cjs
const { SmsTunnelNodeClient } = require('@narcisbodea/smstunnel-sdk/node');

const client = new SmsTunnelNodeClient({ apiKey: process.env.SMSTUNNEL_API_KEY });

client
  .sendSms('+14155550123', 'Your order #1042 has shipped.')
  .then((result) => console.log(result.messageId))
  .catch((error) => {
    console.error(error.message);
    process.exitCode = 1;
  });

Make one client and reuse it: it holds only your settings, so calls can run at the same time. Without a key, new SmsTunnelNodeClient() throws API key is required.

All the options, and what comes back:

const result = await client.sendSms(
  '+14155550123',                // international format; one number per call
  'See you at 10:30.',           // up to 1600 characters
  '6aea3248fedd7cccac9abf42',    // the phone that sends it (Phones → Copy); undefined lets SMSTunnel pick one of yours
  { defaultCountryCode: '+40' }, // completes numbers written without + or 00, like 0721 123 456
);

result.messageId;      // string
result.queued;         // true when no phone of yours was online: the text waits and goes out when one is back
result.data.branded;   // true when the Free plan added its short line to the text
result.data.remaining; // left today, e.g. { clean: 1985, total: 1985 } (-1 and 'unlimited' when there is no limit)

One call sends one text to one number. To text several people, call sendSms() once for each.

Sign-in and verification codes

await client.send2FaSms('+14155550123', 'Your sign-in code is 482913');
// a third argument takes { deviceId, defaultCountryCode }, as for a text

The text must contain the code as a number of its own, 4 to 10 digits (482913 or 123-456), and be at most 320 characters; otherwise SMSTunnel refuses it (400). Codes have their own daily allowance. Use it for codes only. What counts as a code

Look up a status

const { data: status } = await client.getMessageStatus(result.messageId); // needs sms:read

status.status;         // 'pending', 'sent', 'delivered' or 'failed'
status.deliveryStatus; // the same, or 'unconfirmed': handed to the SIM, but the phone got no confirmation
status.sentAt;         // when it went out (ISO 8601, UTC)
status.errorMessage;   // why it failed, or null

An unconfirmed text shows in status as 'failed', with unconfirmed: true; deliveryStatus has the real state. The phone handed it to the SIM but never got the confirmation: it most likely went out, and it is never resent automatically. Don't resend it yourself.

Your phones and your usage

const { data: phones } = await client.getDevices();
for (const phone of phones.devices) {
  console.log(phone.id, phone.name, phone.isOnline ? 'online' : 'offline'); // and phone.lastSeenAt
}

const { data: usage } = await client.getUsage();
usage.plan;                 // 'free', 'twofa' (Starter), 'standard', 'pro' (Professional) or 'enterprise'
usage.usage.smsClean;       // texts sent today
usage.remaining.smsClean;   // texts left today, or 'unlimited'
usage.remaining.twoFaClean; // sign-in codes left today, or 'unlimited'

Read received texts

Needs a key you allowed to read received texts (Can also read received texts) and syncing turned on in the app.

const { data: page } = await client.getReceivedSms({
  since: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // the last 24 hours
  phone: '+14155550123', // only from this number
  limit: 20,             // 1 to 100
  page: 1,
});

for (const text of page.messages) { // newest first
  console.log(text.receivedAt, text.sender, text.message);
}

page.hasMore; // ask for the next page

SMSTunnel can also post each received text to your own server instead: in your dashboard, Apps → Forward received texts (how it works).

End-to-end encryption

await client.encryptAndSendSms('+14155550123', 'Your order has shipped.', '6aea3248fedd7cccac9abf42');

It fetches your phone's public key, encrypts the number and the words with it, and sends the result: SMSTunnel keeps the number for your Messages page but can't read the words. The phone needs encryption turned on in the SMSTunnel app; otherwise the call fails with a 400. What is encrypted, and Maximum privacy

Express: a text to you for each order

// server.mjs. Run it: node --env-file=.env server.mjs
import express from 'express';
import { SmsTunnelNodeClient } from '@narcisbodea/smstunnel-sdk/node';

const sms = new SmsTunnelNodeClient({ apiKey: process.env.SMSTUNNEL_API_KEY });
const OWNER_PHONE = process.env.OWNER_PHONE; // your own number, e.g. +14155550123

const app = express();
app.use(express.json());

app.post('/orders', async (req, res) => {
  const order = await saveOrder(req.body); // your own code
  res.status(201).json({ id: order.id });

  // After the answer, so the customer never waits for the text.
  try {
    await sms.sendSms(OWNER_PHONE, `New order #${order.id} from ${order.name}: ${order.total}`);
  } catch (error) {
    console.warn(`The text about order #${order.id} did not go out: ${error.message}`);
  }
});

app.listen(3000);

For Express 5. Add OWNER_PHONE=+14155550123 to the same .env file. The order is saved and answered first, so a text that doesn't go out is only logged and never fails the order. A text to a customer goes to the number they gave you, in international format.

NestJS

The package's part for NestJS is SmsTunnelService, from @narcisbodea/smstunnel-sdk/server. Give Nest the service and its settings in a module of your own, with the key from the environment:

// sms.module.ts
import { Module } from '@nestjs/common';
import {
  SMSTUNNEL_OPTIONS,
  SmsTunnelService,
  type SmsTunnelModuleOptions,
} from '@narcisbodea/smstunnel-sdk/server';

const smsTunnel: SmsTunnelModuleOptions = {
  // The key stays in the environment: there is nothing for the service to save.
  storage: {
    getConfig: async () => ({
      serverUrl: 'https://smstunnel.io',
      apiKey: process.env.SMSTUNNEL_API_KEY ?? '',
    }),
    updateConfig: async () => {},
    clearConfig: async () => {},
  },
  callbackBaseUrl: '', // used only to link a phone by QR code
};

@Module({
  providers: [{ provide: SMSTUNNEL_OPTIONS, useValue: smsTunnel }, SmsTunnelService],
  exports: [SmsTunnelService],
})
export class SmsModule {}

Import SmsModule where texts go out, and inject the service:

// orders.service.ts, in a module with imports: [SmsModule]
import { Injectable, Logger } from '@nestjs/common';
import { SmsTunnelService } from '@narcisbodea/smstunnel-sdk/server';

@Injectable()
export class OrdersService {
  private readonly log = new Logger(OrdersService.name);

  constructor(private readonly sms: SmsTunnelService) {}

  async markShipped(order: Order): Promise<void> {
    // ...save the order first
    const result = await this.sms.sendSms(order.phone, `Your order #${order.id} has shipped.`);
    if (!result.success) {
      const why = `${result.status ?? 'no answer'} ${result.error}`;
      this.log.warn(`The shipping text for order #${order.id} did not go out: ${why}`);
    }
  }
}

The service never throws: each call returns SMSTunnel's answer, or { success: false, error, status, code } when it doesn't go through (status is missing when SMSTunnel couldn't be reached).

sendSms(to, message)
One text to one number, from one of your phones. It takes no phone and no country code.
send2fa(to, code, template?)
A sign-in code: the template with {code} replaced, as in send2fa('+14155550123', '482913', 'Shop: {code} is your sign-in code'). Without a template: "Your verification code: 482913".
getSmsStatus(messageId)
The same answer as getMessageStatus() above.
getReceivedSms(query), getDevices(), getUsage()
The same answers as above, or the same failure object.

Its calls have no timeout of their own. For a timeout, the phone that sends or a country code, give Nest the Node client instead, and inject SmsTunnelNodeClient (its errors are thrown, see Errors):

// sms.module.ts
import { Module } from '@nestjs/common';
import { SmsTunnelNodeClient } from '@narcisbodea/smstunnel-sdk/node';

@Module({
  providers: [
    {
      provide: SmsTunnelNodeClient,
      useFactory: () => new SmsTunnelNodeClient({ apiKey: process.env.SMSTUNNEL_API_KEY ?? '' }),
    },
  ],
  exports: [SmsTunnelNodeClient],
})
export class SmsModule {}

The package's README registers SmsTunnelModule.forRoot() instead, which also mounts routes to link a phone by QR code. It works on NestJS 10, 11 and 12 since 2.2.0 (2.1.1 stopped a NestJS 11 or 12 app at startup). To send with a key from the environment you don't need those routes: the service registered as above is enough.

Errors

The client's calls reject when a text doesn't go through:

SmsTunnelApiError
SMSTunnel answered with an error: status (the HTTP status), code (such as PERMISSION_REQUIRED), permission, body (the whole answer), message (SMSTunnel's explanation) and retryAfter (the seconds to wait, when SMSTunnel says).
401
The API key is wrong, was deleted or turned off, or its account is suspended ("Invalid or expired API key").
403
The key lacks a permission (code is PERMISSION_REQUIRED and permission names it, e.g. sms:inbox), or the plan doesn't include the feature.
429
Too many requests in a minute: error.retryAfter says how many seconds to wait. Or the plan's limit for the day or month is reached (the message says so: "You reached today's limit…"), with no retryAfter: it lasts until the next day (UTC) or month.
409, 422
From a send with an idempotencyKey. 409 (code IDEMPOTENCY_KEY_IN_PROGRESS): the first try with that key is still running; repeat the same call after error.retryAfter seconds. 422 (code IDEMPOTENCY_KEY_REUSED): the key came with a different text, number, phone or country code; a new text needs a new key.
400, 404, 5xx
400: a number SMSTunnel can't read, or a code text without a code. 404: no such message or phone. 5xx: a problem on SMSTunnel's side.
AbortError
A DOMException: no answer within the timeout (30 seconds by default). The text may still go out.
TypeError: fetch failed
SMSTunnel could not be reached, and error.cause says why. Nothing went out when the connection never opened (ENOTFOUND, ECONNREFUSED).
import { SmsTunnelApiError } from '@narcisbodea/smstunnel-sdk/node';

try {
  await client.sendSms(number, text);
} catch (error) {
  if (error instanceof SmsTunnelApiError) {
    // SMSTunnel answered: error.status, error.code (e.g. 'PERMISSION_REQUIRED'), error.permission, error.body,
    // error.retryAfter (seconds, on a 429 for too many requests)
    console.warn(`SMSTunnel ${error.status} ${error.code ?? '-'}: ${error.message}`);
  } else if (error.name === 'AbortError') {
    // no answer within the timeout: the text may still go out, so check before you send it again
  } else {
    // SMSTunnel could not be reached: error.cause says why (ENOTFOUND, ECONNREFUSED, a TLS error)
  }
}

Check instanceof SmsTunnelApiError first: a timeout's DOMException has a code of its own (20). A phone that is offline is not an error: the text waits on your account (queued is true) and goes out when the phone is back, within 3 days.

Retrying a send

The client never retries on its own. After a timeout, a 5xx or a broken connection, the text may already be on its way: sent again without a key, the person can get it twice. So give each text an idempotencyKey (2.2.0 and newer): one new key per text, saved with it and passed again on every retry. For 24 hours, a repeat with the same key gets the first answer back, with the same messageId, instead of a second text.

import { randomUUID } from 'node:crypto';

// One key per text, saved with it (or your own id, such as 'order-1042-shipped').
const key = randomUUID();

// The first try and every retry of this same text pass the same key:
const result = await client.sendSms(number, text, undefined, { idempotencyKey: key });
// A repeat within 24 hours answers with the first result (the same messageId) instead of sending again.

After a 429 for too many requests, wait error.retryAfter seconds and try again, a few times at most; a 429 for the plan's limit lasts until the next day (UTC) or month. After a 400, 401 or 403, fix the cause first. Without a key, look a text up in your dashboard's Messages before you send it again. More in the API reference: Retrying Safely.

Settings and tests

const client = new SmsTunnelNodeClient({
  apiKey: process.env.SMSTUNNEL_API_KEY,
  timeout: 15_000,                    // per call, in milliseconds; the default is 30 seconds
  serverUrl: 'http://127.0.0.1:3141', // a test server of your own; the default is https://smstunnel.io
});

Every call is one HTTPS request to serverUrl with your key in the X-API-Key header. In your own tests, point serverUrl at a local test server, or replace the client with a mock.

Limits worth knowing

  • 30 sends a minute per key; the keys of one account together get three times that. Over it, an SmsTunnelApiError with status 429: wait retryAfter seconds (about a minute).
  • Plans: Free, Starter and Standard have a daily and a monthly allowance; Professional and Enterprise have no daily cap. See Pricing.
  • Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
  • Numbers: use the international format (+14155550123). For local numbers, pass defaultCountryCode (+49, 49 and 0049 all work): it always takes precedence over the phone's country.
  • Offline phone: the text waits (queued) and goes out when the phone is back online.

Build it with an AI assistant

Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.

Help me send text messages from my Node.js app with SMSTunnel.

About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.

What I want: [describe what should send a text, to whom, and when]

How it works:
- Use the official npm package: npm install @narcisbodea/smstunnel-sdk (2.2.0; Node.js 18+, ESM or CommonJS, TypeScript types). Plain Node.js and Express: import { SmsTunnelNodeClient, SmsTunnelApiError } from '@narcisbodea/smstunnel-sdk/node'; const client = new SmsTunnelNodeClient({ apiKey: process.env.SMSTUNNEL_API_KEY }); make one client and reuse it.
- client.sendSms(to, message, deviceId?, { defaultCountryCode, idempotencyKey }?) returns { success, messageId, queued, data: { branded, remaining } }; queued is true when no phone of mine is online, and the text goes out when one is back. One call sends one text to one number: loop for several.
- Sign-in codes: client.send2FaSms(to, "Your sign-in code is 482913", { deviceId, defaultCountryCode, idempotencyKey }?); the text must contain the code as 4 to 10 digits standing on their own (or 123-456) and be at most 320 characters. Codes have their own daily allowance.
- client.getMessageStatus(messageId) returns data.status (pending, sent, delivered or failed) and data.deliveryStatus. deliveryStatus "unconfirmed" (status "failed" with unconfirmed: true) means the phone handed it to the SIM without a confirmation: it most likely went out, so never resend it automatically. getReceivedSms({ since, phone, limit, page }) needs a key allowed to read received texts; getDevices() lists my phones and getUsage() what is left today.
- Errors: an error answer throws SmsTunnelApiError (status, code such as PERMISSION_REQUIRED, permission, body, message, retryAfter). After a 429 for too many requests, wait error.retryAfter seconds (about a minute); a 429 whose message says the plan's limit is reached has no retryAfter and lasts until the next day or month. No answer within the timeout (30 s; the timeout option is in milliseconds) rejects with a DOMException named AbortError; an unreachable server with TypeError "fetch failed" (error.cause says why). new SmsTunnelNodeClient() throws "API key is required" when the key is empty.
- The client never retries on its own. After a timeout, a 5xx or a broken connection the text may already have gone out, so a send that may be retried needs an idempotencyKey: one new key per text (crypto.randomUUID(), or my own id such as order-1042-shipped), saved with the text and passed again on every retry of it. A repeat within 24 hours gets the first answer back (the same messageId) instead of a second text; the same key with a different text, number, phone or country code is refused (422, code IDEMPOTENCY_KEY_REUSED); a 409 with code IDEMPOTENCY_KEY_IN_PROGRESS means the first try is still running: wait error.retryAfter seconds and repeat the same call. Without a key, never resend a text automatically.
- NestJS: register the package's SmsTunnelService in my own module: providers [{ provide: SMSTUNNEL_OPTIONS, useValue: { storage: { getConfig: async () => ({ serverUrl: 'https://smstunnel.io', apiKey: process.env.SMSTUNNEL_API_KEY ?? '' }), updateConfig: async () => {}, clearConfig: async () => {} }, callbackBaseUrl: '' } }, SmsTunnelService], exported (both from '@narcisbodea/smstunnel-sdk/server'). (SmsTunnelModule.forRoot() works too since 2.2.0, but it also mounts routes for linking a phone by QR code, which a key from the environment doesn't need.) SmsTunnelService.sendSms(to, message), send2fa(to, code, template?) ({code} is replaced) and getSmsStatus(messageId) never throw: they return { success: false, error, status?, code? }, and they have no timeout of their own. For a timeout, the phone or a country code, provide SmsTunnelNodeClient with useFactory instead.
- In a web server: the key from the environment, one client for the whole app, and the text sent once the change is saved (after the response, or from a job queue), so a slow network never holds up a request.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.

Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts.

Responsible use

Built for alerts to yourself and your team, and texts people expect from you. One text goes to one number. Sending unsolicited messages from a consumer SIM generally breaches mobile carrier terms of service.

Node.js is a trademark of the OpenJS Foundation. Express and NestJS are trademarks of their owners. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by the OpenJS Foundation or the owners of Express and NestJS.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play