opa-sh
Official Python SDK for the Opa link shortener API (https://api.opa.sh/v1).
Resource-oriented, fully typed (pydantic v2 models, py.typed), sync and
async clients side by side, idiomatic Python error handling — failures raise
typed exceptions, not a Result/tuple you have to unwrap.
Install
pip install opa-sh
# or: uv add opa-sh
# or: poetry add opa-sh
Quick start
from opa_sh import OpaClient
opa = OpaClient(api_key="opa_live_...")
link = opa.links.create(
destination_url="https://example.com/black-friday",
domain="opa.sh",
tag_ids=["tag_campaign2026"],
)
print(link.short_link)
print(link.destination_url)
opa.close()
Or as a context manager, which closes the underlying connection pool for you:
with OpaClient(api_key="opa_live_...") as opa:
link = opa.links.create(destination_url="https://example.com")
print(link.short_link)
Async
Every resource method has an async counterpart on AsyncOpaClient — same
names, same arguments, awaited:
import asyncio
from opa_sh import AsyncOpaClient
async def main() -> None:
async with AsyncOpaClient(api_key="opa_live_...") as opa:
link = await opa.links.create(destination_url="https://example.com")
print(link.short_link)
asyncio.run(main())
Get your API key at opa.sh/settings/api-keys.
Authentication
Server-side only — never expose a key from a browser bundle or a mobile app:
opa = OpaClient(api_key="opa_live_...")
Base URL defaults to https://api.opa.sh/v1. Override it for staging or a
self-hosted instance:
opa = OpaClient(api_key="...", base_url="https://api.staging.opa.sh/v1")
Error handling
Every failed call raises a subclass of OpaError — a Python-idiomatic
raise-based SDK (mirrors Stripe, OpenAI, Anthropic, boto3), not a
Result/Either pattern. Catch OpaError to handle any SDK failure, or a
specific subclass to handle one case:
from opa_sh import OpaClient
from opa_sh.errors import NotFoundError, RateLimitError, ValidationError, OpaError
opa = OpaClient(api_key="...")
try:
link = opa.links.get("lnk_xxx")
except NotFoundError:
... # 404
except ValidationError as e:
print(e.details) # e.g. {"issues": [{"path": "destinationUrl", "message": "Required"}]}
except RateLimitError as e:
print("retry after", e.retry_after, "seconds")
except OpaError as e:
# catch-all: e.code, e.status, e.message, e.details
print(e.code, e.status, e.message)
| Exception | HTTP status | Meaning |
|---|---|---|
AuthenticationError |
401 | Missing or invalid API key. |
PermissionDeniedError |
403 | Valid credential, insufficient permission or plan capability. |
NotFoundError |
404 | The resource doesn't exist. |
ConflictError |
409 | E.g. a custom key already taken, or no domain available. |
ValidationError |
400 / 422 | Request body or query params failed validation. |
RateLimitError |
429 | Rate limit hit — see .retry_after (seconds). |
ServerError |
5xx | Opa API had an internal error. Safe to retry. |
NetworkError |
— | Connection reset, DNS failure, timeout — never reached the API. |
All of the above subclass OpaError, which subclasses Exception.
Resources
Links
# Create — only destination_url is required
link = opa.links.create(
destination_url="https://example.com",
domain="opa.sh",
key="custom-slug", # optional — omit for a random key
tag_ids=["tag_xxx"],
password="s3cret",
expires_at="2027-01-01T00:00:00Z",
)
# Read
link = opa.links.get("lnk_xxx")
# Update — destination_url and domain are required (full replace, not a merge patch)
link = opa.links.update("lnk_xxx", destination_url="https://example.com/new", domain="opa.sh")
# Lifecycle
opa.links.archive("lnk_xxx") # reversible — the link keeps resolving
opa.links.restore("lnk_xxx")
opa.links.duplicate("lnk_xxx")
# List — single page
page = opa.links.list(search="marketing", limit=50)
print(page.items, page.has_more, page.next_cursor)
# List — every page, auto-paginating (memory-safe generator)
for link in opa.links.list_all(search="marketing"):
print(link.short_link, link.tags)
# Bulk operations
opa.links.bulk_archive(["lnk_a", "lnk_b", "lnk_c"])
opa.links.bulk_restore(["lnk_a", "lnk_b"])
opa.links.bulk_move(["lnk_a", "lnk_b"], "folder_xxx")
opa.links.bulk_tag(["lnk_a", "lnk_b"], ["tag_q1"])
Analytics
summary = opa.analytics.query(from_="2026-01-01", to="2026-01-31", link_id="lnk_xxx")
print(summary.clicks, summary.unique_clicks)
timeseries = opa.analytics.timeseries(from_="2026-01-01", to="2026-01-31", link_id="lnk_xxx")
for point in timeseries.points:
print(point.date, point.clicks)
# Raw click events — single page or auto-paginating
page = opa.analytics.events(link_id="lnk_xxx", limit=100)
for event in opa.analytics.events_all(link_id="lnk_xxx"):
print(event.timestamp, event.country, event.device)
from_/to are ISO dates (max 366-day range). The trailing underscore on
from_ avoids colliding with the from keyword — it's sent as from on the
wire.
Domains
domains = opa.domains.list()
for domain in domains:
print(domain.domain, domain.primary, domain.verified)
Not paginated — this list is small by nature (verified custom domains plus the shared app domain).
Retries and rate limiting
Automatic exponential backoff (with jitter) on 5xx and 429, respecting the
Retry-After header. Never retries other 4xx statuses, so idempotent
semantics are preserved for POST. Transport-level failures (DNS, connection
reset) are retried the same way, then surfaced as NetworkError if every
attempt fails.
opa = OpaClient(
api_key="...",
retries=3, # default: 3
retry_delay=1.0, # default: 1.0s base, doubles each attempt (capped at 20s)
)
Pagination
Every list-style method comes in two shapes:
list(...)(orevents(...)) — returns a singlePage[T], with.items,.has_more, and.next_cursor.list_all(...)(orevents_all(...)) — a generator that walks every page automatically, yielding items one at a time. Memory-safe: it never holds more than one page in memory.
# Sync generator
for link in opa.links.list_all():
...
# Async generator — same method name, `async for`
async for link in opa.links.list_all():
...
An error partway through iteration raises the same OpaError subclass you'd
get from the eager list() call — you already handle that exception type, so
this doesn't introduce a new failure mode.
Custom httpx client
For advanced configuration — custom transports, proxies, mTLS, connection
pooling tuned for your workload — pass your own httpx.Client /
httpx.AsyncClient. The SDK merges the auth header into it and does not
override anything else you've configured:
import httpx
from opa_sh import OpaClient
custom_client = httpx.Client(
base_url="https://api.opa.sh/v1",
limits=httpx.Limits(max_connections=50),
proxy="http://localhost:8080",
)
opa = OpaClient(api_key="...", http_client=custom_client)
Note: the SDK's own retry transport (opa_sh.retry.RetryTransport) only
applies when it builds the httpx.Client itself. A custom http_client is
responsible for its own retry/backoff behavior.
Types
Every response is a pydantic v2 model (Link, LinkSummary, Domain,
AnalyticsSummary, AnalyticsTimeseries, AnalyticsEvent, ...) with
snake_case attributes and full type hints — the package ships a py.typed
marker, so mypy/pyright pick up types automatically in consuming projects.
from opa_sh import Link, LinkSummary, Domain, Page
def handle(link: Link) -> None:
print(link.short_link)
Runtime support
- Python 3.9, 3.10, 3.11, 3.12, 3.13
- CPython and PyPy (no C-extension dependencies beyond what
httpx/pydanticalready require)
Contributing
The SDK's request/response shapes are hand-written pydantic models in
src/opa_sh/models.py, kept in sync by hand against openapi/v1.json (the
raw OpenAPI spec has no named component schemas, so a mechanical generator
produces synthetic, unusable names — see that file's module docstring for
detail). src/opa_sh/_generated/ is a raw client generated by
openapi-python-client
from the same spec; it's committed as a diff target for the sync-openapi
workflow but is not imported at runtime.
openapi/v1.json is refreshed daily from https://api.opa.sh/v1/openapi via
the sync-openapi workflow, which opens a PR when the spec changes — extend
it with matching updates to models.py and the relevant resources/*.py
methods.
git clone https://github.com/espocalabs/opa-sdk-python
cd opa-sdk-python
uv sync --all-extras
uv run pytest
uv run ruff check .
uv run mypy src
uv build
License
MIT © Espoca Labs
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file opa_sh-0.1.1.tar.gz.
File metadata
- Download URL: opa_sh-0.1.1.tar.gz
- Upload date:
- Size: 111.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
868d3eb8d02c636dc09716b69b83388fd7159c2433a151e5f9eee4a585f73922
|
|
| MD5 |
1910243bb39b1b63cef72cdde6009708
|
|
| BLAKE2b-256 |
c79a998bdb95cc4b5a338b853ddcd647958b41491ec14f013f932406e9863c55
|
Provenance
The following attestation bundles were made for opa_sh-0.1.1.tar.gz:
Publisher:
release.yml on espocalabs/opa-sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
opa_sh-0.1.1.tar.gz -
Subject digest:
868d3eb8d02c636dc09716b69b83388fd7159c2433a151e5f9eee4a585f73922 - Sigstore transparency entry: 2513980100
- Sigstore integration time:
-
Permalink:
espocalabs/opa-sdk-python@e93c625b1e1da4e992a69a05f21fabf971c03f11 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/espocalabs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e93c625b1e1da4e992a69a05f21fabf971c03f11 -
Trigger Event:
push
-
Statement type:
File details
Details for the file opa_sh-0.1.1-py3-none-any.whl.
File metadata
- Download URL: opa_sh-0.1.1-py3-none-any.whl
- Upload date:
- Size: 22.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7ec59d92a9e61a8bb65f60c38ce567550ce16086687fa71b529d500f5e6a8bec
|
|
| MD5 |
7bbfa46545cc0ae4c3cfbe0b00b9c0b3
|
|
| BLAKE2b-256 |
160aaa6ee10b6ed361f0ad7764ec440872dbc6b6debfe8fc8ab5b630720df919
|
Provenance
The following attestation bundles were made for opa_sh-0.1.1-py3-none-any.whl:
Publisher:
release.yml on espocalabs/opa-sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
opa_sh-0.1.1-py3-none-any.whl -
Subject digest:
7ec59d92a9e61a8bb65f60c38ce567550ce16086687fa71b529d500f5e6a8bec - Sigstore transparency entry: 2513980125
- Sigstore integration time:
-
Permalink:
espocalabs/opa-sdk-python@e93c625b1e1da4e992a69a05f21fabf971c03f11 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/espocalabs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e93c625b1e1da4e992a69a05f21fabf971c03f11 -
Trigger Event:
push
-
Statement type: