Skip to content

Playground and tools

Three console pages let you learn this system by using it: /playground drives any read endpoint from the browser and tells you whose fault a failure was, /tools exposes the primitives the service uses on every request, and /docs is the API reference generated by the instance you are actually running. After this page you should be able to make a call, read the failure when it fails, reproduce the exact platform request outside this instance, and take a signature apart.

Page Path What it does What it costs
Playground /playground Calls the real read endpoints with a form, then shows the normalized result, the raw platform payload, the failure facts and copy-ready snippets A real upstream request and an identity, unless the answer is cached
Building blocks /tools Sign a URL, decode a signature, parse a link, batch-parse a list, mint a guest identity Nothing at all, except minting — a browser for Douyin and TikTok, one plain HTTP request for Bilibili — and signing a Bilibili URL, which fetches two public keys at most once an hour
API docs /docs Swagger UI for this instance’s own OpenAPI document, in the console’s language Nothing

They are separate because their costs are separate. Everything on /tools except the last tab is arithmetic over strings you supply: no network, no identity, no row in the request log. The one exception is signing a Bilibili URL, whose signature needs two keys the site publishes; see Sign a URL. Everything on /playground goes through the scheduler and spends something. Keeping the cheap things where they cannot accidentally become expensive is the point of the split.

All three are console pages behind a login. See Console overview for the shell, the navigation and the roles.


The page fills the viewport as three resizable panes rather than one scrolling column:

  1. Endpoints — the platform picker, the catalogue, and below it this session’s history.
  2. Request — the form for the selected endpoint.
  3. Response — status, payloads, failure facts, signature, snippets.

The splitter positions are remembered per browser under dtk.split.playground. Default widths are 16 / 42 / 42 percent, and the panes will not shrink below 170 / 320 / 320 pixels.

Above the panes is an address bar showing the method and the URL exactly as it will be called, query string included, rebuilt as you type. The Send button lives there, next to the URL it will call. Pressing Enter in the form sends too.

Response panels are collapsed by default and opened deliberately — one large raw payload would otherwise push everything below it off the screen. The failure panel is the exception and is never collapsed, because on a failed call it is the entire reason to be on the page.

Seventeen entries. The first is platform-agnostic; the other sixteen are addressed as /api/v1/{platform}/... for the platform picked above the catalogue (douyin, tiktok or bilibili). The picker sits above the list rather than in the form because the list depends on it: an entry the selected platform does not serve stays in the list, greyed, with “Not served on platform” under it, and selecting it says so and disables Send. It used to sit in the form, the catalogue offered every entry on every platform, and the first a reader learned of a gap was the refusal. What each platform serves comes from GET /api/v1/system/capabilities (see the REST API guide); until that answers, nothing is greyed.

Entry Method Path Scheduler endpoint
Parse any link POST /api/v1/parse decided after the link is expanded
Post detail GET /api/v1/{platform}/video {platform}.content_detail
Video parts (multi-part videos) GET /api/v1/{platform}/video/parts {platform}.content_parts
Post comments GET /api/v1/{platform}/video/comments {platform}.comments
Comment replies GET /api/v1/{platform}/video/comments/replies {platform}.comment_replies
Author profile GET /api/v1/{platform}/user {platform}.author_profile
Author posts GET /api/v1/{platform}/user/posts {platform}.author_posts
Author likes GET /api/v1/{platform}/user/likes {platform}.author_likes
Author reposts GET /api/v1/{platform}/user/reposts {platform}.author_reposts
Bookmark folders (collections) GET /api/v1/{platform}/user/collections {platform}.author_collections
Saved posts (every folder) GET /api/v1/{platform}/user/bookmarks {platform}.author_bookmarks
One folder’s details GET /api/v1/{platform}/collection {platform}.collection_detail
Saved posts (one folder) GET /api/v1/{platform}/collection/posts {platform}.collection_posts
Author’s own series (mix / playlist) GET /api/v1/{platform}/mix/posts {platform}.mix_posts
Author followers GET /api/v1/{platform}/user/followers {platform}.author_followers
Author following GET /api/v1/{platform}/user/following {platform}.author_following
Popular and rankings GET /api/v1/{platform}/popular {platform}.popular, .popular_weekly, .popular_precious or .ranking, by board
Related posts GET /api/v1/{platform}/video/related {platform}.related_posts
Author’s series (mixes / playlists / seasons) GET /api/v1/{platform}/user/mixes {platform}.author_mixes
Sound detail GET /api/v1/{platform}/music {platform}.music_detail
Posts using a sound GET /api/v1/{platform}/music/posts {platform}.music_posts
Hashtag detail GET /api/v1/{platform}/hashtag {platform}.hashtag_detail
Posts under a hashtag GET /api/v1/{platform}/hashtag/posts {platform}.hashtag_posts
Search posts GET /api/v1/{platform}/search/posts {platform}.search_posts
Search accounts GET /api/v1/{platform}/search/users {platform}.search_authors
Live room GET /api/v1/{platform}/live {platform}.live_room, and bilibili.live_streams for a Bilibili room that is on; {platform}.live_room_by_id on Douyin and TikTok when asked by live_id or a Douyin share link
Live room audience ranking GET /api/v1/{platform}/live/ranking douyin.live_ranking
Whether live rooms are on air GET /api/v1/{platform}/live/status {platform}.live_status
Trending board GET /api/v1/{platform}/trending {platform}.trending

