Skip to content

Repository files navigation

Lihi Node SDK

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

Read this in another language: 繁體中文 · 简体中文 · 日本語 · 한국어 · Español · Português · Français · Italiano · Русский

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

About

Shorten URLs, send SMS, and verify OTP codes from Node.js — the official TypeScript client for the Lihi API

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages