Python SDK for the Lihi short-URL and SMS API.
Read this in another language: 繁體中文 · 简体中文 · 日本語 · 한국어 · Español · Português · Français · Italiano · Русский
- One exception hierarchy across every endpoint — the API's error formats vary, and the SDK normalises them
- Fully typed,
py.typedincluded - Client-side checks that stop billable mistakes before they are sent
- One runtime dependency:
httpx
pip install lihiRequires Python 3.10 or newer.
from lihi import Lihi
with Lihi(short_url_api_key="...") as client:
link = client.short_url.create("https://example.com/a-long-page")
print(link)Each API has its own credential; pass only the ones you use. Calling a service
you have no key for raises ConfigurationError immediately, without a request.
client = Lihi(
basic_api_key="...",
short_url_api_key="...",
sms_api_key="...",
)Close the client when you are done, or use it as a context manager. If you pass
your own http_client, closing it stays your responsibility.
import asyncio
from lihi import AsyncLihi
async def main() -> None:
async with AsyncLihi(short_url_api_key="...") as client:
link = await client.short_url.create("https://example.com")
print(link)
asyncio.run(main())Build one client per event loop: the underlying connection pool binds to the
loop that created it, so a module-level singleton breaks in a script that calls
asyncio.run() more than once.
link = client.short_url.create(
"https://example.com",
slug="spring-sale",
title="Spring sale",
tags=["campaign", "2026"],
expired_at=datetime(2026, 12, 31, 23, 59, 59),
)
record = client.short_url.get(link) # None if there is no such link
record = client.short_url.get_or_raise(link) # raises NotFoundError instead
print(record.click, record.total_click, record.created_at)
client.short_url.update(link, "https://example.com/new-target")
client.short_url.delete(link)update replaces rather than patches, which is why both URLs are required
positional arguments — omitting the target would blank it.
click is reported by the API and counted differently by the single and batch
endpoints, so do not compare it across the two. total_click is summed by the
SDK and means the same thing everywhere.
found = client.short_url.batch_get(["https://lihi.io/a", "https://lihi.io/b"])
# {"https://lihi.io/a": ShortURL(...), "https://lihi.io/b": None}
result = client.short_url.batch_create(
"spring campaign",
"lihi.io",
[BatchURL(url="https://example.com/1"), BatchURL(url="https://example.com/2")],
)
print(result.count, result.short_urls)
client.short_url.batch_delete(["https://lihi.io/a"])batch_get keys the result by the exact strings you passed in, so duplicates
collapse into one entry. Every input gets a key; a link that does not exist maps
to None.
client.sms.send_otp("0912345678") # normalised to E.164
ok = client.sms.verify_otp("0912345678", "123456")
template = client.sms.create_bulk(
["0912345678", "0987654321"],
"Your order has shipped.",
)
status = client.sms.get_template(template.id)
if status and status.is_finished:
print(status.success_count, status.failed_count)
client.sms.cancel_template(template.id)OTP numbers are normalised using the client's default_country_code. Spaces,
dashes, dots and brackets are stripped first, so all of these reach the same
number:
| Given | Sent |
|---|---|
0912345678 |
+886912345678 |
09 1234-5678 |
+886912345678 |
912345678 |
+886912345678 |
00886912345678 |
+886912345678 |
+886912345678 |
+886912345678 |
Write the country code with a +, or leave it off entirely. A number that
opens with the country code and no + — 886912345678 — is read as a local
number, and the country code is prefixed on top of it. Use +886912345678 or
0912345678 instead.
Bulk recipients are sent exactly as given, with no normalisation.
verify_otp returns False only when the code was wrong. A server fault, a
rate limit or a bad credential still raises.
from lihi import estimate_points
points = estimate_points(phones, content)One point per 70 characters, per recipient, counted in code points — an emoji is one character. This is an estimate; your account's rate is what bills.
If your account has a registered brand, set it and the SDK will check that bulk content opens with it:
client = Lihi(sms_api_key="...", brand="Acme")
client.sms.create_bulk(phones, "[Acme] Your order has shipped.")Acme …, [Acme] … and 【Acme】… all pass. The SDK never adds the prefix for
you, because adding characters would change the point cost after you had
already estimated it.
Everything raised inherits from LihiError:
LihiError
├── ConfigurationError the client is set up wrong
├── AuthenticationError the credential was rejected
├── PermissionDeniedError valid credential, not allowed to do this
├── QuotaExceededError an account limit was reached
├── UpstreamRejectedError refused, without a stated reason
├── ValidationError malformed request, or a client-side check
│ └── InsufficientPointsError
├── NotFoundError
├── RateLimitError carries retry_after and retryable
├── ServerError
└── TransportError the request never completed
└── RequestTimeoutError
from lihi import LihiError, RateLimitError, ValidationError
try:
client.sms.create_bulk(phones, content)
except InsufficientPointsError:
... # also caught by `except ValidationError`
except RateLimitError as exc:
time.sleep(exc.retry_after or 60)
except LihiError as exc:
log.error("%s (%s)", exc, exc.code)Every error carries message, status_code, code, errors and
request_summary. errors is always a dict, so exc.errors.get("phone") is
safe without a guard. response holds the raw response and is readable —
exc.response.text works.
A timeout is worth handling separately: the message says the write may already have been applied, because that is the only thing you can act on.
except RequestTimeoutError:
# Query before retrying — the request may have gone through.GET requests are retried twice by default, on 5xx, 429 and transport failures,
backing off 0.5s, 1s, 2s. A Retry-After header is honoured up to 30 seconds;
past that the SDK stops rather than stalling your process.
Writes are never retried automatically, because repeating one can double-charge you or send a message twice. If you want that anyway:
Lihi(..., retry_unsafe_methods=True) # repeats writes; use with careRateLimitError.retryable tells you whether retrying by hand is worth it. It is
not what drives the SDK's own retries — those go by the status code.
lihi.testing ships with the package, so you can exercise your error handling
without a network:
from lihi.testing import MockAPI
def test_handles_a_taken_slug():
mock = MockAPI()
mock.queue_json(400, {"result": "failed", "msg": {"error": "the slug has already been taken"}})
with mock.client(short_url_api_key="k") as client:
with pytest.raises(ValidationError) as caught:
client.short_url.create("https://example.com", slug="taken")
assert caught.value.code == "slug_taken"
mock.assert_request_sent("POST", "/api/v1/url")mock.client() and mock.async_client() both work from the same MockAPI.
Queue responses with queue_json, queue_response, queue_stream or
queue_error; inspect what was sent with requests, last_request and the
assert_* helpers. An unstubbed request fails loudly rather than looking like
an empty 200.
| Option | Default | Notes |
|---|---|---|
base_url |
https://app.lihi.io |
|
timeout |
10.0 |
Per attempt, not per call |
retry |
True |
GET only, unless retry_unsafe_methods |
max_retries |
2 |
|
retry_unsafe_methods |
False |
Repeats writes; can double-charge |
default_country_code |
"886" |
Used for OTP normalisation |
timezone |
"Asia/Taipei" |
Wall-clock timestamps are read in this zone |
brand |
None |
Enables the bulk-content prefix check |
http_client |
built for you | Pass your own; you then close it |
logger |
None |
No logging unless you pass one |
user_agent |
lihi-python/{version} |
Timestamps sent and received have no zone marker and are wall-clock time in
timezone. A naive datetime is taken to be in that zone already; an aware one
is converted into it.
Pass a logger to see requests, retries and failures. Credentials, query strings and bodies are never logged.
import logging
Lihi(..., logger=logging.getLogger("lihi"))from lihi import MAX_BATCH_GET, MAX_BATCH_CREATE, MAX_BULK_RECIPIENTSExported so you can chunk on your side. Requests that exceed them are rejected locally, before being sent.
MIT.