Not every platform serves every entry, and asking for one it does not serve is refused at the API boundary with UNSUPPORTED_CONTENT (400) naming the platform and the capability: no request id, and no scheduler endpoint of that name for the failure panel to match.

Author followers and Author following are TikTok only. The registry declares tiktok.author_followers and tiktok.author_following and no Douyin or Bilibili equivalent. On Douyin the asymmetry is the platform’s own — Douyin answers “not signed in” for a follow graph even to a healthy guest identity, and a route that submitted the task anyway would hand back an empty page and let you conclude the author has no followers. The Playground starts on Douyin, where both rows are greyed.

Bilibili serves nine of the sixteen: post detail, comments, comment replies, author profile, author posts, an author’s public folders (Bookmark folders, which on Bilibili takes the author), one folder’s videos (Saved posts (one folder)), and the two entries no other platform has — Video parts, which lists a multi-part video’s parts with their titles and lengths, and Popular and rankings. Every other entry answers UNSUPPORTED_CONTENT on Bilibili. Post detail there makes a second request for the playable file, which is scheduled, signed and written to the request log under its own name, bilibili.content_streams; if it fails the detail still comes back, without media.video. The REST API guide has the rest of what differs.

The scheduler endpoint name is what the token bucket and the circuit breaker are keyed on, and it is the name the failure panel and the health table use. POST /api/v1/parse has no fixed one because the worker only learns which endpoint it needs after following the link. Popular and rankings has four: its board (hot, weekly, precious or rank) picks one of four upstream operations, each with its own circuit, so the health shown is the chosen board’s.

The catalogue is a hand-written list of the read endpoints, not a rendering of the OpenAPI document — the rest of the API (identities, downloads, settings, archive) is not here. Use /docs or the REST API guide for those.

Fields are grouped into three sections.

Target. What you are asking about. Where two fields are alternatives — url or aweme_id, url or sec_user_id — exactly one has to be filled in, and the form says so on both if neither is. On Bilibili the same two fields take a bvid such as BV1xx411c7mD (an av number is accepted and converted) and the author’s numeric mid. Some endpoints have a genuinely required field instead: comment_id for replies, mix_id for a mix, collection_id for a folder. On Bilibili, Post detail also takes part: which part of a multi-part video to return the stream of.

Placeholders follow the selected platform, and Load a platform example fills the target with a real, public post or author on it, so the first call made with it succeeds. It is not offered where no target can be named in advance — a comment, a folder, a mix. A parameter the selected platform’s endpoint does not take is left out of the form rather than shown disabled: Douyin’s folder list has no author field, and only Bilibili’s comments take sort.

Paging. cursor and count, on the endpoints that page. count is capped at the platform’s own page ceiling, by the form and by the API: 50 on Douyin, 35 on TikTok, 20 on Bilibili. A larger count is a 400; the form used to allow 50 everywhere and leave the API to say so.

Request. The envelope parameters every read endpoint accepts:

Parameter Type Console default What it does
include_raw boolean on Also return the platform’s own untouched payload. The API’s own default is off; the console turns it on because comparing the two is what this page is for. It is large — a single post runs to hundreds of kilobytes.
wait number empty Hold the connection this many seconds waiting for the task. Empty or 0 returns a task id immediately. The form allows 0–30; the instance ceiling is api.max_wait_seconds (default 30) and asking for more is a 400.
proxy text empty Send the upstream request through this egress. Refused unless an administrator has set security.request_proxy to public or any.
identity picker any Send as one named identity and no other. Needs identity:manage (or admin) and an operator role. Required where the endpoint answers only about the identity sending it — see below.
refresh boolean off Ignore the response cache and any identical task already in flight, and ask upstream again.
explain boolean off Report the request as it actually went out. See below.

The identity field is a picker rather than a text box, because the value is a UUID nobody types from memory and the real question is “which of my accounts”. The first option, Any identity (scheduler picks), leaves the choice to rotation. After it come up to 200 identities, newest first, each reading note · full id · state · logged in, or its source (minted or imported) for a guest. The note leads when there is one, because it is what tells your logins apart and the id is read only to confirm it; the id is printed whole, because an option has no hover and no copy button to fall back on. Retired identities are excluded — retirement wipes the jar, so there is nothing left to send as — and on a platform-scoped endpoint only that platform’s identities are offered. Switching platform clears a pin that no longer applies, rather than letting you send a Douyin identity to a TikTok endpoint and read the 400 that comes back.

If the picker is empty, the caller cannot list identities. That is not a bug on this page: the same scope gates listing them and using one.

Some endpoints answer only about the identity sending them: Saved posts (every folder) on Douyin and TikTok, and Bookmark folders on Douyin, whose request carries no user id. The capability table marks them session_bound, and the form then marks identity required and says so above the fields, rather than letting a 400 say it after the call. Pin a logged-in identity: a guest has nothing saved to show. Pinning takes the operator role, so a session below it is told the call cannot succeed for it, and Send is disabled instead of spending a request on a known refusal.

The proxy field is offered even on an instance where security.request_proxy is deny, which is the default. That is deliberate — a refusal that names the setting is more use than a field that does not exist. See Configuration.

refresh is worth understanding before you reach for it. Two separate mechanisms make a repeat call free: the task layer joins an identical run that is already going or has just finished, and the fetch layer caches the shaped body. refresh turns off both, and costs an identity and a real upstream request. The cached answer’s lifetime depends on what was asked for and is configurable:

