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

@lihi.io/sdk

v1.0.0-alpha

Published

The official Node client for the Lihi short URL and SMS APIs.

Readme

Lihi Node SDK

The official Node client for the Lihi short URL and SMS APIs.

Read this in another language: 繁體中文

This is a server-side SDK. Its credentials can create, change and delete records and send messages that cost money. Shipping them to a browser exposes your whole account, so the SDK refuses to run outside Node.


Requirements

  • Node 20.3 or newer
  • Nothing else. There are no runtime dependencies.

Installation

npm install @lihi.io/sdk

Getting credentials

Sign in at app.lihi.io and open API.

| API | Plan required | How to get the credential | | --- | --- | --- | | Basic | Starter | Available immediately on the API page | | SMS | Starter, plus a separate SMS key | Apply on the same page | | Short URL | Business, and the application must be approved | Apply on the same page and wait for approval |

The three credentials are not interchangeable. A key issued for one API is refused by the others, and the refusal does not say so — it is the most common first failure. Name the ones you have and the SDK will tell you which is missing before it sends anything.

Quickstart

import { Lihi } from '@lihi.io/sdk'

const lihi = new Lihi({
  basicApiKey: process.env.LIHI_BASIC_API_KEY,
  shortUrlApiKey: process.env.LIHI_SHORT_URL_API_KEY,
  smsToken: process.env.LIHI_SMS_TOKEN,
})

const { shortUrl } = await lihi.basic.shorten('https://example.com')
// https://lihi.cc/AbCdE

Each client can also be built on its own:

import { ShortUrlClient, Config } from '@lihi.io/sdk'

const shortUrls = new ShortUrlClient(process.env.LIHI_SHORT_URL_API_KEY!, new Config())

Choosing a client

  • Just shortening links? lihi.basic — one method, available on Starter.
  • Need click counts, edits, deletes or batches? lihi.shortUrl — requires an approved Business plan.
  • Sending SMS or one-time passwords? lihi.sms — uses the separate SMS credential.

Short URLs

const created = await lihi.shortUrl.create({
  longUrl: 'https://example.com',
  domain: 'your-verified-domain.com',
  slug: 'spring-sale',
  title: 'Spring Sale',
  tags: ['campaign'],
  expiredAt: new Date('2026-12-31T23:59:59+08:00'),
})

const record = await lihi.shortUrl.get('https://lihi.cc/AbCdE')  // null if there is none
const orFail = await lihi.shortUrl.getOrFail('https://lihi.cc/AbCdE')

record?.totalClick
record?.createdAt        // a Date, read in your configured timezone

await lihi.shortUrl.update({
  shortUrl: 'https://lihi.cc/AbCdE',
  longUrl: 'https://example.com/new',   // required: this replaces the record
  title: 'Updated',
})

await lihi.shortUrl.delete('https://lihi.cc/AbCdE')

click vs totalClick click is the figure exactly as the API reported it, which is not always comparable between records. totalClick sums the destinations itself and is consistent however you fetched the record. Prefer it.

Batches

// Up to 100 at a time. Keyed by what you asked for; a miss is null.
const results = await lihi.shortUrl.batchGet(['https://lihi.cc/one', 'https://lihi.cc/two'])

for (const [requested, record] of results) {
  console.log(requested, record?.totalClick ?? 'not found')
}

// Up to 5000 at a time. The batch name must be unique on your account.
const batch = await lihi.shortUrl.batchCreate({
  name: 'spring-campaign',
  domain: 'your-verified-domain.com',
  urls: [{ longUrl: 'https://example.com/a', slug: 'sale-a' }, { longUrl: 'https://example.com/b' }],
})

await lihi.shortUrl.batchDelete(['https://lihi.cc/one'])

SMS and one-time passwords

await lihi.sms.sendOtp('0912345678', 'YourBrand')

if (await lihi.sms.verifyOtp('0912345678', '1234')) {
  // verified
}

Phone numbers may be written in local or international form — the SDK converts them for you. The brand name is optional but must already be registered on your account. A wrong code returns false; it is an ordinary outcome, not an error. Malformed input still throws.

Links in messages need a registered domain. Taiwanese law requires that any URL in an SMS use a domain registered in advance, and a message carrying an unregistered one is rejected. This applies to short links as well as to your own site, so if you are sending into Taiwan from elsewhere, sign in to the Lihi console and get the carrier allowlist approval through before your first send.

Bulk sending

import { estimatePoints } from '@lihi.io/sdk'

const message = { phones: ['0912345678', '0987654321'], content: '【Acme】Your order has shipped' }

estimatePoints(message)   // check the cost before sending

const job = await lihi.sms.createBulk({ ...message, reservingTime: new Date('2026-12-31T23:59:59+08:00') })

const status = await lihi.sms.getTemplate(job.id)
status?.isPending
status?.isFinished
status?.hasFailures

await lihi.sms.cancelTemplate(job.id)

Bulk content has to open with the brand registered for your accountAcme, [Acme] and 【Acme】 are all accepted. Set the brand option and the SDK checks this before sending, so a batch is rejected here rather than by the carrier once the points have gone.

Up to 1000 recipients per request. Sending costs points: one per 70 characters per recipient for bulk, one per domestic OTP and five per international one. estimatePoints() is for pre-flight checks — the amount actually billed is decided by the service.

Only scheduled messages can be cancelled, and only while they are more than three minutes away. An immediate send cannot be called back.

Configuration

