@fennecstudio/x-tkn-js
v2.0.0
Published
SDK for the X-TKN API — stateful tokens as a service
Maintainers
Readme
@fennecstudio/x-tkn-js
TypeScript/JavaScript 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.
npm install @fennecstudio/x-tkn-jsNode 18+, or any runtime with a global fetch — browsers, Deno, Bun, Cloudflare
Workers, Vercel Edge. No dependencies.
Upgrading from 1.x? The package moved — 1.x was published as unscoped
x-tkn, and v2 lives here under@fennecstudio/x-tkn-js. Bumping a version will not find it; you have to change the dependency name. v2 is also a breaking change, and 1.x does not work against the current API either way. See Migrating from v1.
Quickstart
import { xtkn } from '@fennecstudio/x-tkn-js'
const token = await xtkn.createToken({
type: 'password-reset',
refId: user.id,
maxUses: 1,
expiresIn: { minutes: 30 },
})
// token.code is returned once and never again. Persist or send it now.
await sendEmail(user.email, `https://app.example.com/reset?c=${token.code}`)…and on the other side:
import { xtkn, XTknGoneError, XTknNotFoundError } from '@fennecstudio/x-tkn-js'
try {
const token = await xtkn.redeemToken(code, { refId: user.id, type: 'password-reset' })
await resetPassword(user, token.payload)
} catch (err) {
if (err instanceof XTknGoneError) return render('That link has already been used.')
if (err instanceof XTknNotFoundError) return render('That link is not valid.')
throw err
}Authentication
The key is read on every request, so it can be set after import.
X_TKN_API_KEY=your_key_hereOr pass it explicitly — necessary if you talk to more than one account:
import { XTkn } from '@fennecstudio/x-tkn-js'
const client = new XTkn({ apiKey: process.env.MY_KEY })Keep the key server-side. It grants full token CRUD for your account, so a key
in a browser bundle is a published key. v1 read REACT_APP_- and VUE_APP_-prefixed
variables, whose entire purpose is to inline a value into a browser bundle; v2 does
not.
Options
| Option | Default | |
| ----------- | ---------------------------------------- | ---------------------------------------------------------- |
| apiKey | X_TKN_API_KEY, then X_TKN_API_KEY_ID | Resolved per request |
| baseUrl | https://api.x-tkn.com | |
| timeoutMs | 30000 | 0 disables |
| fetch | globalThis.fetch | For tests or a custom agent |
| headers | — | Merged into every request; cannot override Authorization |
Methods
Every method throws on failure — see Errors. code is the token's
public identifier, returned by createToken.
createToken(input?)
const token = await xtkn.createToken({
type: 'handoff', // [a-z0-9_-], ≤64 chars, defaults to 'generic'
refId: 'user_123', // your own identifier, ≤256 chars
payload: { role: 'admin' }, // JSON-encoded, ≤64 KB
maxUses: 1, // 1–1,000,000. omit for unlimited
description: 'Admin invite', // operator-facing note
expiresIn: { hours: 2 }, // or expiresAt: new Date(...) — not both
})token.code is the only copy. The server stores sha256(code) and cannot return
it later; every subsequent read leaves the field undefined.
With no expiresAt or expiresIn, the server expires the token 30 days after
creation.
readToken(code)
Returns the token without consuming a use. Check isActive, isExpired, isUsed.
redeemToken(code, options?)
Consumes one use and returns the token. Throws XTknGoneError if it is revoked,
expired or exhausted; XTknNotFoundError if the code is wrong.
await xtkn.redeemToken(code, { refId: user.id, type: 'handoff' })Pass both refId and type where you can. refId 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.
updateToken(code, updates)
Changes type, refId, payload, maxUses or the expiry. description is not
updatable.
extendExpiration(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.
await xtkn.extendExpiration(sessionCode, { hours: 2 })revokeToken(code) / revokeTokens(filter)
await xtkn.revokeToken(code)
// Log a user out everywhere. One call revokes a bounded batch.
let result
do {
result = await xtkn.revokeTokens({ refId: user.id, type: 'session' })
} while (result.hasMore)revokeTokens requires type or refId. An empty filter means "revoke
everything", which is not allowed by omission.
deleteToken(code)
Permanent. Prefer revokeToken — a revoked token can still explain why a
redemption failed, a deleted one is indistinguishable from one that never existed.
listTokens(options?)
const { tokens, count } = await xtkn.listTokens({
where: { refId: 'user_123', isRevoked: false },
sort: '-createdAt',
page: 1,
limit: 50, // capped at 100
})count is the total matching the filter, not tokens.length.
Only type, refId and isRevoked are filterable, and the server silently
drops anything else — so a typo widens the result rather than erroring. sort
accepts createdAt, updatedAt, expiresAt, lastUsedAt or uses, each
optionally prefixed with -; anything else is silently replaced with
-createdAt, which the TokenSort type makes unreachable from TypeScript.
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 — readToken(code) does that directly.
Errors
Every non-2xx response throws. 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; thrown 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 yet. Pass an already-encrypted string as
payload if your account enforces it. Local encryption is planned; until it
lands, the envelope format is documented in the X-TKN crypto contract.
Migrating from v1
1.x returns the HTTP envelope instead of the token, never throws on an error response, and calls routes with a parameter the API renamed — so it does not work against the current API. There is no compatibility shim; the changes are mechanical.
| v1 | v2 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| npm install x-tkn | npm install @fennecstudio/x-tkn-js — a different package, not a version bump |
| setup(apiKey) | new XTkn({ apiKey }), or X_TKN_API_KEY. v1's setup() assigned its argument to the base URL, so it never set a key at all |
| import { createToken } from 'x-tkn' | import { xtkn } from '@fennecstudio/x-tkn-js' → xtkn.createToken(...) |
| Any tokenId argument | code |
| Result was { success, data } | The token itself |
| A 401/404/410 arrived as data | Throws a typed error |
| createSecurityToken(refId, ttl) | createToken({ refId, maxUses: 1, expiresIn: ttl }) |
| createSessionToken(refId, ttl, payload, type) | createToken({ refId, payload, type, expiresIn: ttl }) |
| createShortCode(...) | Removed. The API no longer issues short codes |
| redeemToken(id, shortCode) | redeemToken(code, { refId, type }) |
| listTokens(where, orderBy, skip, take) | listTokens({ where, sort, page, limit }) |
| revokeTokens(where) | revokeTokens({ type, refId }) |
| listTokens filtering by code | Not wrapped. Use readToken(code) |
| REACT_APP_ / VUE_APP_ key variables | Removed — see Authentication |
| deleteToken returned the response body | Returns void |
ttl objects carry over unchanged as expiresIn, and seconds now works —
v1 documented it and had no branch for it.
License
ISC
