Skip to main content

spoo

The official Python SDK for the spoo.me URL shortener API.

CI PyPI Python Codecov License

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.typed included
  • Sign in with Spoo (PKCE device auth) with automatic token refresh
  • Automatic, idempotency-aware retries
  • Auto-pagination for list endpoints
  • Two runtime dependencies: httpx and pydantic

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)
preview = client.urls.preview("mylink")   # public: destination, status, protection
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(), in csv, xlsx, json, or xml. The return value is bytes plus the server's suggested filename and content_type:

from pathlib import Path
from spoo import ExportFormat

data = client.stats.export(ExportFormat.CSV, start_date="2026-07-01")
Path(data.filename or "report.csv").write_bytes(data)   # server names the file

link_data = client.stats.export_link(url.id, ExportFormat.XLSX)
Path(link_data.filename or "link.xlsx").write_bytes(link_data)

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
413 PayloadTooLargeError
429 RateLimitError (retry_after, limit, remaining, reset)
451 ContentBlockedError (the link was taken down for safety)
503 ServiceUnavailableError
other 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

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spoo-1.1.0.tar.gz (35.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spoo-1.1.0-py3-none-any.whl (33.9 kB view details)

Uploaded Python 3

File details

Details for the file spoo-1.1.0.tar.gz.

File metadata

  • Download URL: spoo-1.1.0.tar.gz
  • Upload date:
  • Size: 35.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spoo-1.1.0.tar.gz
Algorithm Hash digest
SHA256 b9d358933bb76ff23a14c3ee6993e6b39b165b5db1ab3734e4dd64495c38f0d1
MD5 97f6b8f96a7f56756c324e9f8269ac01
BLAKE2b-256 a48123fe122e7cd0842fc5bd8be05859fb17e34b502c946925203e3be3a38710

See more details on using hashes here.

Provenance

The following attestation bundles were made for spoo-1.1.0.tar.gz:

Publisher: python-publish.yml on spoo-me/spoo-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spoo-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: spoo-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 33.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spoo-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9d49f0a67d7b3ced4d815495920d8ea40924e86043a2da5ce50ad258d3b9d7b6
MD5 0e4ec7bdb8d9915597dae61dac1dde9e
BLAKE2b-256 31f32ec55ccaec7fbd3eaa5af580306121b9060e29a7bdbff6907f0fb3e0187a

See more details on using hashes here.

Provenance

The following attestation bundles were made for spoo-1.1.0-py3-none-any.whl:

Publisher: python-publish.yml on spoo-me/spoo-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.5.0

2 files

1.4.0

2 files

1.3.0

2 files

1.2.0

2 files

This release

1.1.0 This release

2 files

1.0.0

2 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