const lihi = new Lihi({ smsToken: '…', timeout: 5_000, brand: 'Acme' })

| Option | Default | Purpose | | --- | --- | --- | | basicApiKey / shortUrlApiKey / smsToken | none | One per API; supply the ones you use | | baseUri | https://app.lihi.io | Rarely needs changing | | timeout | 10_000 | Milliseconds per attempt | | retry | true | Retry safe requests — see below | | maxRetries | 2 | How many times | | retryUnsafeMethods | false | ⚠️ See the warning below | | defaultCountryCode | '886' | Used when converting local phone numbers | | timezone | 'Asia/Taipei' | The zone the API reads timestamps in | | brand | none | Checks bulk content opens with your registered brand | | fetch | global fetch | Inject your own, or a mock | | logger | none | debug / info / warning / error methods; credentials are never written to it | | userAgent | lihi-js/{version} | |

Unknown options are rejected rather than ignored, so a typo like timeOut fails loudly instead of silently keeping the default. Known options are type-checked for the same reason.

Every method takes an optional last argument for one call:

await lihi.shortUrl.get(url, { signal: controller.signal, timeout: 2_000 })

Error handling

Every failure is a LihiError. Because catch gives you unknown, the SDK exports guards rather than making you write instanceof:

import { isRateLimitError, isInsufficientPointsError, isLihiError } from '@lihi.io/sdk'

try {
  await lihi.sms.sendOtp('0912345678')
} catch (error) {
  if (isInsufficientPointsError(error)) { /* top up */ }
  else if (isRateLimitError(error)) { await wait(error.retryAfter ?? 60) }
  else if (isLihiError(error)) { log(`${error.requestSummary}: ${error.message}`) }
  else throw error
}
LihiError
├── AuthenticationError      credential missing, wrong or unknown
├── PermissionError          valid key, but your plan or key type is not allowed here
├── QuotaExceededError       out of short URL or batch allowance
├── UpstreamRejectedError    the request was rejected; retrying will not help
├── ValidationError          bad input, caught locally or by the API
│   └── InsufficientPointsError
├── NotFoundError            no such record
├── RateLimitError           rate limited or on cooldown; carries retryAfter
├── ServerError              a genuine server-side failure
├── TransportError           the request never arrived
│   └── TimeoutError         it may or may not have arrived
└── ConfigurationError       the SDK was used wrongly: a missing credential, an unknown option

Each error carries statusCode, errors, errorCode, requestSummary (safe to log — never contains credentials) and response.

The same status code can mean different things, so branch on the type, never on statusCode.

Retries

Only GET requests are retried by default. Writes are never retried automatically, because retrying one can send a second SMS, deduct points twice or create a duplicate short URL. You can opt in with retryUnsafeMethods, but read this first:

  • createBulk() timed out? Check with getTemplate(id). Do not resend.
  • create() timed out? Check with getByLongUrl().
  • batchCreate() failed? Fix the input before retrying — failed attempts still consume your batch allowance.

A cancellation you asked for is passed straight through as an AbortError, never retried and never wrapped.

Limits

| | Limit | | --- | --- | | Batch query | 100 URLs | | Batch create / delete | 5000 URLs | | Bulk SMS recipients | 1000 | | Tag length | 190 characters | | OTP resend cooldown | 60 seconds per number | | All endpoints | 1000 requests per minute |

Anything the SDK can check for you is checked before the request is sent.

Things worth knowing

An unrecognised domain is not an error. Pass a domain that is not verified on your account and you silently get one of your default domains instead. Check the returned URL if it matters.

slug, title, desc and image only apply on your own verified domains. Without one they are ignored, again with no error — you get a generated path. Compare the returned URL against the slug you asked for to detect this.

Long values are truncated rather than rejected. If a slug, title, description or destination URL matters, check it on the record you get back.

Timestamps are read in Asia/Taipei. Pass a Date and the SDK converts it for you. Pass a string and it is sent exactly as written.

Expiry deletes the record, it does not disable it.

A job's status never reports completion. It moves off pending only for a cancellation or a failure, so it does not mean "still running". Read successCount and failedCount against totalRecords instead; isFinished and hasFailures do that for you.

Testing your integration

MockFetch ships in the package and answers from a queue instead of the network, so your tests need no network and no other library:

import { Lihi } from '@lihi.io/sdk'
import { MockFetch } from '@lihi.io/sdk/testing'

const mock = new MockFetch()
mock.queueJson(200, { result: 'success', url: 'https://lihi.cc/AbCdE' })

const lihi = new Lihi({ basicApiKey: 'test-key', fetch: mock.fetch })

expect((await lihi.basic.shorten('https://example.com')).shortUrl).toBe('https://lihi.cc/AbCdE')

mock.assertRequestSent('POST', '/api/v1/shortening', (request) => {
  return (request.json() as { longUrl: string }).longUrl === 'https://example.com'
})

Queue: queueJson(), queueResponse(), queueError(). Assert: assertRequestSent(), assertNothingSent(), assertRequestCount(). Inspect: recordedRequests, lastRequest. Assertions throw MockFetchError rather than depending on any test framework.

Versioning

Semantic Versioning. Before 1.0 the public API may still change; every release says what changed on the releases page.

Support

  • Bugs and feature requests: GitHub issues
  • Account, billing and plan questions: Lihi
  • Domains: we recommend LihiDomain — buy one there and the short URL service comes with it

Keep your credentials out of version control — read them from the environment.

License

MIT