Setting Default Applies to
cache.content_ttl 1800 s (30 minutes) one post
cache.author_ttl 900 s (15 minutes) a profile, sound or hashtag
cache.list_ttl 300 s (5 minutes) anything paged, and a live room, its audience ranking and whether it is on air

Validation is client-side first: required fields, one-of groups, numbers, and ranges are checked before anything is sent, so a typo costs nothing.

The console calls with its session cookie. The API is async-first: a call without wait returns 202 with a task id, and the console polls the task for you. The HTTP request times out after 60 seconds; polling a task gives up after 180. A Cancel button appears while a call is running.

When it settles, a strip across the top of the response pane reports:

Item Meaning
HTTP <status> The status of the final HTTP response
Duration Measured in the browser, from click to answer — includes polling
Server meta.duration_ms, the time the worker recorded for the upstream call
Cache hit Whether the answer came from the response cache (success only)
Request id The correlation id, copyable. This is the value to search for on the Logs page

The two durations are shown separately on purpose. A long browser duration with a short server duration means you spent the time in the queue, not on the platform.

On success, four collapsible panels:

  • Normalized result — the payload with every raw island stripped out. This is the stable contract every client sees.
  • Raw response — the platform’s own payload, present only when include_raw was set. When it was not, the panel says so and offers Include the raw payload and run again, which sets the flag and re-sends in one click.
  • Response metadata — the whole meta object. If the payload is a page with a cursor and has_more is not false, a Fetch the next page button appears here; it fills cursor in and sends again, which spends another request.
  • Copy-ready call — see Copy-ready snippets.

Two things can appear above them:

  • A guest identity answered. When meta.guest_fallback is set, no logged-in identity was available and a guest served the call, so the answer may hold less than a logged-in one would see: Douyin leaves an author’s newest posts out of a guest’s first page, and Bilibili shows a guest three comments and no second page. Nothing in the payload says so except that flag, which nothing used to read. The warning links to the platform’s logged-in identities, to add one.
  • Next. A result that is one post offers Download media and Open in Library; an author offers This author in Library. The download is the same request the Downloads page sends with its defaults, so “already downloaded” means the same from both. The demo account, which cannot start downloads, is not offered one.

The failure panel opens automatically and puts five things in one place: the stable error code (with a retry-after when the error carries one), the human message and its hint, which identity served the call, the endpoint’s circuit state, and the identity pool census. The request id sits in the status strip just above.

Those are the facts that separate the platform changed from we are out of identities:

What you see What it usually means
A code, an identity, circuit closed, healthy pool This request or this target. Read the error code.
Circuit open on this endpoint The breaker tripped on repeated failures; the endpoint is being rested. Every endpoint’s circuit is listed on the Overview page (/) — see Console overview — and Identities and proxies says what to do while one is open.
active 0 and degraded 0 in the pool Nothing on the call’s platform can serve it — the census shown is that platform’s. Calls to it are rejected with IDENTITY_POOL_EXHAUSTED until an identity recovers. See Identities and proxies.
No request id at all The call never reached the scheduler — it was rejected at the API boundary (scope, validation, rate limit).

The serving identity is resolved from two sources. If the run asked for an explanation and the console took the async path — no wait, or a wait that expired — the identity comes straight back with the task and appears at once. Otherwise the console looks the request id up in the request log, which lags a little; while it lags the panel says so and offers a refresh rather than pretending the answer is unknown.

Filling wait in the form is the second case even with explain on. The console reads the explanation only off the task it polled, so an answer that came back inline inside the wait window leaves the identity to the request-log lookup, and leaves the signature, cookie-jar and upstream-curl panels empty. Leave wait empty on the runs you intend to explain.

Below the response, and available even before you have made any call, is the Endpoint and pool panel: the circuit for the selected endpoint (or a count of how many of all endpoints are tripped), success rate, risk rate and sample count, and a per-state census of the selected platform’s identity pool (every platform together for Parse any link, which works the platform out from the link), with a link to that platform’s identities and a warning when none of them is active or degraded. It is polled every few seconds, so it is current at the moment a call fails. An operator who arrives to find out why nothing works needs this before they need a response body.

The Signature panel lists the signature parameters this request actually carried, read straight out of the URL the worker sent. Values are shown whole and not elided — the reason to look at a signature is to compare it with something, and half of one compares with nothing.

The parameters it picks out:

a_bogus, X-Bogus, X-Gnarly, X-Dynosaur, msToken, x-secsdk-web-signature, verifyFp, fp, uifid, and Bilibili’s w_rid and wts.

A Read them back button posts the whole signed URL and its User-Agent to /api/v1/tools/decode and renders the result inline — the same view the Decode tab produces. That is a separate, deliberate click rather than something that happens on arrival: decoding is a question most runs do not ask, and firing it automatically would spend a round trip on every call.

This panel needs an explanation to exist, because the signature that matters was computed in the worker against the platform’s own URL, and that URL is not part of an ordinary answer. Without one, the panel says so and offers Send again and explain it.

It also needs the async path. The console picks the explanation out of the task it polled, so a run with wait filled in that finished inside the window still shows Explain is on. Send the request and this panel fills in. — even though explain did succeed and was audited — and the cookie jar and upstream curl below it stay empty for the same reason. Clear wait and send again.

explain — what request did this instance actually make?

