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.

Note that the base URL is a security boundary: every request, credential, and server-suggested filename flows through whatever it points at. Prefer the explicit base_url argument in anything security-sensitive, since the environment variable can redirect a whole process without any call site showing it.

Async is the same surface with AsyncSpooClient, await, and async for.

from spoo import SpooClient, LinkFilter, LinkStatus, SortBy

client = SpooClient(api_key="spoo_...")

url = client.links.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.links.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.links.get(url.id)
preview = client.links.preview("mylink")   # public: destination, status, protection
link = client.links.get_by_alias("mylink")                    # your base domain
link = client.links.get_by_alias("mylink", domain="links.acme.com")

# Iterate everything (auto-pagination), or one filtered page
for item in client.links.list(sort_by=SortBy.TOTAL_CLICKS):
    print(item.alias, item.total_clicks)
page = client.links.list_page(filter=LinkFilter(status=LinkStatus.ACTIVE, search="docs"))

# Update, toggle, delete
client.links.update(url.id, long_url="https://example.com/new", max_clicks=0)
client.links.set_status(url.id, LinkStatus.INACTIVE)
client.links.delete(url.id)

Bulk operations

Up to 100 ids per call; results are reported per item instead of throwing:

result = client.links.bulk_set_status(ids, LinkStatus.INACTIVE)
print(result.summary.succeeded, result.summary.failed)
for row in result.results:
    if not row.ok:
        print(row.id, row.error_code)

client.links.bulk_delete(ids)
client.links.bulk_set_expiry(ids, "2027-01-01T00:00:00")   # None clears
client.links.bulk_set_domain(ids, "links.acme.com")        # None = default
client.links.bulk_update_tags(ids, add=[launch.id], remove=[old.id])

Tags

Tags are labels you define once per account and attach to links by id. A link carries up to 10. Requests type the palette as TagColor (9 keys) and TagIcon (87 keys); response models keep color and icon as plain str on purpose, so a key the server adds later still parses.

launch = client.tags.create("launch", color="violet", icon="rocket")
client.tags.update(launch.id, color="teal")   # omitted fields stay as they are

# Tag at create time, or replace the list on update ([] clears it)
url = client.links.create("https://example.com", tag_ids=[launch.id])
client.links.update(url.id, tag_ids=[launch.id, other.id])
print([t.name for t in url.tags])

# Filter the list by tag; "any" (default) or "all" for how several combine
page = client.links.list_page(filter=LinkFilter(tag_names=["launch", "q3"], tags_match="all"))

for tag in client.tags.list():
    print(tag.name, tag.link_count)
deleted = client.tags.delete(launch.id)   # also strips it from every link
print(deleted.links_updated)

Stats and exports take the same scope through StatsFilter(tag=[...]) by name or StatsFilter(tag_id=[...]) by id.

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.links.emoji_set()   # ~1170 entries with names and groups

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.links.claim(anon_url.id, anon_url.claim_token)
print(result.status)   # claimed | already_yours | invalid

client.links.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"], tag=["q3"]),
)
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)

Large accounts should stream instead of buffering; retries still apply up to the first byte of the body:

with client.stats.export_stream(ExportFormat.XLSX) as stream:
    with open(stream.filename or "export.xlsx", "wb") as f:
        for chunk in stream.iter_bytes():
            f.write(chunk)

export_link_stream(url_id, format) is the per-link variant, and the async client swaps in async with and async for.

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}")

Scope

The SDK covers the data plane a third-party integration builds against: links (create, manage, bulk, claim, emoji aliases, tags), analytics (account, per-link, public, exports), the public preview, and Sign in with Spoo plus the read-only identity check.

Deliberately out of scope: API key management, account and profile lifecycle, /contact, /health, and all legacy (pre-v1) endpoints. Feature-gated surfaces (custom domains management, webhooks, geo rules, meta tags) are not wrapped while they are not generally available, except the domain parameters which pass through.

Method Endpoint
shorten, links.create POST /api/v1/shorten
links.check_alias GET /api/v1/shorten/check-alias
links.list, links.list_page GET /api/v1/urls
links.get GET /api/v1/urls/{url_id}
links.get_by_alias GET /api/v1/urls/{domain}/{alias}
links.update, links.set_status, links.delete PATCH/DELETE /api/v1/urls/{url_id}
links.delete_all DELETE /api/v1/urls?domain=
links.claim, links.claim_many POST /api/v1/urls/claim
links.bulk_* POST /api/v1/urls/bulk/{delete,status,expiry,domain,tags}
links.preview GET /api/v1/public/preview/{short_code}
links.emoji_set GET /api/v1/emoji-set
tags.list, tags.create GET/POST /api/v1/tags
tags.update, tags.delete PATCH/DELETE /api/v1/tags/{tag_id}
stats.query, stats.for_link GET /api/v1/stats, /api/v1/stats/links/{url_id}
stats.public GET/POST /api/v1/public/stats/{short_code}
stats.export, stats.export_link (+ _stream variants) GET /api/v1/export, /api/v1/export/links/{url_id}
oauth.* /auth/device/{token,refresh}
me GET /auth/me

For anything not listed, client.request(method, path, params=, json=) is the supported escape hatch: it applies the client's auth, retries, and error mapping, and returns the parsed JSON. If you need it, the surface has a gap worth filing.

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, link 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

Metadata

Release files for spoo 1.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spoo 1.5.0
File Size Uploaded Provenance
spoo-1.5.0.tar.gz 44.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for spoo 1.5.0
File Interpreter ABI Platform
spoo-1.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 85.5 kB

Release files / spoo-1.5.0.tar.gz

Download URL spoo-1.5.0.tar.gz
Size 44.8 kB
Tags Source
SHA-256 checksum
How to use checksums
b9456ed9dd034814f03fe7ae0aba80d94dce46b041a54cde7b0159ec44559b7e
BLAKE2b-256 checksum
How to use checksums
d06bacc8d1881a34e44d5a5b70345d542a1057b44950663c3e22805ca31d6347
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 Sep 4, 2026.

Transparency log

Release files / spoo-1.5.0-py3-none-any.whl

Download URL spoo-1.5.0-py3-none-any.whl
Size 40.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9abee302bd4cc47839c61ba07ce3809767d00138c9b127037389234fa9e5b915
BLAKE2b-256 checksum
How to use checksums
52b1da7f100a8909aa34c9f07920c8ecdf97199426da33f5f98c831b3368c783
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 Sep 4, 2026.

Transparency log

Security for spoo 1.5.0

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for spoo-1.5.0.tar.gz

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 Sep 4, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

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