x-tkn
Python client for X-TKN — stateful tokens as a service. Mint a token, hand the code to someone, and control how many times and for how long it can be redeemed.
Get an API key by signing up at x-tkn.com.
pip install x-tkn
Python 3.10+. One dependency, httpx, which is what gives you a sync and an async client from the same package.
Quickstart
from x_tkn import xtkn
token = xtkn.create_token(
type="password-reset",
ref_id=user.id,
max_uses=1,
expires_in={"minutes": 30},
)
# token.code is returned once and never again. Persist or send it now.
send_email(user.email, f"https://app.example.com/reset?c={token.code}")
…and on the other side:
from x_tkn import xtkn, XTknGoneError, XTknNotFoundError
try:
token = xtkn.redeem_token(code, ref_id=user.id, type="password-reset")
reset_password(user, token.payload)
except XTknGoneError:
return render("That link has already been used.")
except XTknNotFoundError:
return render("That link is not valid.")
Async
AsyncXTkn has the same methods, the same arguments and the same validation —
the request building is shared code, not a parallel implementation.
from x_tkn import AsyncXTkn
async with AsyncXTkn() as client:
token = await client.create_token(max_uses=1, expires_in={"minutes": 15})
In a web application, hold one client for the process rather than building one per request — a client built per request throws its connection pool away each time. Close it on shutdown:
client = AsyncXTkn()
@app.on_event("shutdown")
async def close_xtkn():
await client.aclose()
The module-level xtkn is sync-only and deliberately so. An AsyncClient that
nothing closes is a resource leak the caller cannot see, so the async client is
yours to construct and close.
Authentication
The key is read on every request, so it can be set after import.
X_TKN_API_KEY=your_key_here
Or pass it explicitly — necessary if you talk to more than one account:
from x_tkn import XTkn
client = XTkn(api_key=os.environ["MY_KEY"])
Keep the key server-side. It grants full token CRUD for your account.
Options
| Option | Default | |
|---|---|---|
api_key |
X_TKN_API_KEY, then X_TKN_API_KEY_ID |
Resolved per request |
base_url |
https://api.x-tkn.com |
|
timeout |
30.0 |
Seconds. None or 0 disables |
headers |
— | Merged into every request; cannot override Authorization |
transport |
httpx's default | For tests, proxies or retries |
UNSET versus None
JSON has two ways to say nothing and the API means different things by them: an
absent key takes the server's default, an explicit null clears the field.
Python's None can only carry one of those, so every optional argument defaults
to UNSET and None is left to mean null.
xtkn.update_token(code) # changes nothing
xtkn.update_token(code, max_uses=None) # clears the ceiling — unlimited uses
You rarely have to name UNSET yourself. It matters when you are forwarding
values you may or may not have:
from x_tkn import UNSET
xtkn.create_token(ref_id=user.id if user else UNSET)
Methods
Every method raises on failure — see Errors. code is the token's
public identifier, returned by create_token.
create_token(...)
token = xtkn.create_token(
type="handoff", # [a-z0-9_-], ≤64 chars, defaults to "generic"
ref_id="user_123", # your own identifier, ≤256 chars
payload={"role": "admin"}, # JSON-encoded, ≤64 KB
max_uses=1, # 1–1,000,000. omit for unlimited
description="Admin invite", # operator-facing note
expires_in={"hours": 2}, # or expires_at=datetime(...) — not both
)
token.code is the only copy. The server stores sha256(code) and cannot return
it later; every subsequent read leaves the field None.
With no expires_at or expires_in, the server expires the token 30 days after
creation.
expires_in takes a timedelta as readily as a dict — timedelta(hours=2) and
{"hours": 2} are the same request.
read_token(code)
Returns the token without consuming a use. Check is_active, is_expired,
is_used.
redeem_token(code, ...)
Consumes one use and returns the token. Raises XTknGoneError if it is revoked,
expired or exhausted; XTknNotFoundError if the code is wrong.
xtkn.redeem_token(code, ref_id=user.id, type="handoff")
Pass both ref_id and type where you can. ref_id confines the lookup to the
identity the code was issued for, which is what bounds guessing; type stops the
redemption consuming a different kind of code held by the same identity.
update_token(code, ...)
Changes type, ref_id, payload, max_uses or the expiry. description is
not updatable — the server's update handler ignores it.
extend_expiration(code, duration)
Moves the expiry to duration from now, not from the existing expiry — so
this revives an already-expired token rather than extending from a past date.
xtkn.extend_expiration(session_code, {"hours": 2})
revoke_token(code) / revoke_tokens(...)
xtkn.revoke_token(code)
# Log a user out everywhere. One call revokes a bounded batch.
while xtkn.revoke_tokens(ref_id=user.id, type="session").has_more:
pass
revoke_tokens requires type or ref_id. An empty filter means "revoke
everything", which is not allowed by omission.
delete_token(code)
Permanent. Prefer revoke_token — a revoked token can still explain why a
redemption failed, a deleted one is indistinguishable from one that never existed.
list_tokens(...)
result = xtkn.list_tokens(
ref_id="user_123",
is_revoked=False,
sort="-createdAt",
page=1,
limit=50, # capped at 100
)
for token in result: # TokenList iterates its tokens
print(token.display_name)
print(result.count) # total matching the filter, not len(result)
Only type, ref_id and is_revoked are filterable. On the raw API the server
silently drops anything else, so a typo widens the result rather than erroring;
here it is a TypeError before anything is sent.
sort accepts createdAt, updatedAt, expiresAt, lastUsedAt or uses,
each optionally prefixed with -. Anything else is silently replaced with
-createdAt by the server; the TokenSort literal type catches it in a type
checker first.
Codes are not exposed as a filter. The API accepts one and hashes it to match
sha256(code), but it can only return the single token you already hold the code
for — read_token(code) does that directly.
The Token object
Attributes are snake_case and timestamps are parsed into aware datetimes.
Whatever the server actually sent is on token.raw, unrenamed and unparsed, so
a field this SDK does not know about is still reachable.
token.expires_at # datetime | None
token.raw["expiresAt"] # "2026-09-30T12:00:00.000Z"
repr(token) masks code, because reprs reach logs and tracebacks far more
readily than a deliberate print. Attribute access still returns the real value.
Errors
Every non-2xx response raises. Catch XTknError for all of them, or a subclass
to tell them apart.
| Class | Status | Means |
|---|---|---|
XTknRequestError |
400 | Malformed request or failed validation |
XTknAuthError |
401, 403 | Key missing, unknown or revoked |
XTknNotFoundError |
404 | No such token on this account |
XTknGoneError |
410 | Exists but spent: revoked, expired or out of uses |
XTknRateLimitError |
429 | Hourly guard or monthly quota exhausted |
XTknServerError |
5xx | |
XTknConnectionError |
— | Never reached the server: DNS, reset, timeout |
XTknConfigError |
— | Bad arguments; raised before any request |
Each carries status, and details when the API supplied a field-level map.
Payload encryption
The server never needs to read your payload, and accounts with
requireEncryptedPayload set reject anything that is not already an xtkn.v1.
or xtkn.v1r. envelope.
This SDK does not encrypt for you. Pass an already-encrypted string as
payload if your account enforces it.
Differences from the JavaScript SDK
The two clients cover the same API and are deliberately close, but they are not transliterations of each other.
@fennecstudio/x-tkn-js |
x-tkn |
|---|---|
timeoutMs, milliseconds |
timeout, seconds |
fetch injection |
transport injection |
Options objects ({ maxUses: 1 }) |
Keyword arguments (max_uses=1) |
listTokens({ where: {...} }) |
list_tokens(ref_id=..., is_revoked=...) — flattened |
| Returns plain objects, camelCase | Returns dataclasses, snake_case, with .raw for the wire |
| Timestamps are ISO strings | Timestamps are datetime |
undefined omits, null sends null |
UNSET omits, None sends null |
| Async only | XTkn and AsyncXTkn |
x-tkn 2.0.0 and @fennecstudio/x-tkn-js 2.x ship against the same generation
of the API. The two are versioned independently from here, so do not read
matching major numbers as a promise they stay matched.
Development
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest # unit tests
.venv/bin/mypy # strict type check
Tests live in __tests__/ and are named <module>.<function>.unit.py, matching
the monorepo's convention rather than pytest's default test_*.py glob — see the
python_files setting in pyproject.toml.
License
ISC
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 x_tkn-2.0.0.tar.gz.
File metadata
- Download URL: x_tkn-2.0.0.tar.gz
- Upload date:
- Size: 30.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 |
bacbe478eeb229b12e36c4d198ec887b75d37899a29e953be35d07f75f3c4bbf
|
|
| MD5 |
6a126f52a7627b48c73caecf4a4374a6
|
|
| BLAKE2b-256 |
70277639ffd4de007188531c2200e7970aa83e06bba568f80a283d9daa20a8a6
|
Provenance
The following attestation bundles were made for x_tkn-2.0.0.tar.gz:
Publisher:
publish-x-tkn-python.yml on fennecstudio/platform
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
x_tkn-2.0.0.tar.gz -
Subject digest:
bacbe478eeb229b12e36c4d198ec887b75d37899a29e953be35d07f75f3c4bbf - Sigstore transparency entry: 2655526707
- Sigstore integration time:
-
Permalink:
fennecstudio/platform@7ab4fbdc1c5429b7bf799a333965bcfee61a70bf -
Branch / Tag:
refs/tags/x-tkn-python-v2.0.0 - Owner: https://github.com/fennecstudio
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-x-tkn-python.yml@7ab4fbdc1c5429b7bf799a333965bcfee61a70bf -
Trigger Event:
push
-
Statement type:
File details
Details for the file x_tkn-2.0.0-py3-none-any.whl.
File metadata
- Download URL: x_tkn-2.0.0-py3-none-any.whl
- Upload date:
- Size: 22.2 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 |
cbc69388047070c47b9dbacb16789a0b4e9a8de8a33adf14a7a6f7031bfa6564
|
|
| MD5 |
986fc830c32c4ec0c6f85bf3410ec57e
|
|
| BLAKE2b-256 |
b15cf0af47cadf29e230a2acd33731697758be2543a4c75a18c893fda0c23ed8
|
Provenance
The following attestation bundles were made for x_tkn-2.0.0-py3-none-any.whl:
Publisher:
publish-x-tkn-python.yml on fennecstudio/platform
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
x_tkn-2.0.0-py3-none-any.whl -
Subject digest:
cbc69388047070c47b9dbacb16789a0b4e9a8de8a33adf14a7a6f7031bfa6564 - Sigstore transparency entry: 2655526712
- Sigstore integration time:
-
Permalink:
fennecstudio/platform@7ab4fbdc1c5429b7bf799a333965bcfee61a70bf -
Branch / Tag:
refs/tags/x-tkn-python-v2.0.0 - Owner: https://github.com/fennecstudio
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-x-tkn-python.yml@7ab4fbdc1c5429b7bf799a333965bcfee61a70bf -
Trigger Event:
push
-
Statement type: