This release is a pre-release and may not be stable for production use.
lihi
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 lihi
Requires Python 3.10 or newer.
Getting started
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.
Async
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.
Short URLs
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.
Batches
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.
SMS
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.
Estimating cost
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.
Errors
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.
Retries
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 care
RateLimitError.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.
Testing your own 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.
Configuration
| 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.
Logging
Pass a logger to see requests, retries and failures. Credentials, query strings and bodies are never logged.
import logging
Lihi(..., logger=logging.getLogger("lihi"))
Limits
from lihi import MAX_BATCH_GET, MAX_BATCH_CREATE, MAX_BULK_RECIPIENTS
Exported so you can chunk on your side. Requests that exceed them are rejected locally, before being sent.
Licence
MIT.
Metadata
Release files for lihi-py 1.0.0a0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lihi_py-1.0.0a0.tar.gz | 90.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lihi_py-1.0.0a0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 140.3 kB
Release files / lihi_py-1.0.0a0.tar.gz
| Download URL | lihi_py-1.0.0a0.tar.gz |
|---|---|
| Size | 90.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
29348404666860f78b8f1ca6753be57f084c8aefd4a5f490997aef02eaf5f9a3
|
|
BLAKE2b-256 checksum How to use checksums |
5f1d04c90ddc29c53aad693fc57a9a29bdab0dcf5663a691367af74a228c5039
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.
Transparency logRelease files / lihi_py-1.0.0a0-py3-none-any.whl
| Download URL | lihi_py-1.0.0a0-py3-none-any.whl |
|---|---|
| Size | 50.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
80c101185bb85406f4cf3baecaf0a13ab8dc1c983d79a6191790f668945030e5
|
|
BLAKE2b-256 checksum How to use checksums |
da4020fe24676391e9dabd557b7f4efb9525bc871eeb72a6b3cb004dded967da
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.
Transparency log