Section titled “explain — what request did this instance actually make?”

explain=true makes the instance describe the upstream call it made, in the shape it made it.

What comes back

Field What it is
method The method sent upstream
url The full platform URL, signature parameters included
headers Every header the transport was about to send — the signer’s, plus the identity’s own User-Agent, language and client hints
cookie_header The identity’s jar as one Cookie: header, ready to paste
identity_id Which identity served the attempt
signer Which signer produced the signature
endpoint The internal endpoint name, e.g. douyin.content_detail
proxy The identity’s exit, masked
body A POST’s body exactly as sent, or null. The signature covers it, so the curl line carries it as --data-raw

What it costs. An explanation describes an attempt, and a cached answer made none — so explain implies refresh: it skips the response cache, and an explained call is never joined onto an unexplained one already in flight. Every explained call is a real upstream request and spends an identity.

Who may ask. The answer contains a live cookie jar, so it is gated like pool management and not like reading: an identity:manage (or admin) scope and an operator role. A douyin:read key may ask this instance to use a jar; it may not ask to be handed one, and “show me the request you made” is the same disclosure by a longer route.

It is audited. Every accepted explain writes a request.explained entry to the audit log naming who asked and for which endpoint — never the jar itself. The same bargain the identities page makes when it reveals cookies.

It survives a refusal. The explanation is stored on both outcomes, because “the request was refused, show me what we sent” is the case the feature exists for. A failed task carries it in exactly the same place a successful one does.

It is stripped for readers who may not see it. The explanation is stored with the task result. When anyone reads that task back, the explain block is removed unless that reader could have revealed the jar directly.

Where it lands on the wire. In the response metadata under explain — as result_meta.explain when you fetch the task, and in meta when a wait long enough to finish the task returned a successful answer inline. A task that failed inside the wait window is rendered as an error envelope, and an error envelope’s meta carries only request_id. The explanation is still stored on that task; it has to be read back from GET /api/v1/tasks/{task_id} as result_meta.explain.

With an explanation present, the Endpoint and pool panel gains the concrete facts about the run in front of you: the identity id, the signer, the masked exit, and the jar.

The jar is behind a Show the jar click rather than behind a mask. Half a cookie helps nobody, and you have already asked for it by name; what the click buys is that it is not sitting open on a shared screen. Until you click, the panel shows only how many cookies there are.

Treat what you copy as a credential. It is a live session for a real platform account or guest, it will be accepted by anyone who has it, and it does not belong in a bug report, a shared Postman workspace or a chat message. See Security.

Below the jar, the console assembles the whole upstream request as a curl line: the platform’s own endpoint, the signed query byte for byte, every header the transport was going to send, and the identity’s jar.

This is the thing anybody debugging a refused call ends up building by hand, and building it by hand is where it goes wrong. Three things have to line up or the platform refuses you, and all three are known here and were not guessable before:

  1. The query goes byte for byte. The signature covers the query as its own encoder produced it. Re-encoding it — which most HTTP clients do helpfully and silently — changes the bytes the signature covers.
  2. The User-Agent has to be the one it was hashed with. Douyin and TikTok hash the UA into the signature. “The UA I am sending is not the UA I signed with” is the most common way a hand-built request fails while looking perfectly correct. (Bilibili’s WBI signature covers only the query and its timestamp.)
  3. The jar has to be the identity’s own. The platform answers a session. A correct signature with no cookies gets 200 and an empty body.

The curl line is also short-lived. The signature carries a clock and the jar carries a session; both go stale. If it worked ten minutes ago and does not now, re-run the explained call rather than debugging the old line.

Postman’s importer accepts a raw cURL command — Import → Raw text, paste, and it becomes a request. Two caveats matter more here than in most APIs:

  • Check the query survived. Any client that re-encodes the query string breaks the signature, and the failure looks like a bad signature rather than a bad client. After importing, compare the URL in Postman against the line you pasted, character for character. If they differ, send the URL as one opaque string rather than as parsed key/value parameters.
  • TikTok will refuse it anyway. TikTok checks that the TLS fingerprint agrees with the User-Agent. Postman, curl, httpx and every ordinary HTTP client present their own TLS fingerprint, so a Chrome User-Agent over a non-Chrome handshake is refused whatever the signature says. That is not something a header can fix — it is why this project ships a browser-grade transport. On Douyin the same line usually does work.

The same warning applies to the curl line itself on a machine whose curl is not built against a browser TLS profile. Use it to check what was sent; do not conclude from its failure that the signature was wrong.

The Copy-ready call panel generates a snippet for the form as it currently stands, in three flavours:

Snippet What it produces
curl curl -X <method> with Authorization: Bearer and Accept-Language, plus Content-Type and -d on a POST
Python An httpx call with params, json, headers and a 60-second timeout, printing payload["data"]
JavaScript A fetch call that throws on payload.success === false and logs payload.data

Every snippet carries the placeholder key dtk_xxxxxxxx_your_key_here. Replace it with a real key from the API keys page — the console itself authenticates with a session cookie, which a script does not have. Outside the browser, use Authorization: Bearer <key> or the X-API-Key header.

The snippets call this API. They are not the upstream curl and they do not need a cookie jar.

Under the catalogue, the last 12 calls of this session: which endpoint, which platform, whether it succeeded, and how long it took. Clicking one puts its parameters back in the form — and deliberately does not re-send, because a click that replays an upstream call would spend an identity by accident.

