spoo
The official Python SDK for the spoo.me URL shortener API.
from spoo import SpooClient
client = SpooClient()
print(client.shorten("https://example.com").short_url)
- Sync and async clients with the same API
- Fully typed: every response is a Pydantic model,
py.typedincluded - Sign in with Spoo (PKCE device auth) with automatic token refresh
- Automatic, idempotency-aware retries
- Auto-pagination for list endpoints
- Two runtime dependencies:
httpxandpydantic
Migrating from py_spoo_url? See MIGRATION.md.
Install
pip install spoo
# or
uv add spoo
Requires Python 3.10+.
Authentication
client = SpooClient() # reads SPOO_API_KEY, else anonymous
client = SpooClient(api_key="spoo_...") # explicit API key
client = SpooClient(api_key="") # force anonymous even with env set
client = SpooClient(bearer_token=...) # a JWT, or a callable returning one
Anonymous clients work under anonymous limits. Create API keys from your spoo.me dashboard. Self-hosting? Point base_url (or SPOO_BASE_URL) at your instance's /api/v1.
Async is the same surface with AsyncSpooClient, await, and async for.
Links
from spoo import SpooClient, UrlFilter, UrlStatus, SortBy
client = SpooClient(api_key="spoo_...")
url = client.urls.create(
"https://example.com",
alias="mylink",
password="Secret@123",
max_clicks=500,
expire_after="2026-12-31T00:00:00",
block_bots=True,
private_stats=True,
)
# Check availability first if you want a precise reason
check = client.urls.check_alias("mylink")
if not check.available:
print(check.reason) # taken | format | length | reserved | emoji_policy
# Fetch one link by id, or by its address
link = client.urls.get(url.id)
link = client.urls.get_by_alias("mylink") # your base domain
link = client.urls.get_by_alias("mylink", domain="links.acme.com")
# Iterate everything (auto-pagination), or one filtered page
for item in client.urls.list(sort_by=SortBy.TOTAL_CLICKS):
print(item.alias, item.total_clicks)
page = client.urls.list_page(filter=UrlFilter(status=UrlStatus.ACTIVE, search="docs"))
# Update, toggle, delete
client.urls.update(url.id, long_url="https://example.com/new", max_clicks=0)
client.urls.set_status(url.id, UrlStatus.INACTIVE)
client.urls.delete(url.id)
Bulk operations
Up to 100 ids per call; results are reported per item instead of throwing:
result = client.urls.bulk_set_status(ids, UrlStatus.INACTIVE)
print(result.summary.succeeded, result.summary.failed)
for row in result.results:
if not row.ok:
print(row.id, row.error_code)
client.urls.bulk_delete(ids)
client.urls.bulk_set_expiry(ids, "2027-01-01T00:00:00") # None clears
client.urls.bulk_set_domain(ids, "links.acme.com") # None = default
Emoji aliases
url = client.shorten("https://example.com", alias="🚀🔥") # pick your own
url = client.shorten("https://example.com", alias_type="emoji") # auto-generate
The SDK validates emoji aliases before sending, against the server's own accepted catalogue (fetched once per client and cached). The catalogue is available directly for building pickers:
emoji_set = client.urls.emoji_set() # ~1170 entries with names and groups
Claim links
Anonymous creates return a one-time claim_token. After the user signs in, the token proves they created the link and transfers ownership, stats included:
anon_url = SpooClient(api_key="").shorten("https://example.com")
result = client.urls.claim(anon_url.id, anon_url.claim_token)
print(result.status) # claimed | already_yours | invalid
client.urls.claim_many([(id1, token1), (id2, token2)]) # up to 16
Statistics
Account-wide analytics (requires authentication):
from spoo import GroupBy, Metric, StatsFilter
stats = client.stats.query(
start_date="2026-07-01",
end_date="2026-08-19",
group_by=[GroupBy.TIME, GroupBy.COUNTRY, GroupBy.DEVICE, GroupBy.UTM_SOURCE],
metrics=[Metric.CLICKS, Metric.UNIQUE_CLICKS],
timezone="Asia/Kolkata",
filters=StatsFilter(country=["IN", "US"], utm_campaign=["launch"]),
)
print(stats.summary.total_clicks)
for row in stats.metrics["clicks_by_country"]: # "{metric}_by_{dimension}"
print(row)
For a single link you own:
stats = client.stats.for_link(url.id, group_by=[GroupBy.TIME])
Public per-link stats work without authentication. Password-protected links take the password in a POST body, never in the URL:
public = client.stats.public("mylink")
public = client.stats.public("mylink", password="Secret@123")
Exports
Same parameters as query(), returned as bytes in csv, xlsx, json, or xml:
from pathlib import Path
from spoo import ExportFormat
data = client.stats.export(ExportFormat.CSV, start_date="2026-07-01")
Path("report.csv").write_bytes(data)
Path("mylink.xlsx").write_bytes(client.stats.export_link(url.id, ExportFormat.XLSX))
Sign in with Spoo
For connected apps: the PKCE device-auth flow gets you user-scoped tokens without handling passwords. Your app must be registered with spoo.me.
client = SpooClient()
pkce = client.oauth.generate_pkce()
state = client.oauth.generate_state()
# 1. Send the user to the consent page
print(client.oauth.authorization_url("my-app", code_challenge=pkce.challenge, state=state))
# 2. Your redirect URI receives code + state; verify state, then exchange
tokens = client.oauth.exchange_code(code, pkce.verifier)
print(tokens.user.email)
# 3. A provider keeps the session fresh (refresh tokens rotate: persist them)
provider = client.oauth.token_provider(tokens, on_refresh=save_to_disk)
user_client = SpooClient(bearer_token=provider)
print(user_client.me().plan)
When the refresh token itself is rejected, calls raise SessionExpiredError: send the user through the flow again. See examples/sign_in_with_spoo.py for the full loop.
Error handling
Errors map to typed exceptions carrying the backend error code:
| Status | Exception |
|---|---|
| 400 / 422 | ValidationError |
| 401 | AuthenticationError |
| 403 | ForbiddenError |
| 404 | NotFoundError |
| 409 | ConflictError |
| 410 | GoneError |
| 429 | RateLimitError (retry_after, limit, remaining, reset) |
| 5xx | InternalServerError |
Network failures raise APIConnectionError / APITimeoutError; a rejected refresh token raises SessionExpiredError. All of them subclass SpooError.
from spoo import RateLimitError, ValidationError
try:
client.shorten("https://example.com", alias="taken")
except ValidationError as e:
print(e.error_code, e.message)
except RateLimitError as e:
print(f"limited, window resets at {e.reset}")
Retries and configuration
Retries (default 2) honor Retry-After and back off exponentially with jitter. GET/PUT/DELETE retry on 408/429/5xx and network failures; POST/PATCH retry only on 429 and 503, where the server provably did no work.
client = SpooClient(
api_key="spoo_...",
base_url="https://your-instance/api/v1",
timeout=30.0,
max_retries=3,
default_headers={"X-Request-ID": "..."},
)
Every request carries an X-Spoo-Client: sdk-py/<version> tag; override it via default_headers if you are building a product on top and want traffic attributed to it.
Note on custom domains: domain= parameters work end to end, but custom domains are currently in a limited beta on spoo.me, so most accounts will see 403 until it opens up.
Examples
Runnable scripts in examples/: quickstart, async usage, analytics, URL management, claim links, emoji aliases, and Sign in with Spoo.
Development
uv sync
uv run pytest
uv run ruff check src/ tests/ examples/
uv run mypy --strict src/spoo/
Versioning
Response models tolerate new fields (extra="allow"), so additive API changes never break an installed version. Breaking changes bump the major version.
License
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 spoo-1.0.0.tar.gz.
File metadata
- Download URL: spoo-1.0.0.tar.gz
- Upload date:
- Size: 32.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4902e917d8c186c0fcf221a4b81899f543890dca98ce5f7c5d21f342c8550e1
|
|
| MD5 |
585514ce351ae5cd412c803b9e558359
|
|
| BLAKE2b-256 |
dabf9513de134da7e3aacab29a749e9b5810b219c6c494c91dda14d2c08d2f34
|
Provenance
The following attestation bundles were made for spoo-1.0.0.tar.gz:
Publisher:
python-publish.yml on spoo-me/spoo-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spoo-1.0.0.tar.gz -
Subject digest:
a4902e917d8c186c0fcf221a4b81899f543890dca98ce5f7c5d21f342c8550e1 - Sigstore transparency entry: 2515484401
- Sigstore integration time:
-
Permalink:
spoo-me/spoo-py@b63904dcf0eb5413363eb8f898c89857ac298bf8 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/spoo-me
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b63904dcf0eb5413363eb8f898c89857ac298bf8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file spoo-1.0.0-py3-none-any.whl.
File metadata
- Download URL: spoo-1.0.0-py3-none-any.whl
- Upload date:
- Size: 31.6 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 |
91c9671e35fb943d14545aa1fc989b6483c0180814bd816ae88749d57cfeb9be
|
|
| MD5 |
22c49551e8d25fa2fe5aa7763ca090fa
|
|
| BLAKE2b-256 |
51e327c89e7ea24ebec224ae23e82d4845975b0efc25aa00466820acdcc0a3e8
|
Provenance
The following attestation bundles were made for spoo-1.0.0-py3-none-any.whl:
Publisher:
python-publish.yml on spoo-me/spoo-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spoo-1.0.0-py3-none-any.whl -
Subject digest:
91c9671e35fb943d14545aa1fc989b6483c0180814bd816ae88749d57cfeb9be - Sigstore transparency entry: 2515484409
- Sigstore integration time:
-
Permalink:
spoo-me/spoo-py@b63904dcf0eb5413363eb8f898c89857ac298bf8 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/spoo-me
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b63904dcf0eb5413363eb8f898c89857ac298bf8 -
Trigger Event:
release
-
Statement type: