npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cloudtext/sdk

v0.2.0

Published

Official JavaScript SDK for cloudtext.frionode.online, the open source SMS gateway

Readme

@cloudtext/sdk

npm CI license

Official JavaScript SDK for cloudtext, the open source SMS gateway that turns an Android phone into an SMS API.

Zero dependencies, TypeScript types included, works in Node 18+, Bun, Deno, Cloudflare Workers, and Vercel Edge.

Install

npm install @cloudtext/sdk
pnpm add @cloudtext/sdk

Quickstart

Get an API key from the cloudtext dashboard, then:

import { Cloudtext } from '@cloudtext/sdk'

const cloudtext = new Cloudtext({ apiKey: process.env.CLOUDTEXT_API_KEY })

await cloudtext.sendSms({
  recipients: ['+12025550123'],
  message: 'Hello from cloudtext!',
})

Send options

sendSms needs only message and recipients. Everything else is optional.

| Option | Type | What it does | | --- | --- | --- | | deviceId | string | Which phone sends the message. Omit it and cloudtext uses your default device, or the enabled device with the most recent heartbeat. | | simSubscriptionId | number | Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. | | scheduledAt | string \| Date | Send later instead of now. ISO 8601 or a Date, must be in the future, up to 72 hours ahead. |

await cloudtext.sendSms({
  recipients: ['+12025550123'],
  message: 'Your appointment is tomorrow at 9am',
  deviceId: '65f0000000000000000000aa',
  simSubscriptionId: 2,
  scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
})

Finding your simSubscriptionId

Open the cloudtext Android app, go to Dashboard, and find the SIM Cards section. Each SIM shows its subscription id with a copy button.

Be aware that this value is not validated. If the id does not match a SIM currently in the phone it is ignored, and the message goes out from the preferred or default SIM instead. Nothing errors, so confirm which SIM was used by checking the number the message arrived from.

Devices

const devices = await cloudtext.getDevices()
const device = await cloudtext.getDevice(deviceId)

// Change which device handles sends that omit deviceId
await cloudtext.setDefaultDevice(deviceId)

Messages and delivery status

History is account-level: one call covers every device, and deviceIds narrows it.

// Paginated history across the whole account, filterable and searchable
const { data, meta } = await cloudtext.getMessages({
  direction: 'received', // 'all' | 'sent' | 'received'
  deviceIds: [deviceId], // omit for every device
  status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends
  search: 'invoice',
  from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
  to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
  page: 1,
  limit: 50,
})

// direction on each message is lowercase and feeds straight back into filters
data.filter((m) => m.direction === 'received')

// Which recipients of a bulk send failed: filter by the batch a send returned
const { smsBatchId } = await cloudtext.sendSms({ recipients, message })
const failed = await cloudtext.getMessages({ smsBatchId, status: 'failed' })

// Drain everything matching a filter: iterateMessages follows the
// pagination cursor for you until there is nothing left
for await (const message of cloudtext.iterateMessages({ direction: 'received', order: 'asc' })) {
  console.log(message.sender, message.message)
}

// A single message and its current status
const sms = await cloudtext.getSms(deviceId, smsId)

// A whole batch, using the smsBatchId returned by sendSms
const { batch, messages } = await cloudtext.getSmsBatch(deviceId, smsBatchId)

Verifying webhooks

cloudtext signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the X-Signature header. Pass the raw request body, not a re-serialized object, whenever your framework gives you access to it.

import { verifyWebhookSignature } from '@cloudtext/sdk'

app.post('/webhooks/cloudtext', express.raw({ type: 'application/json' }), async (req, res) => {
  const valid = await verifyWebhookSignature({
    payload: req.body.toString('utf8'),
    signature: req.get('x-signature'),
    signingSecret: process.env.CLOUDTEXT_WEBHOOK_SECRET,
  })

  if (!valid) return res.sendStatus(401)

  const event = JSON.parse(req.body.toString('utf8'))
  res.sendStatus(200)
})

SMS utilities

Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just cloudtext. Import only what you need and the rest is tree-shaken away.

Segments and encoding

Carriers bill per segment, not per message. A message stays in the 7-bit GSM alphabet at 160 characters per segment, but a single character outside that alphabet, one emoji or one curly quote, switches the whole message to UCS-2 and drops the limit to 70.

import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@cloudtext/sdk'

countSmsSegments('Your code is 123456')
// { encoding: 'gsm-7', length: 19, segments: 1, remainingInSegment: 141 }

countSmsSegments('Your code is 123456 🎉')
// { encoding: 'ucs-2', length: 22, segments: 1, remainingInSegment: 48 }

getSmsEncoding('plain ascii') // 'gsm-7'
findNonGsm7Characters('Hi 🎉') // ['🎉']

Longer messages are split, and concatenation headers shrink each segment to 153 characters (GSM-7) or 67 (UCS-2). remainingInSegment counts single-unit characters, so a two-unit character such as an emoji or € may not fit even when it reads as 1.

Keeping messages in GSM-7

Text pasted from a word processor or a CMS is full of curly quotes, ellipses, and non-breaking spaces. sanitizeForGsm7 swaps them for plain equivalents so a message does not silently cost three times as much.

import { sanitizeForGsm7, countSmsSegments } from '@cloudtext/sdk'

const pasted = '“Your order shipped…”'
countSmsSegments(pasted).encoding // 'ucs-2'

const clean = sanitizeForGsm7(pasted) // '"Your order shipped..."'
countSmsSegments(clean).encoding // 'gsm-7'

// Optionally strip accents that GSM-7 does not carry. Letters it does carry,
// like é, ü, and ñ, are always left alone.
sanitizeForGsm7('naïve', { transliterateAccents: true }) // 'naive'

It is best effort: characters with no safe equivalent pass through untouched. Check the result with getSmsEncoding and see what is left with findNonGsm7Characters.

Phone number helpers

import { isValidE164, normalizePhoneNumber } from '@cloudtext/sdk'

isValidE164('+12025550123') // true
isValidE164('202-555-0123') // false

normalizePhoneNumber('+1 (202) 555-0123') // '+12025550123'
normalizePhoneNumber('0012025550123') // '+12025550123'
normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }) // '+12025550123'
normalizePhoneNumber('not a number') // null

These are format-only helpers, not libphonenumber. They know nothing about country dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns null; an unusable defaultCountryCode throws a TypeError.

Errors

Any non-2xx response throws a CloudtextError carrying the status and the parsed body. Network failures reject with the underlying fetch error instead.

import { CloudtextError } from '@cloudtext/sdk'

try {
  await cloudtext.sendSms({ recipients: ['+12025550123'], message: 'hi' })
} catch (error) {
  if (error instanceof CloudtextError) {
    console.error(error.status, error.message)
  } else {
    throw error
  }
}

Client options

new Cloudtext({
  apiKey: 'your-api-key',
  baseUrl: 'https://cloudtextapi.frionode.online/api/v1', // override for self-hosted instances
})

What is not covered yet

The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at cloudtext.frionode.online/docs.

Community and support

Questions or feedback? Join the community on Discord or email [email protected].

License

MIT. Part of the cloudtext project.