History is held in memory only and is gone when you leave the page. A request can carry a proxy password or an identity id, and neither belongs in storage that outlives the tab.


Five tabs over five API endpoints. These are the pieces the service uses on every request, published because this is an open-source project and people build on them. Everything here except the last tab is a pure function of what you type: no network, no identity, no request log row — apart from the two public keys a Bilibili signature needs, described below.

One warning is worth having before you start, because it is the first thing anyone hits: a signature alone is not enough. Douyin and TikTok answer a session, so a correct signature sent with no cookies gets 200 and an empty body, and a Bilibili request still has to carry a visitor id (buvid3) from a jar. The signing tab says what else has to line up; the minting tab is what produces it.

POST /api/v1/tools/sign — computes the signature parameters a platform API URL needs. For Douyin and TikTok it is pure arithmetic: nothing is fetched, no identity is spent, and the result depends only on what you send.

For Bilibili it signs with WBI — w_rid and wts — computed natively from two public keys the site publishes. Those keys are fetched from the site’s nav endpoint and cached for an hour per process, so this tab makes that one request at most hourly and spends no identity; if a refresh fails, keys up to a day old keep being used. This is the same native signer the worker uses: browser-rpc never signs Bilibili, whatever signing.mode says.

Field Limit Notes
Platform — douyin, tiktok or bilibili
API URL 4096 chars The full API URL including its query. Must start http:// or https://.
User-Agent 512 chars Douyin and TikTok hash it in; Bilibili’s WBI does not. Leave blank for a Chrome default, which is echoed back.
msToken 512 chars TikTok only. Sealed into the signature, so it must be the token the request will really carry — an invented value is worse than none.
Cookie jar 8192 chars Any paste format: a Cookie: header, DevTools JSON, Netscape. Douyin computes x-secsdk-web-signature over the visitor id inside it.

The default User-Agent, used when you leave the field blank:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36

Start from an example. One button per platform fills every field — Douyin’s profile query and TikTok’s post list, both taken from a real browser request, and Bilibili’s video detail query (/x/web-interface/wbi/view), whose jar holds only a placeholder buvid3. Two things were removed on purpose, and the removals are the lesson: the signature parameters are gone, because they are what this tool computes and pasting a stale one is the commonest way to get a 200 with an empty body; and the cookie values are placeholders, because a jar is a live login. The cookie names are real, which is the actual question somebody has when they look at that field.

The results.

  • Signed URL — ready to send, with a Read this signature back button that hands the whole thing to the Decode tab. The two forms are inverses and running one straight into the other is the most useful thing you can do with them.
  • How this query was assembled — the same result unflattened, one box per layer of the pipeline in the order the layers ran. This is the part worth reading when a request is refused, because it says which layer is missing rather than handing you one flat query string.
  • Parameters added — the signature parameters, keyed by the algorithm name.
  • Headers the signature requires — send every one of these alongside the signed query. Douyin refuses a sign-protected endpoint without them.
  • User-Agent — echoed back. Send the request with this exact string.

The layers:

Layer What it contributes
business What you put in the URL, minus msToken — which is a session value wherever it was written, and is counted in the next layer instead
session The msToken, and ms_token_source saying where it came from: url, cookies, generated or absent
signature The seal. seal_param names the parameter that actually is the seal, and roles says the same per parameter
websign Douyin only: x-secsdk-web-signature over the visitor id, with the salt and the md5 preimage

Two asymmetries in that table are the platforms’ own, not this project’s, and both are the kind of thing readers otherwise “fix”:

  • Only Douyin ever shows generated. Douyin’s signer invents an msToken when the URL carries none. TikTok’s never does, because TikTok verifies a token that is present and accepts one that is absent — a fabricated token fails where a missing one is fine. With no token, TikTok’s session layer is marked skipped with no_ms_token_supplied and an empty msToken is sent.
  • On TikTok, algorithm and the seal are different parameters. algorithm reports X-Bogus because that is the stable name of the scheme on the wire, but the X-Bogus TikTok Web sends on an HTTP request is the constant 1. X-Gnarly is the seal over the query and X-Dynosaur is the environment report it covers. A diagram drawn from algorithm alone would point at the one parameter that seals nothing, so seal_param exists to point at the right one.

Two things the signature layer will not show you, and why:

  • input_reconstructible is false and stays false. The seal is computed over the query as its own encoder produced it, and the string finally sent is built by a different encoder, so for some inputs the bytes signed are not the bytes sent. A preimage that is right for most queries would be read as documentation of the algorithm, which is worse than not offering one.
  • The websign preimage is shown, but only after being recomputed and checked against the signature beside it. An unverifiable preimage is dropped rather than published, for the same reason.

If the jar you sent carried no uifid, the websign layer is present and skipped with no_uifid_cookie. That is not a curiosity: it is why headers came back empty, and it is why a sign-protected Douyin endpoint will refuse the request naming uifid. Mint a jar on the Identity tab.

Whatever the tool returns, the checklist beside it still applies. Send the request with the exact User-Agent that was echoed back; use a TLS fingerprint that agrees with it; and send a cookie jar. The project measured this on 2026-09-08 by sending this endpoint’s own output: with all three, 2537 bytes and the expected profile; with no cookies at all, 200 and an empty body.

