@toktikhq/sdk-js
v0.5.0
Published
Typed JavaScript client for the TokTik Developer API (REST + realtime LIVE events)
Maintainers
Readme
TokTik JavaScript SDK
Typed client for the TokTik Developer API — REST data-plane + realtime LIVE event stream.
npm i @toktikhq/sdk-jsQuickstart
import { TokTikClient } from "@toktikhq/sdk-js";
const client = new TokTikClient({ apiKey: process.env.TOKTIK_API_KEY! });
// REST — provenance-enveloped responses expose `{ data, provenance }`, unchanged.
const creators = await client.creators.list({ q: "cooking", limit: 10 });
console.log(creators.provenance.freshness, creators.data.results);
const board = await client.rankings.official({ board: "hourly", region: "VN" });
console.log(board.provenance.freshness, board.data.board.entries);
// Realtime — three lines to a live event stream.
const stream = await client.live.stream(["@some.creator"], {
onEvent: (frame) => console.log(frame.event, frame.data),
onStatus: (frame) => console.log(frame.creatorId, "→", frame.status)
});The client stays thin: it sends your bearer key only to the configured API origin and leaves REST retries, caching, and key storage to the calling application. (The realtime stream is the one exception — reconnection there is not optional, see below.)
Provenance is part of the answer
Most data methods return { data, provenance } and the SDK never strips the envelope. Much of this
data is observed, not officially published, so freshness (near_realtime / stale /
historical), source and coverageStatus tell you how much to trust a given answer.
A few shapes deliberately differ, matching the server: exports return the job / job list directly,
account.usage() returns its own { period, balance, items } shape, and exports.download()
returns the CSV as a string, not JSON. Everything else is enveloped.
Realtime
client.live.stream(creatorIds, handlers) handles the parts that are easy to get wrong:
- Token lifetime — mints a short-lived handshake token per connection (via
POST /v1/live/stream/token), so an expiry mid-stream costs a reconnect, not a dead socket. - Credential transport — the token travels in the
Sec-WebSocket-Protocolsubprotocol, never the URL, where it would leak into access logs and browser history. - Reconnect — exponential backoff with full jitter and a bounded ceiling, so a fleet does not stampede a restarted gateway.
- Resume — the gateway keeps no per-connection memory, so the SDK re-subscribes everything after a reconnect. Subscribe/unsubscribe issued while disconnected is replayed on the next open.
const stream = await client.live.stream(["creator.one"], {
onEvent: (f) => console.log(f.event, f.sequence, f.data),
onStatus: (f) => console.log(f.status, f.reason), // queued | active | offline | unavailable
onError: (e) => console.error(e),
onReconnect: () => console.log("resumed")
});
stream.subscribe("creator.two");
stream.unsubscribe("creator.one");
stream.close(); // final — stops reconnectingStatuses are honest: a creator whose room is still being admitted/acquired reports queued, not
active. active means events are actually flowing.
Node
Node 22+ has a global WebSocket, so nothing extra is needed. On older runtimes pass a factory:
import WebSocket from "ws";
client.live.stream(ids, handlers, {
socketFactory: (url, token) => new WebSocket(url, ["bearer", token]) as any
});The two subprotocol values must be the literal bearer followed by the token — the gateway selects
and echoes the marker, and a client that offers subprotocols closes the socket if the server selects
none.
Pending (202) answers
A creator we hold nothing fresh for is resolved from TikTok on demand, so creators.get,
content.creatorStories, creators.following / followers, content.videoComments and
live.playback can answer 202 "not ready yet": { status: "resolving" | "collecting",
retryAfterSeconds, … } with no data. It is a success, not an error (nothing throws), and it is
never billed. The SAME call returns the real envelope once the read lands; while TikTok's read is
failing the server holds it for up to 120 s per call.
Since 0.5.0 these methods are typed as a union with that pending body, so TypeScript makes you
handle it. Narrow with isPending, or let waitUntilReady poll:
import { isPending, TokTikPendingTimeoutError, waitUntilReady } from "@toktikhq/sdk-js";
const answer = await client.creators.get("someone");
if (isPending(answer)) {
console.log(`check again in ${answer.retryAfterSeconds} s`);
} else {
console.log(answer.data.creator.followerCount);
}
// Honours retryAfterSeconds (clamped to 1–120 s), gives up after 3 minutes (maxWaitMs), stops on `signal`.
try {
const profile = await waitUntilReady(() => client.creators.get("someone"));
console.log(profile.data.creator.followerCount); // no narrowing needed
} catch (error) {
if (error instanceof TokTikPendingTimeoutError) error.retryAfterSeconds; // still not ready
}A non-2xx answer inside waitUntilReady still throws TokTikApiError unchanged.
TokTikPendingTimeoutError is deliberately not a TokTikApiError (the server answered 202 every
time). After a timeout, calling the method again later is safe — but a manual retry that lands the
read may be billed like any delivered read.
Errors
Failures (non-2xx answers) throw TokTikApiError, which keeps the status distinguishable rather
than collapsing it. A 202 "not ready yet" is not a failure — see "Pending (202) answers" above.
try {
await client.creators.get("someone");
} catch (error) {
if (error instanceof TokTikApiError) {
error.isUnauthorized; // 401 — bad or expired key
error.isForbidden; // 403 — key lacks the scope this resource is sold under
error.isPaymentRequired; // 402 — out of credits; retrying will not help
error.isRateLimited; // 429
error.retryable; // 429 + 5xx only
error.requestId; // quote this to support
}
}Coverage
| Namespace | Scope | Endpoints |
|---|---|---|
| client.live | live:read, live:stream | creator performance, stream token, stream() |
| client.rankings | rank:read | official({ board }), movers({ board }), history({ board }), regions(), games({ region }) |
| client.livePro | live-pro:read | directory({ region }), regions() — the LIVE Pro creator directory |
| client.recruit | rank:read (pool), watchlist:manage (marks) | list({ region, excludeLivePro, … }), mark(creatorUid, { status, note }), unmark(creatorUid), marks() |
| client.creators | creator:read | list, get, changes, analysis, following, followers |
| client.content | content:read | creator videos (no params), video, video comments |
| client.gifters | gifter:read | list, get, for creator |
| client.agencies | agencies:read | list, get, regions — TikTok's LIVE agency directory; contact details only on the Agency plan |
| client.trends | trend:read | list |
| client.exports | export | list, create, get, download |
| client.account | keys:manage | entitlements, usage |
Option names mirror the server schemas exactly. Those routes declare additionalProperties: false,
so an invented parameter is a hard 400 — not an ignored extra.
The pre-D3.1 method listCreatorPerformance still works and is marked deprecated; it forwards to
client.live.creatorPerformance.
0.5.0 (breaking types, same runtime): the 202-capable methods (creators.get, creators.following /
followers, content.creatorStories, content.videoComments, live.playback) are typed as a union with their
pending body, so code that read .data without narrowing no longer compiles. Adds isPending, waitUntilReady,
TokTikPendingTimeoutError, PendingResult and LivePlaybackPending (CR.9). What the methods return at runtime is
unchanged.
0.4.1: recruit.list({ country }) and the recruit export accept country (the creator's RESOLVED country — what a Recruit country card groups by; region stays the board bucket). Additive, no breaking change.
0.4.0: adds client.livePro (directory, regions — LP.4/LP.7) and client.recruit (list, mark, unmark,
marks — the recruiting pool + your workspace's marks, EC.1/EC.2); exports.create takes the new recruit dataset
with its filters (region, excludeLivePro, newOnly, minFollowers, maxCoins30d). Additive, no breaking change.
0.3.0 (breaking): live.listSessions, live.getSession and the deprecated listLiveSessions /
getLiveSession were removed — GET /v1/live/sessions is no longer part of the API. exports.create now
takes the full export request: rankings (board + region), gifters (rangeDays, optional region /
segment), creator_roster, creator_gifters (creatorUniqueId, optional window); live_sessions is gone.
Agencies: client.agencies.list({ region: "MENA", q: "lumio", sort: "size", limit: 24 }), client.agencies.get(agencyId)
and client.agencies.regions() read TikTok's LIVE agency directory. region is TikTok's own agency region bucket
(MENA, US+), sizes are TikTok's published bands, and get() carries the agency's email / phone / address
(contact, public on every plan) plus its links when known. The directory is not exportable (dataset: "agencies" was
removed in EX.7 and answers 400).
Publishing
The npm artifact is self-contained: @v2/contracts is a build-only workspace dependency and the
public declaration graph is bundled into dist/index.d.ts. npm run test:package imports the built
ESM entry point and type-checks a clean consumer whose only dependency is this SDK.
Releases are tag-driven through .github/workflows/sdk-release.yml:
git tag sdk-js-v0.5.0
git push origin sdk-js-v0.5.0The npm package must have a Trusted Publisher configured for this repository and that workflow. The
package remains ESM-only ("type": "module"); a dual ESM/CJS build is not currently provided.
Known scope limits
- Alert-rule (
webhook:manage) and API-key (keys:manage) management endpoints are sellable but intentionally out of scope here; onlyusage/entitlementsare exposed.
