@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/sdkGetting 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/AbCdEEach 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')
clickvstotalClickclickis the figure exactly as the API reported it, which is not always comparable between records.totalClicksums 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 account —
Acme, [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 optionEach 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 withgetTemplate(id). Do not resend.create()timed out? Check withgetByLongUrl().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