POST /api/v1/tools/decode — the inverse of the tab beside it, and the reason this project reversed these algorithms itself rather than vendoring somebody’s port: an implementation you own can be explained. Also pure arithmetic, with one exception: a Bilibili URL carrying w_rid needs the two public WBI keys, which are not in the request, so they are taken from the same hourly cache the sign tab uses.

Paste a whole signed URL and every signature parameter in it is decoded against the exact string that parameter seals. That is much the most useful form: only from a whole URL is the covered string known exactly, so only then can the checks run. A single value, or a name=value pair, works too. The value is limited to 8192 characters.

Fill in the User-Agent field even when the answer seems obvious. Douyin and TikTok hash the UA into the signature, and this is how you find out whether the UA you are sending is the one you signed with.

Read it as is left on automatic by default, where the value is identified by properties no other parameter in the set has. Name it yourself when the shape is ambiguous: a_bogus, X-Bogus, X-Gnarly, X-Dynosaur, msToken, x-secsdk-web-signature, verifyFp, w_rid. A bare 32-digit hex value is the one shape two parameters share: automatic reads it as Douyin’s x-secsdk-web-signature, so name a Bilibili w_rid, or better, paste the URL it came on.

Three different things arrive looking like the same table row, and taking the second for the first is the mistake this design exists to prevent. Every field says which one it is.

Kind Shown as What it means
plain Recovered Read straight out of the payload, exactly. aid, page_id, versions, counters, nonces
time Clock A clock, as both the raw number and an ISO-8601 instant
environment Environment What the SDK reported about the page it believed it was in — screen geometry, flags, canvas and WebGL hashes
digest Hash A hash, or a byte of one. The input is not recoverable; see the checks
checksum Checksum Internal redundancy, recomputed here — which is what proves the decode
opaque Unexplained Present in every capture, meaning not established. Named, not invented

So the honest summary is: recovered values come back, hashes are checked rather than reversed, and issued values have no plaintext underneath them at all. msToken and the visitor tokens are drawn or handed out by the platform; the decoder reports not_computed rather than reporting a failure to find something that was never there.

For anything hashed, the decoder checks instead of guessing. Give it a candidate — a URL that carries the query, a User-Agent — and each check comes back as one of:

Status Meaning
match Your candidate is the input this signature sealed. The exact covered string is shown, because it reproduced the value beside it
differs It is not. This is what turns “my request is refused” into “the signature I am sending was computed over a different URL”
not_supplied You did not send a candidate. Not a failure, and drawn differently from differs on purpose

Each check also reports how many bits of evidence there are, so “match” is never more confident than it should be:

Parameter Bits per check Notes
a_bogus 24 Three SM3 bytes per chain, over query, body and User-Agent
X-Bogus 16 Two bytes of a double md5
X-Dynosaur 32 A 32-bit hash over query, User-Agent and body
X-Gnarly 128 Whole md5s of query, body and User-Agent
x-secsdk-web-signature 128 A whole md5 of the rebuilt preimage
w_rid 128 A whole md5 of the sorted query and the mixin key
Parameter Platform What comes back
a_bogus Douyin Header magic, SDK version, two clocks, aid, page_id, a fortnight counter, the browser info string, env and detect flags, a call bucket and a tripwire — plus three digest chains
X-Bogus Douyin Constant lead, a timestamp in whole seconds, the canvas constant, three digest pairs and an internal checksum. Noted as superseded by a_bogus
X-Bogus = 1 TikTok Reported as a constant that seals nothing, not as a malformed value — TikTok Web genuinely sends the literal
X-Gnarly TikTok Sixteen fields: two checksums, env and ub codes, md5s of query, body and User-Agent, timestamp, nonces, SDK and SCM versions, call sequences
X-Dynosaur TikTok Twenty-five fields: versions, counters, the page, WebGL/canvas/device hashes, a timestamp, four 32-bit hashes
msToken either Length, padding and alphabet only. Issued, not computed. The length also identifies whose it is — a Douyin token in a TikTok request is 20 characters short and looks exactly like a signing bug until somebody counts them
x-secsdk-web-signature Douyin The digest itself, and — when the visitor id and timestamp travel with it — the salt and the exact preimage its md5 covers
w_rid Bilibili The digest, the wts clock beside it, and — when the keys could be had — the mixin key and the exact preimage: the query without w_rid, sorted and encoded as the page encodes it, then the key
verifyFp, fp, s_v_web_id Douyin The prefix, the mint time as base36 milliseconds, and the random tail. A token minted long before the request carrying it is the shape of a jar that has been sitting in a file

Notes below a result explain what was verified. checksum_verified means every input to the internal fold is itself a field of the payload and they all agree, so the whole layout came back correctly. noise_not_recoverable means some bytes are drawn per call and carry nothing — two signatures made in the same millisecond differ in most of their bytes and mean the same thing. wbi_keys_unavailable means a w_rid came with its query but the WBI keys could not be fetched just then, so the check reads not_supplied rather than differs.

When a value cannot be read at all, the reason says why: malformed (with the structural test it failed appended), one_way, not_computed, or unknown_parameter.

GET /api/v1/tools/parse-url — works out what a link points at without fetching anything. Accepts a share link or the whole clipboard text with a link buried in it, which is what the Douyin and TikTok apps actually put on the clipboard.

