Skip to main content
Pre-release

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.typed included
  • 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)

Source distribution for lihi-py 1.0.0a0
File Size Uploaded
lihi_py-1.0.0a0.tar.gz 90.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lihi-py 1.0.0a0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.0a0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page