It reports the platform, what kind of resource the link names, the id, the handle where there is one, the canonical URL, whether it is a supported target at all, and whether it needs expanding. A recognised post link offers Download media and Open in Library, and a Douyin or Bilibili profile link the author in the Library — the same buttons as the Playground’s. A TikTok profile link yields the @handle, which is not the key the archive files an author under, so it offers neither.

Which id you get differs by platform, because the endpoints differ: a Douyin profile link yields a sec_user_id, a TikTok one yields the @handle, because that is what TikTok’s user-detail call accepts, and a Bilibili space link yields the numeric mid (Bilibili has no handles). A Bilibili video link yields the bvid, an av link included — the av number is converted to the bvid by arithmetic, not by a request. All of them come back under resource_id.

A short link (v.douyin.com, vm.tiktok.com, b23.tv, bili2233.cn) cannot be resolved without following it, and following it is a network call this endpoint promises not to make. Those come back with needs_expansion: true and no id; send them to POST /api/v1/parse instead — which is the Playground’s first catalogue entry. A bilibili.tv link is not recognised at all: the international site is a different service.

POST /api/v1/tools/parse-batch — the same recognition over a pasted list, because one at a time is the wrong shape for the thing people actually have: a column out of a spreadsheet, or the output of a scrape.

Paste one item per line. Blank lines and duplicates are dropped. A line that is a share caption with a URL inside it yields the URL, not four fragments; a line with several URLs yields several.

Limit Value
Items returned 1000
Text accepted 524288 characters

If more distinct items were found than were returned, the result says truncated and the page tells you how many were parsed, rather than leaving you to count.

Each line comes back as one of:

Kind Meaning
link A recognised link, with platform, resource and id
short_link A recognised link whose target needs a network call
content_id A bare post id that checks out, or a bare Bilibili bvid
bad_id A line of digits that cannot be a post id
unknown Not recognised as either

Douyin and TikTok mint post ids whose high 32 bits are the Unix second they were issued, so the table also shows when each such id was issued — read out of the id itself, not out of a request. It is at or a little before the publication time, never after. A bvid carries no such clock, so a Bilibili row has no issue time.

That check cannot tell a post that never existed from one that has been deleted: a plausible id is a plausible id. What it does is reject text that cannot be an id at all — which is the part worth doing for free, because the alternative is an upstream request that spends an identity to be told the same thing.

Copy the ids puts every recognised id on the clipboard, one per line, skipping the bad_id rows. It works over plain http too, where the browser offers no clipboard API, and says so when copying failed rather than claiming it worked.

POST /api/v1/tools/identity — for Douyin and TikTok, drives a real browser to the platform and hands back the cookies it was issued. This is the half of the work that cannot be computed: msToken on TikTok and UIFID_TEMP on Douyin are issued by the platform to a browser that loaded its page. No algorithm produces them, which is why a signature alone is not enough.

Bilibili needs no browser. Its guest is one plain HTTP request to the site’s /x/frontend/finger/spi, which hands out buvid3 and buvid4, made through this instance’s own TLS-impersonating transport and behind the proxy you give.

What a browser mint costs, plainly:

  • Tens of seconds. A browser has to launch, load the page and wait for its scripts to finish setting cookies.
  • identity:manage scope and the operator role. Ordinary read keys do not carry the scope, and a viewer’s session is refused. This applies to Bilibili too.
  • A browser-rpc service. Without DTK_BROWSER_RPC_URL configured, a Douyin or TikTok mint fails saying so and naming the setting; a Bilibili mint does not need it. browser-rpc is the heaviest container in the stack and is an optional compose profile; see Installation.

The optional Proxy field mints behind a given egress, and is worth setting: cookies minted at one address and used from another are exactly the mismatch Douyin and TikTok look for. It goes through the same security.request_proxy gate as every other proxy parameter — a tool endpoint must not be the way around that setting.

Nothing is stored. The jar is returned to you and forgotten. To add one to this instance’s own pool, use the Identities page or POST /api/v1/admin/identities/mint — see Identities and proxies.

The result is the cookie jar, the browser fingerprint it was minted with — User-Agent, browser family and major version, platform, screen, language, timezone — and the exit address it was seen from. Send all of it together. A jar used with a different User-Agent is a weaker identity than no identity.

Tab Endpoint Scope needed Network Identity spent Time
Sign a URL POST /api/v1/tools/sign any platform read scope (douyin:read, tiktok:read or bilibili:read) none; for Bilibili, the two public keys at most hourly none instant
Decode POST /api/v1/tools/decode any platform read scope none none instant
Parse a link GET /api/v1/tools/parse-url any platform read scope none none instant
Batch POST /api/v1/tools/parse-batch any platform read scope none none instant
Mint an identity POST /api/v1/tools/identity identity:manage and the operator role a real browser session (Douyin, TikTok) or one HTTP request (Bilibili) one minted jar handed to you tens of seconds with a browser

All five are rate-limited like every other endpoint on this API.


/docs is the console’s own reference page. It renders Swagger UI inside the console shell, pointed at /openapi.json?lang=<language> for whichever language the console is set to, and restyled from the console’s own design tokens so it does not read as a different product bolted on. The override reaches colour, typography and radius — the widget keeps its own behaviour.

Above the widget, a card reports the document URL (copyable), how many paths and operations it declares, and which language it was generated in. If it says the document declares no operations, that is a deployment problem rather than an empty database: the api container registered no routes.

Operations arrive collapsed, models unexpanded, request durations shown, and authorization persisted across reloads. Try it out is enabled and the widget sends the browser’s credentials, so calls you make from /docs while signed in to the console are authenticated by your session cookie — no key to paste. That also means they are real calls: a Try it out against a platform endpoint spends an identity exactly as the Playground does.

Swagger UI’s assets are loaded from a public CDN (cdn.jsdelivr.net/npm/[email protected]) with a 15-second timeout. On an air-gapped deployment or behind a blocked CDN that will fail, which is expected rather than broken: the page says so and offers the server-rendered page, the raw document, and a retry, instead of leaving an empty frame.

The API serves its own documentation pages, independent of the console:

Path What it is
/swagger Swagger UI, server-rendered, with Try it out enabled and every tag collapsed
/redoc ReDoc, a read-only three-pane reference
/openapi.json The raw OpenAPI document

Both pages forward their own ?lang= to the schema URL, so /swagger?lang=zh gets the Chinese document. Language is resolved in that order: ?lang=, then Accept-Language, then the instance’s api.default_language. An unsupported ?lang= value is ignored rather than fatal, and negotiation continues with the header. Supported values are en and zh.

The console deliberately does not own /swagger. /docs is the console page, behind a session; /swagger stays credential-free for callers who have no console account, which is most consumers of a public instance. Both claiming one path meant which page you got depended on how you arrived — client-side navigation gave the console, a reload gave the bare document — and a reload is exactly what somebody does after expanding a tag.

Because /swagger, /redoc and /openapi.json need no session, anyone who can reach your instance can read its API surface. That is intentional for a service whose purpose is to be called by someone else’s program, but it is a fact to weigh before you expose the port. See Security.

The document declares both real authentication schemes, so the Authorize button works:

Scheme Where Value
X-API-Key header A key from the API keys page
dtk_session cookie Set by signing in to the console; sent automatically by a browser

Security is declared per operation rather than globally, so public endpoints are not wrongly shown as needing a key.

The document types the response envelope — success, data, error, meta — and not each endpoint’s data shape. That is a deliberate trade-off: responses are assembled as dictionaries, and declaring them a second time in the schema would only let the two drift. What a generated client genuinely needs is the part that never varies.


Everything on these pages is a normal API endpoint. Set a key with the right scopes first — see Users and API keys — and adjust the host if your instance is not on the default loopback address.

Terminal window
export DTK_KEY='dtk_...your key...'
export DTK_URL='http://127.0.0.1:8000'

Identify a link, without spending anything:

Terminal window
curl -s -G "$DTK_URL/api/v1/tools/parse-url" \
--data-urlencode 'url=https://www.douyin.com/video/7300000000000000000' \
-H "X-API-Key: $DTK_KEY"

Sort a pasted list of links and ids:

Terminal window
curl -s -X POST "$DTK_URL/api/v1/tools/parse-batch" \
-H "X-API-Key: $DTK_KEY" \
-H 'Content-Type: application/json' \
-d '{"text":"7300000000000000000\nhttps://www.douyin.com/video/7300000000000000001"}'

Sign a URL:

Terminal window
curl -s -X POST "$DTK_URL/api/v1/tools/sign" \
-H "X-API-Key: $DTK_KEY" \
-H 'Content-Type: application/json' \
-d '{"platform":"douyin","url":"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&aweme_id=7300000000000000000"}'

Read a signature back, checking a candidate User-Agent against it:

Terminal window
curl -s -X POST "$DTK_URL/api/v1/tools/decode" \
-H "X-API-Key: $DTK_KEY" \
-H 'Content-Type: application/json' \
-d '{"value":"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&a_bogus=...","user_agent":"Mozilla/5.0 ..."}'

Mint a guest identity and keep it yourself — needs identity:manage and an operator, and takes tens of seconds ({"platform":"bilibili"} mints without a browser):

Terminal window
curl -s -X POST "$DTK_URL/api/v1/tools/identity" \
-H "X-API-Key: $DTK_KEY" \
-H 'Content-Type: application/json' \
-d '{"platform":"douyin"}'

Ask a data endpoint to explain itself — needs identity:manage and an operator role, skips the cache, and is audited:

Terminal window
curl -s -G "$DTK_URL/api/v1/douyin/video" \
--data-urlencode 'url=https://www.douyin.com/video/7300000000000000000' \
--data-urlencode 'wait=30' \
--data-urlencode 'explain=true' \
-H "X-API-Key: $DTK_KEY"

When the call succeeds inside the wait, the explanation is in the response metadata under explain. Every other outcome sends you to the task: without a wait long enough for the task to finish you get a task id, and a task that failed inside the window comes back as an error envelope whose meta holds only request_id. In both of those cases read the explanation from GET /api/v1/tasks/{task_id} as result_meta.explain.

For the full request and response contract, see the REST API guide. For the same work from a terminal, see the CLI reference.


  • Concepts — what an identity, a lease, a circuit and a signer actually are. Worth reading if the failure panel’s vocabulary is unfamiliar.
  • Console overview — the Logs page, where a request id is searched for, and the Overview board that lists every endpoint’s circuit.
  • Identities and proxies — where the pool the Playground draws from comes from, how to fix an empty one, the Scheduler page, and what to do while a breaker is open.
  • Operations — backups, notifications, retention and monitoring, and what the container logs hold that the Logs page does not.
  • Troubleshooting — for the specific failures these pages will show you.
  • Security — what a revealed cookie jar means, and who should be able to ask for one.