Skip to content

REST API guide

This page explains how to call the HTTP API from a program: how to authenticate, how the uniform envelope is shaped, how the asynchronous task model works, how to page, and which errors your code has to handle. After reading it you should be able to write a client that submits work, collects results and backs off correctly when the instance pushes back.

It complements the generated reference rather than repeating it. The reference — /swagger, /redoc and /openapi.json — lists every endpoint, every parameter and every constraint, and it is generated from the same code that serves the requests, so it is never out of date. This page explains the parts a list of endpoints cannot: what a 202 means, when to retry, and what a parameter costs you.

Where What it gives you Credentials
/swagger Swagger UI over the full endpoint list, with “Try it out” None to view; a key to call
/redoc The same document rendered for reading None to view
/openapi.json The OpenAPI document itself, for code generation None
/docs The console’s own API reference page, themed and inside the shell None to view
This page How to use all of the above from a program —

/docs needs no session either: signed out it renders inside the console shell in its signed-out state and reads the same public /openapi.json everything else here reads.

All four honour ?lang= (see Response language), so /swagger?lang=zh renders the Chinese document.

The compose stack publishes the API on 127.0.0.1:8000 by default (DTK_BIND_HOST and DTK_BIND_PORT in .env change that — see Installation and deployment). If you have no instance and no key yet, Quick start produces both in about ten minutes. Every example below uses:

Terminal window
export DTK_BASE_URL=http://127.0.0.1:8000
export DTK_API_KEY=dtk_0a1b2c3d4e5f_REPLACE_WITH_YOUR_KEY

Path layout:

Prefix Contains
/api/v1/… Everything a program calls: content, tasks, tools, archive, downloads, admin
/api/setup/… First-run bootstrap only; anonymous by necessity and gated by the one-time setup token, and can never be added to api.public_endpoints
/healthz, /readyz Process probes, outside the versioned surface and outside the envelope
/mcp/ The MCP endpoint — keep the trailing slash, because /mcp answers 307 Temporary Redirect to it. See MCP and AI agents

The v1 in the path is the version of this contract. Two things inside it are also treated as contract and are append-only, never renamed: the error codes and the envelope keys. New fields may appear in data and meta over time, so parse defensively — read the keys you need, ignore the ones you do not.

The running version is reported by GET /api/v1/system/status, which returns version, commit, uptime_seconds, settings_version, component health, the identity pool census and storage use. It requires a credential, because naming component versions and row counts is more than an anonymous probe needs.

Every endpoint requires a credential unless an operator has explicitly opened it. A handful of routes are anonymous by construction and cannot be closed — login, logout, the two /api/setup routes and GET /api/v1/ios/shortcut, which the Shortcut asks for before it has anywhere to put an API key. They are listed in Routes that were never closed; /healthz and /readyz are unauthenticated too and are not in the API document at all. A program authenticates with an API key, in either of two header forms — they are equivalent, and the Authorization header is read first when both are present:

Terminal window
curl -sS "$DTK_BASE_URL/api/v1/auth/me" -H "Authorization: Bearer $DTK_API_KEY"
curl -sS "$DTK_BASE_URL/api/v1/auth/me" -H "X-API-Key: $DTK_API_KEY"

GET /api/v1/auth/me is the cheapest way to check that a key is live and see what it may do: it returns the account, the role, the scopes the credential carries, via (api_key or session) and the key’s own rate_limit_per_min.

A third form exists and is not for programs: the console signs in with POST /api/v1/auth/login and gets a dtk_session cookie. Use a key instead — a key carries scopes, can be revoked on its own, and does not expire with a browser session.

A key looks like dtk_<12 hex characters>_<random>. It is generated by POST /api/v1/admin/api-keys and shown exactly once, in that response; only a prefix (for display) and a SHA-256 digest (for verification) are stored, so nobody — administrator included — can read it back. Revocation takes effect on the next request, since authentication reads the row every time. See Users and API keys.

Scope Reaches
douyin:read Douyin content endpoints, and /parse for a Douyin link
tiktok:read TikTok content endpoints, and /parse for a TikTok link
bilibili:read Bilibili content endpoints, and /parse for a Bilibili link
archive:read GET /api/v1/archive… — what this instance has already stored
archive:export GET /api/v1/archive/export — the whole collection in one call; POST /api/v1/archive/export — the posts named in the body
media:read Download records and stored files
media:write Starting, pinning and cancelling a download, and deleting its files
transcription:read GET /api/v1/transcriptions… — the list and one transcript’s text, for an account of the viewer role or above
transcription:write Starting, cancelling and deleting a transcription; it reads them too
identity:manage The identity pool, and the identity and explain request parameters
admin Everything

Two rules are worth knowing because they surprise people:

  • A key is bounded by its scopes even when its owner is an administrator. On a self-hosted instance almost every key belongs to the admin account; a plain douyin:read key still cannot reach identity management or archive:export.
  • Reading a task’s result costs the same scope that creating it did. GET /api/v1/tasks/{task_id} checks the scope for the endpoint the task was submitted to, so a low-scope key cannot read a high-scope result out of a task id it was handed.

Some operations additionally require a role (demo < viewer < operator < admin) on top of a scope — identity and explain both need at least operator. Roles apply to the account the credential belongs to. ?proxy= is not one of them: it carries no scope or role check at all and is gated solely by the security.request_proxy setting, as ?proxy=<url> below describes.

An operator can list specific endpoints in api.public_endpoints (written as GET /api/v1/{platform}/video, exactly as the API document spells the path) and those are served without a credential. Admin, auth and setup paths can never be opened, whatever the setting says. An anonymous caller runs with the three platform read scopes — douyin:read, tiktok:read and bilibili:read — only, and is metered by client address rather than by key.

Sending a bad credential is not the same as sending none: a key we rejected gets UNAUTHENTICATED even on an opened endpoint, rather than being quietly downgraded to anonymous. That turns “your key expired” into an error you can see instead of “your key works but sees less”.

Every JSON response — success or failure — has the same four top-level keys.

{
"success": true,
"data": {},
"error": null,
"meta": { "request_id": "b6f0f2c6-4a5f-4a0e-9a1f-2b7c2b6f1f21" }
}
Key Meaning
success true or false. Branch on this, not on the HTTP status alone
data The endpoint’s payload. Always null when success is false
error null on success. Otherwise {code, message} plus optional retry_after and details
meta Always carries request_id. May carry cached, duration_ms, cursor and endpoint-specific extras, such as guest_fallback: true on an author feed a guest identity served - which may be missing the newest posts - or on a Bilibili comment page a guest served

Branch on error.code, never on error.message. The code is a stable enum that is never translated; the message is rendered in the caller’s language and is written for a human.

Submitting a link returns 202 with a task id:

Terminal window
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://www.douyin.com/video/7123456789012345678"}'
{
"success": true,
"data": { "task_id": "0f2f1b7c-3f9e-4b8a-9a11-0a6d2b0e51c3", "state": "queued" },
"error": null,
"meta": { "request_id": "b6f0f2c6-4a5f-4a0e-9a1f-2b7c2b6f1f21" }
}

Send a URL the allowlist does not admit and the same envelope carries the error, with HTTP 400:

Terminal window
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://evil.example/video/1"}'
{
"success": false,
"data": null,
"error": {
"code": "INVALID_URL",
"message": "The URL was not recognized as a supported Douyin, TikTok or Bilibili link.",
"details": { "reason": "host_not_allowed" }
},
"meta": { "request_id": "1d5b1a7e-90c7-4f2e-9b3c-6b2f0f9a8d44" }
}

(The message is English here because the command names no language and the instance default is en; add ?lang=zh and it renders in Chinese.)

details is keyed by name and its shape varies by code — the field that failed, the endpoint that refused, the id that was not found. It never contains a credential.

Every response carries two headers you can log:

Header Meaning
X-Request-ID The correlation id, also written into the request log
X-Response-Time-Ms How long this process spent on the request

Quote X-Request-ID in a bug report; the operator can find the matching row on the console’s Logs page. meta.request_id normally carries the same value — with one exception worth knowing: a result rendered from a finished task (a ?wait= call that completed) carries the worker’s fetch request id in meta.request_id, because the worker’s own metadata is merged in there. The header is always the HTTP correlation id.

POST bodies must be sent as application/json. Plain curl -d '{…}' sends application/x-www-form-urlencoded and the body will not parse — every example here passes -H 'Content-Type: application/json' for that reason. A body that fails to parse comes back as INVALID_PARAM with the field path and the reason in details.fields; the offending value is deliberately never quoted back to you, because for an unparseable body that value is the whole request.

A request body is capped at 1 MiB. Over that you get HTTP 413 with code INVALID_PARAM and details.limit_bytes.

Fetching from a platform costs a real upstream call on a real pooled identity, and it can take seconds. So the data endpoints are asynchronous by default: they validate, queue the work and answer 202 immediately with a task id. Nothing in the API container ever talks to a platform.

POST /api/v1/parse -> 202 {"task_id": "...", "state": "queued"}
GET /api/v1/tasks/{task_id} -> 200 {"state": "done", "data": {...}}

There are three ways to get the result. They differ only in who does the waiting.

GET /api/v1/tasks/{task_id} until state is done or failed. It is safe to poll and safe to call twice: the answer does not change until the task does. The payload:

Field Present Meaning
task_id always The id you polled
state always queued, running, done or failed
endpoint always What was submitted, e.g. parse or douyin.content_detail
created_at, finished_at always ISO-8601; finished_at is null until it settles
data when done The result payload, nested inside the envelope’s data
result_meta when finished cached, duration_ms, endpoint, platform, cursor, guest_fallback when a guest identity served an endpoint that gives guests less, and gated extras
error when failed {code, message, retryable} plus optional retry_after and details

Note the nesting: on a finished task the result is at body.data.data, not body.data. And note that a failed task is still HTTP 200 with success: true — the HTTP call to read the task succeeded; the failure is inside it, in data.state and data.error. This is the opposite of the ?wait= path below, and the difference trips people up.

A task’s stored error object carries an explicit retryable boolean, so an agent reading a result does not need to carry the non-retryable table around.

Results expire. The payload is kept for retention.task_result_hours (default 24). After that the row survives with its state and timestamps but the payload is blanked, and the lookup answers TASK_NOT_FOUND. That means submit the work again — not keep polling.

Add ?wait=10 to any submitting endpoint and the connection is held until the task settles, up to that many seconds. This is how you make a call synchronous, and it is meant for clients that cannot poll at all: an iOS Shortcut, a shell one-liner, a spreadsheet.

Outcome Status Body
Finished in time 200 The result in data, exactly as the task endpoint would have returned it
The task failed while you waited its own status (e.g. 503) A normal failure envelope with that error’s code
Not finished in time 202 {"task_id": "...", "state": "running"}
Above the instance ceiling 400 INVALID_PARAM, with details.max
Omitted, or 0 202 Returns at once, same as no wait
Negative 400 INVALID_PARAM

A 202 after the wait elapses is not an error and nothing was lost. The work is still running; the same task id fetches it a moment later. A client that treats the early 202 as a failure will resubmit work that was already queued, spend identities twice and get the same answer.

The ceiling is api.max_wait_seconds, default 30 seconds. A value above it is rejected rather than silently shortened — deliberately, because a caller that asked to block for five minutes has to learn that it cannot, or it will read the early 202 as a failure. The live ceiling is published on the parameter itself in the OpenAPI document, as maximum.

Nothing changes internally: the work goes through the same queue either way. ?wait= only decides who holds the connection. It is the wrong choice for a batch job or anything with its own event loop — you are holding an HTTP connection open to save yourself a loop.

POST /api/v1/parse and POST /api/v1/tasks/batch accept a callback_url in the body. When the task finishes the instance POSTs a notification there, so nothing polls and nothing blocks.

This is an outbound request to an address the caller chooses — SSRF in its plainest form — so it is fenced:

  • It is refused unless an administrator has set security.enable_task_webhook (default off). Asking for one on an instance where it is off is INVALID_PARAM on callback_url.
  • The URL must be https, and its host must not be loopback, private or link-local. Checked at submission and again at delivery, because the setting can be turned off and a queued task can outlive the moment it was accepted.
  • Redirects are not followed and certificates are verified.

The notification says what happened, not what was found — the result can be megabytes, and posting it to a third party is a data-flow decision nobody makes by typing a URL into a field:

{
"event": "task.completed",
"task_id": "0f2f1b7c-3f9e-4b8a-9a11-0a6d2b0e51c3",
"endpoint": "parse",
"state": "done",
"sent_at": "2026-09-10T04:15:12.883921+00:00"
}

A failure sends "event": "task.failed" and adds error with the code and a truncated message. Collect the actual result with GET /api/v1/tasks/{task_id}.

Property Value
X-Dtk-Event header task.completed or task.failed
X-Dtk-Signature header sha256=<hex>, HMAC-SHA256 over the exact bytes sent, when security.webhook_secret is set
Timeout 10 seconds per attempt
Attempts 3, with 2s then 8s backoff
Gives up early on any 4xx except 429

Verify the signature over the raw body bytes, not over a re-serialization of the parsed object — key order and separators would differ and an otherwise correct check would fail. Without a secret configured a receiver cannot tell a real notification from anyone who guessed the URL.

Delivery never affects the task. A webhook endpoint that is down, slow or hostile cannot turn a successful fetch into a failed task; every delivery failure is logged and swallowed.

GET /api/v1/tasks/{task_id}/events is a server-sent event stream, which the console uses instead of polling many rows at once.

Event Payload
state The task payload without the result, sent whenever the state changes
result The full task payload once it is done or failed
end {task_id}, immediately after result
timeout {task_id, state} when the deadline passes first
error {code: "TASK_NOT_FOUND", message} if the row disappears

?timeout= bounds the stream between 1 and 300 seconds and defaults to 300; a comment frame goes out every ~15 seconds so an intermediate proxy does not drop a quiet connection. Authorization happens before the stream opens. The privileged explain block is not sent on this stream — fetch it once over the ordinary GET, where the scope check sits beside the read.

POST /api/v1/tasks/batch takes up to 50 items and always answers 202. Submission is batched; tracking is not — each item gets its own task id and its own fate, so one bad link cannot smear the whole request into a single error:

{
"success": true,
"data": {
"items": [
{ "url": "https://v.douyin.com/abc123/", "task_id": "0f2f…", "state": "queued" },
{ "url": "https://evil.example/x", "task_id": null,
"error": { "code": "INVALID_URL",
"message": "The URL was not recognized as a supported Douyin, TikTok or Bilibili link.",
"details": { "reason": "host_not_allowed" } } }
],
"submitted": 1,
"rejected": 1
},
"error": null,
"meta": { "request_id": "…" }
}

Per-item errors are rendered in the language you asked for, like every other error this API returns. /tasks/batch takes no ?wait=: poll each task id, or supply a callback_url.

Submission is refused rather than queued once the Redis queue reaches sched.queue_max (default 500): 503 with code QUEUE_FULL, a Retry-After header, and details.queued naming the current depth. retry_after is sched.max_wait_seconds (default 10). A caller left hanging is worse off than one that is told to come back.

Two exceptions, both deliberate: work that joins an already queued identical task is admitted (it adds nothing to the backlog), and operator-triggered maintenance jobs are exempt — refusing to run the self check because the queue is deep would withhold the tool exactly when it is wanted. Media downloads and transcriptions are not exempt, because one operator’s bulk run would otherwise starve every read on the instance. Nor are the archive recheck and author backfill: they take only a platform read scope, so any read key could otherwise stack them past the ceiling, each one a run of real requests.

List endpoints page with an opaque cursor. You send back what the previous page gave you and nothing else.

Parameter Where Default Ceiling
cursor Platform lists, archive list first page 512 characters
count Platform lists 20 50, or the platform’s own lower ceiling (35 on TikTok, 20 on Bilibili)
limit GET /api/v1/archive 50 200

A platform list’s result carries the cursor in two places, because two readers want it in two shapes:

{
"success": true,
"data": { "items": [], "cursor": "1757462400000", "has_more": true },
"error": null,
"meta": {
"request_id": "…",
"cached": false,
"duration_ms": 812,
"cursor": { "next": "1757462400000", "has_more": true },
"task_id": "…",
"endpoint": "douyin.author_posts",
"platform": "douyin"
}
}

When polling instead of waiting, the same values are at data.data.cursor and data.result_meta.cursor.next.

Rules:

  • The cursor is opaque. Douyin pages by a millisecond max_cursor timestamp, TikTok by an offset, and some Bilibili lists by a page number; all of them are stringified into one field so a caller never has to know which. Do not parse it, do not increment it, do not construct one.
  • No cursor, or has_more: false, means the last page. Stop there rather than sending the previous cursor again.
  • A cursor belongs to the query that produced it. Changing count or any filter mid-walk and reusing an old cursor is not defined behaviour.

A page above the ceiling is refused by validation rather than silently trimmed for count (the OpenAPI schema declares maximum: 50). A platform that serves less than that has its own lower ceiling, refused the same way - INVALID_PARAM with details.maximum: 35 on TikTok, 20 on Bilibili. A caller that wants more should page; one that wants everything at once is a caller to slow down.

Not everything uses cursors. GET /api/v1/downloads and the admin listings use limit + offset and return a total (default 100, ceiling 500) because they read the local database; GET /api/v1/admin/logs/requests uses a bounded minutes window plus limit, so there is no combination of parameters that reaches the log table without a window.

Two error codes are the same in every language; the sentence beside them is not. Resolution order for one request:

  1. ?lang= query parameter — en or zh
  2. Accept-Language header — matched on the primary subtag, so zh-CN, zh-Hans and zh-TW all resolve to zh; * resolves to the configured default
  3. api.default_language (default en)
Terminal window
curl -sS "$DTK_BASE_URL/api/v1/parse?lang=zh" -X POST \
-H "X-API-Key: $DTK_API_KEY" -H 'Content-Type: application/json' \
-d '{"url": "not a link"}'

An unsupported ?lang= value is ignored rather than rejected, and negotiation continues with the header — so ?lang=fr still honours Accept-Language: zh. Anything that resolves to neither language falls back to English rather than being half-served in a language this build does not have.

This applies to the OpenAPI document too (/openapi.json?lang=zh, /swagger?lang=zh), and to a task’s stored error: the worker stores the code and its arguments, and the sentence is built when you read it, so one failed task can answer a Chinese console and an English agent correctly.

Every failure is the same envelope with success: false. Branch on error.code.

Code HTTP Retryable What it means
INVALID_URL 400 no Not a supported Douyin, TikTok or Bilibili link, or the host is off the allowlist
UNSUPPORTED_CONTENT 400 no This platform does not serve that operation; details.supported names the ones that do
INVALID_PARAM 400 no A parameter is wrong; details names the field. Also used for a body over the 1 MiB ceiling (with HTTP 413)
UNAUTHENTICATED 401 no No credential, or one that was rejected
FORBIDDEN_SCOPE 403 no The credential is not permitted; details names both halves - required_scopes or required_roles, and have_role / have_scopes / via
CONTENT_PRIVATE 403 no Private, or removed by its author
NOT_FOUND 404 no No such resource
TASK_NOT_FOUND 404 yes No such task, or its result has expired. Retryable in the sense the flag means — but no retry brings an expired result back, so resubmit the work
METHOD_NOT_ALLOWED 405 no Wrong method for this path; the Allow header lists the right ones
SETUP_ALREADY_DONE 409 no Setup has already run on this instance
CANCELLED 409 no Somebody cancelled the task deliberately
UNSUPPORTED_MEDIA_TYPE 415 no The body arrived in a media type this endpoint does not read
RATE_LIMITED 429 yes Too many requests; honour retry_after
SETUP_TOKEN_INVALID 403 no The first-run token is wrong or spent
NOT_CONFIGURED 501 no This deployment never had that optional component
INTERNAL 500 yes Unexpected; quote X-Request-ID in a bug report
UPSTREAM_RISK_CONTROL 502 yes The platform flagged the request; the identity is cooling down
UPSTREAM_CHANGED 502 no The platform’s response no longer matches the parser; details.path names the field. Report it
SIGNING_FAILED 502 yes Signing failed; the algorithm may be out of date
UPSTREAM_UNREACHABLE 502 yes Every attempt failed on the network before the platform answered; details.proxied says whether the last one went through a proxy. Usually this host’s egress, not the platform
IDENTITY_POOL_EXHAUSTED 503 yes No identity free; retry_after estimates the recovery
IDENTITY_LOGIN_REQUIRED 503 no The request needs a logged-in identity and rotation has none - later pages of a Douyin author’s posts, or of a Bilibili video’s comments or replies, and every page of a Douyin author’s likes. details.cooling counts the logged-in ones cooling down. Fixed by importing one, not by retrying; see Identities
ENDPOINT_CIRCUIT_OPEN 503 yes This endpoint is paused after repeated failures
QUEUE_FULL 503 yes The queue is at its ceiling
DOWNLOADER_UNAVAILABLE 503 yes The media sidecar is running somewhere and did not answer
TRANSCRIBER_UNAVAILABLE 503 yes The transcription sidecar did not answer, or the worker that picked the job up has no transcriber configured. Reported on the transcription task, not on the submit

NOT_CONFIGURED and DOWNLOADER_UNAVAILABLE / TRANSCRIBER_UNAVAILABLE look similar and are not: the first means this install never had the component, so no amount of waiting will help; the other two mean a service that exists is down.

Codes that clear on their own carry retry_after (seconds) inside error, and the same value in the standard Retry-After response header. Wait at least that long. RATE_LIMITED, QUEUE_FULL, IDENTITY_POOL_EXHAUSTED, ENDPOINT_CIRCUIT_OPEN and UPSTREAM_RISK_CONTROL are the ones you will actually see.

The retryable boolean appears on a task’s stored error (GET /api/v1/tasks/{task_id} → data.error.retryable) and in the OpenAPI error schema. A top-level envelope error carries the code, and the table above is what that code means. A client that branches on the code needs nothing else; an agent reading a task result gets the flag spelled out so it does not have to carry the table.

Anything else can reasonably be treated as “log it and stop”, but these four decisions have to be in your code:

  1. Back off and retry: RATE_LIMITED, QUEUE_FULL, IDENTITY_POOL_EXHAUSTED, ENDPOINT_CIRCUIT_OPEN, UPSTREAM_RISK_CONTROL, SIGNING_FAILED, UPSTREAM_UNREACHABLE, INTERNAL, DOWNLOADER_UNAVAILABLE, TRANSCRIBER_UNAVAILABLE. A run of UPSTREAM_UNREACHABLE will not clear by waiting if this server cannot reach the platform at all - see Troubleshooting. Honour retry_after where present; use exponential backoff where it is not.
  2. Never retry the same request: INVALID_URL, UNSUPPORTED_CONTENT, INVALID_PARAM, NOT_FOUND, CONTENT_PRIVATE, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA_TYPE, CANCELLED, NOT_CONFIGURED, UPSTREAM_CHANGED, IDENTITY_LOGIN_REQUIRED. A loop on one of these burns identities to be told the same thing.
  3. Fix the credential: UNAUTHENTICATED, FORBIDDEN_SCOPE. details carries both sides: required_scopes or required_roles for what the endpoint wants, and have_role, have_scopes and via for what you sent. via is the one to read first — it says whether this caller is judged by its scopes (api_key) or by its role (session), and an administrator’s key is still bounded by its scopes.
  4. Resubmit the work: TASK_NOT_FOUND. The result window has passed; the task id is dead.

A fixed one-minute window, per credential. It is abuse protection, not metering and not billing: the only purpose is stopping one runaway script from draining the identity pool.

Header Meaning
X-RateLimit-Limit Requests allowed in the current window
X-RateLimit-Remaining How many are left
X-RateLimit-Reset Unix timestamp (seconds) when the window rolls over

The limit is the key’s own rate_limit when one was set at creation (between 1 and 100,000), otherwise api.default_rate_limit_per_min (default 120). If that instance default is itself set to 0 or less, the counter is off for every caller without a key-specific limit and the three headers are absent. Exceeding the limit is 429 with code RATE_LIMITED, details.limit, error.retry_after and a Retry-After header.

The bucket is keyed by API key when you send one, by user for a console session, and by client address for an anonymous caller on an opened endpoint. That last one has a caveat worth knowing before you rely on it: behind Docker’s published-port userland proxy every request appears to come from the bridge gateway, so the anonymous bucket degrades to one shared bucket for the whole internet unless the operator declares a reverse proxy with DTK_FORWARDED_ALLOW_IPS. Degrading toward stricter is the right direction for an abuse counter, but it means one busy anonymous caller can lock others out. See Security.

Read the headers and slow down before you hit 429, rather than probing for the ceiling.

Repeat calls: coalescing, the cache and ?refresh=

Section titled “Repeat calls: coalescing, the cache and ?refresh=”

Two separate mechanisms make a repeated request cheap, and both are on by default.

Coalescing. Two identical requests arriving close together are joined onto one task rather than run twice — a hundred callers asking for the same video cost the pool one upstream request. The claim lives for 90 seconds. A task that has already failed is never joined: replaying a failure for the rest of the window would hide a retry that might well succeed.

The response cache. A shaped answer is cached for a TTL that depends on what was asked for:

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

meta.cached tells you which answer you got. Entries expire on their own, and Redis is capped below its container limit so the cache cannot grow without bound.

?refresh=true turns off both. It ignores any cached or in-flight answer and asks upstream again. The fresh answer is still written to the cache — “do not read the cache” and “do not keep this” are different requests, and only the first one was asked for. It costs an identity and a real upstream request, so it is for checking whether something changed, not for every call.

Both mechanisms are why two spellings of the same request coalesce: ?url=https://www.douyin.com/video/7123… and ?aweme_id=7123… extract to the same parameters and get the same task id.

Adds the platform’s own untouched payload beside the normalized one. Off by default, and worth understanding before you turn it on:

  • On a single post it is large — a single post’s raw payload runs to hundreds of kilobytes.
  • On a page it is per item, so it multiplies the response and everything that stores it.
  • It is part of the cache key, so a raw and a non-raw request for the same thing are two cache entries and two upstream calls.

On POST /api/v1/parse and each item of POST /api/v1/tasks/batch it is a body field; on the GET endpoints it is a query parameter.

Both change how a request is made rather than what it asks for, and both are gated.

Sends the request as one named identity and no other. The case it exists for is a cookie jar you imported from your own logged-in browser: the content is visible to that session and to no other, so substituting a different identity would not degrade the answer, it would change the question.

  • Requires the identity:manage scope and at least the operator role.
  • The id is checked at submission for existence, retirement and platform, so a mistyped uuid is an immediate 400 or 404 naming the field rather than a task that queues, runs and fails. A retired identity is refused explicitly — retirement wipes the ciphertext, so there is no jar left to sign with.
  • A pinned request neither reads nor writes the response cache and is never joined to an unpinned task.
  • It gets one transport attempt instead of three. Retrying is only worth anything because the next attempt lands on a different identity behind a different exit; pinned, three attempts would just drain one identity’s token bucket.
  • The identity id is echoed back in the result metadata as identity_id — only when you named it.

See Identities and proxies.

Sends the upstream request through an egress you supply, replacing the identity’s own exit.

  • Refused unless an administrator sets security.request_proxy to public or any. The default is deny, and an unrecognised value is treated as deny — a typo in configuration must not be the thing that opens a network.
  • public accepts only publicly routable destinations. A proxy given by name is resolved, every address it answers with must be public, and the request is dialled at the address that was checked rather than at the name — so http://proxy.example.com:8080 is sent on as http://<its address>:8080. An https proxy keeps its name, because its certificate is verified against it. any accepts loopback and private ranges too and is only coherent when every API key holder is already trusted with the network the instance runs in.
  • Schemes: http, https, socks5, socks5h. Maximum 512 characters. Give it as a full URL, e.g. http://host:port.
  • A refusal is INVALID_PARAM with details.reason — one of request_proxy_disabled, too_long, scheme_missing, scheme_not_supported, authority_invalid, host_missing, port_invalid, host_not_public, host_unresolvable. The value you sent is never echoed back or logged, because a rejected proxy URL is exactly when someone has pasted a real credential.
  • A disabled feature refuses rather than ignores. Silently dropping the parameter would send the request from the instance’s own address while you believed it went through your proxy.

The cost is real and is not a free option: the identity’s cookies were minted behind one address and would now be presented from another, which is an incoherence the platforms can see. Two callers asking for the same post through different proxies are not asking the same question and are never coalesced.

Returns the request as it actually went out — the signed URL, the headers and the identity’s cookie jar — so it can be replayed outside this instance. It is the debugging tool for “the platform refused us and I need to see what we sent”.

  • Requires identity:manage and the operator role, because the answer contains a credential. A douyin:read key may ask this instance to use a jar; it may not ask to be handed one.
  • Every use is written to the audit log — who asked, and for which endpoint. The line names no jar.
  • It implies refresh: an explanation of a cached answer would describe a call this request did not make. It also disables coalescing, so an explained call is never joined to an unexplained one already in flight.
  • The block appears in the result metadata under explain, with method, url, headers, cookie_header, identity_id, signer, endpoint, proxy and body.
  • It is recorded on failed tasks too — that is the case it exists for.
  • It is stripped from a stored task result for any reader without identity:manage and the operator role, and it is never sent on the SSE stream.

See Playground and tools, which is the console’s front end for the same thing.

{platform} is douyin, tiktok or bilibili and must match the link you pass; a mismatch is INVALID_URL with details.reason: "platform_mismatch".

Content — asynchronous, spends an identity

Section titled “Content — asynchronous, spends an identity”
Method Path Scope Notes
POST /api/v1/parse any platform read scope Any supported link, or the whole share text around one
POST /api/v1/tasks/batch any platform read scope Up to 50 links, one task each
GET /api/v1/{platform}/video {platform}:read One post. url or aweme_id
GET /api/v1/{platform}/video/comments {platform}:read Paged. sort: hot (default) or new; new on Bilibili only, INVALID_PARAM elsewhere
GET /api/v1/{platform}/video/comments/replies {platform}:read comment_id plus url or aweme_id. Paged
GET /api/v1/{platform}/user {platform}:read Profile. url or sec_user_id
GET /api/v1/{platform}/user/posts {platform}:read Paged
GET /api/v1/{platform}/user/likes {platform}:read Douyin and TikTok. Paged. See the platform note below
GET /api/v1/{platform}/mix/posts {platform}:read mix_id — from /user/mixes, or a post’s own: Douyin mix_info, TikTok playlistId. An author’s own series, not a bookmark folder. Paged; a Bilibili season comes whole, in one page
GET /api/v1/{platform}/user/collections {platform}:read Bookmark folders. Takes an author on TikTok and Bilibili (on Bilibili: public folders, all in one page); takes none on Douyin, and there needs identity. Paged
GET /api/v1/{platform}/collection tiktok:read TikTok only. One folder by collection_id: name, cover, count, owner, visibility
GET /api/v1/{platform}/collection/posts {platform}:read Posts in one folder, by collection_id. Paged
GET /api/v1/{platform}/user/bookmarks {platform}:read Douyin and TikTok. Every saved post, across all folders. The account’s own list only, so identity is required. Takes an author on TikTok; takes none on Douyin. Paged
GET /api/v1/{platform}/user/reposts tiktok:read TikTok only. Posts an author reposted. Paged
GET /api/v1/{platform}/user/followers tiktok:read TikTok only
GET /api/v1/{platform}/user/following tiktok:read TikTok only
GET /api/v1/{platform}/video/parts bilibili:read Bilibili only. The parts of a video uploaded in several. url or aweme_id
GET /api/v1/{platform}/popular bilibili:read Bilibili only. A site-curated list: board is hot, weekly, precious or rank. Paged where the list is
GET /api/v1/{platform}/video/related {platform}:read What the platform recommends beside a post. url or aweme_id. One batch, never paged
GET /api/v1/{platform}/user/mixes {platform}:read An author’s own series — Douyin mixes, TikTok playlists, Bilibili seasons — each with the mix_id /mix/posts takes. url or sec_user_id. Paged
GET /api/v1/{platform}/music {platform}:read Douyin and TikTok. One sound: url or music_id
GET /api/v1/{platform}/music/posts {platform}:read Douyin and TikTok. The posts made with a sound. Paged
GET /api/v1/{platform}/hashtag {platform}:read Douyin and TikTok. One hashtag: url, hashtag_id or, on TikTok, name
GET /api/v1/{platform}/hashtag/posts tiktok:read TikTok only. The posts under a hashtag, by hashtag_id. Paged
GET /api/v1/{platform}/search/posts {platform}:read TikTok and Bilibili. The posts a keyword finds. Paged — see below for what a guest gets
GET /api/v1/{platform}/search/users {platform}:read TikTok and Bilibili. The accounts a keyword finds. Paged, as above
GET /api/v1/{platform}/live {platform}:read One live room: url, room_id or, on Douyin and TikTok, live_id. Whether it is on, and its pull URLs while it is
GET /api/v1/{platform}/live/ranking douyin:read Douyin only. A broadcast’s audience ranking, by live_id: up to 200 viewers, top first
GET /api/v1/{platform}/live/status {platform}:read Douyin and TikTok. Whether each of up to 100 broadcasts is on air, by live_ids, in one call
GET /api/v1/{platform}/trending {platform}:read Douyin and Bilibili. A trending board, whole: board is hot, entertainment, society or challenge on Douyin, hot on Bilibili

/video also takes part on Bilibili — see A video’s parts.

The platforms are not symmetric past the core five, and the asymmetry is reported rather than papered over. Asking Douyin for followers or following returns UNSUPPORTED_CONTENT with details.supported naming the platforms that do serve it — a route that submitted the task anyway would answer with an empty page and let you conclude the author has no followers. Bilibili serves the core five, folders, seasons, related videos, search, live rooms, the hot search, a video’s parts and the popular boards — the last two on Bilibili alone — and answers the rest the same way - see Bilibili. user/likes is offered on Douyin and TikTok, but Douyin does not serve that list to a guest identity: it needs an imported logged-in identity, and on TikTok an empty page usually means the author keeps their likes private.

Bookmark folders are the sharpest case of that asymmetry. A folder is public or private individually on Douyin and TikTok, and a public one is readable with an ordinary pooled identity — no login anywhere. But the folder list differs: TikTok’s — and Bilibili’s — takes an author and will answer about a stranger’s public folders, while Douyin’s request carries no user id at all and answers only about the identity sending it. So on Douyin, user/collections needs identity pinned to an imported identity, and passing url or sec_user_id there is refused rather than ignored — ignoring it would hand back your own folders under somebody else’s name. Every folder reports is_public and its owner, so you never have to guess which case you are in.

These are measured per platform like everything else, and the differences are declared rather than discovered. GET /api/v1/system/capabilities lists them, including what a guest identity loses on each.

  • Search. Bilibili pages both its video and its account search for a guest identity, up to the 50 pages it ever offers. TikTok gives a guest a thin first page of posts — one result for some queries — and an empty second page of posts and accounts alike, so a signed-in identity is used for the first page when the pool has one and is needed for every later page. Douyin refuses search to a guest by name (status_code 2483, “sign in before searching”); no signed-in answer has been measured, so it is not offered and answers UNSUPPORTED_CONTENT. Search draws from the slowest bucket the scheduler has.
  • Sounds and hashtags. A sound’s id is the music.music_id of any post that uses it; a hashtag’s is in a Douyin hashtag link, and on TikTok /hashtag looks one up by name (a TikTok tag link carries only the name). /hashtag/posts takes the numeric id, so on TikTok look the name up first. Douyin answers the posts under a hashtag to a guest with an empty body — so did its own hashtag page in a browser that was not signed in — and that list is not offered on Douyin yet. A sound or a hashtag nobody has is NOT_FOUND.
  • Related posts is a recommendation, so the same call can answer differently a minute later, and all three platforms answer an id that names no post with a full batch of unrelated ones — /video is the call that says whether a post exists.
  • Live rooms are addressed the way their links address them: the number in live.douyin.com/<n>, the @handle in TikTok’s /@<handle>/live, the number in live.bilibili.com/<n>. While a room is live the answer carries viewer_count, started_at where the platform states it, and streams, one per protocol (flv or hls) and quality in the platform’s own quality names. Pull URLs are signed and expire within hours; this reads a room and records nothing, and the URLs go to your own player or recorder straight from the platform’s CDN. On Bilibili the pull URLs are a second request, made only while the room is on, and its viewer_count is the “watched” figure it prints rather than who is in the room now.
    • A broadcast’s own id. On Douyin and TikTok a room that is on also answers with live_id, the platform’s id for this broadcast: a new one each time the room goes on air. /live?live_id= asks for a room by it, and so does a Douyin app share link — v.douyin.com/... expands to a webcast.amemv.com/douyin/webcast/reflow/<live_id> link, which /live, /parse, dtk fetch and the MCP parse_url all answer. However the room was asked for, room_id in the answer is what its web link carries, so it can be asked for again after the broadcast ends. Bilibili numbers a room once and answers live_id with UNSUPPORTED_CONTENT.
    • Many rooms at once (Douyin and TikTok): /live/status?live_ids=<a>,<b>,... answers {live_id, is_live} for up to 100 broadcasts in the order asked, for one request. Poll it, and ask /live for the one that has just come on. Every id asked gets an answer in its own place: a broadcast that has ended and an id that was never one are both is_live: false.
    • The audience ranking (Douyin): /live/ranking?live_id= answers the list the room’s page shows beside the stream, up to 200 viewers as {rank, author}, top first. A guest is told the order and not the scores behind it, so no score is returned. A viewer who chose not to be listed is still sent by the platform with their account; they are a place with a null author here, and so is a viewer the platform names to a guest only by a masked nickname.
  • Trending boards are lists of phrases, not posts: each line carries its rank, the heat the platform prints, and web_url, the platform’s own page for it. Douyin’s main board opens with a line pinned above the ranking, which has a rank and heat of null.

POST /api/v1/parse answers music, hashtag and live links with the endpoints above.

Identify a post by either url or aweme_id, never both-or-neither; an author by either url or sec_user_id (TikTok calls it secUid; on Bilibili it is the numeric mid). A URL that already carries the id has it extracted at the edge, so both spellings coalesce onto one task. Text with a link buried in it is accepted — the clipboard content the platform apps produce can be sent unedited. Short links (v.douyin.com, vm.tiktok.com, b23.tv, bili2233.cn) are queued for expansion in the worker, because following one is a network call the API container does not make.

Ids the caller typed are validated before they cost anything: aweme_id=not-an-id and aweme_id=7123 are both INVALID_PARAM with details.field, answered for free rather than sent upstream to be refused.

Prefix What it is Covered by
/api/v1/tasks/… Poll, stream and cancel tasks This page
/api/v1/tools/… parse-url, parse-batch, sign, decode, identity — dry runs and signing, no identity spent by the first four Playground and tools
/api/v1/archive/… What this instance has already stored: search, stats, collections, export, delete, recheck, backfill Downloads, library and watchlist
/api/v1/downloads/… Media stored on the operator’s disk Downloads, library and watchlist
/api/v1/transcriptions/… Offline transcripts of downloaded videos: start, list, read, cancel, delete Downloads, library and watchlist
/api/v1/admin/… Identities, proxies, keys, users, settings, logs, watchlist, backup Identities and proxies, Operations, Users and API keys; the identity calls a script reaches for are below
/api/v1/auth/… Console sessions and password changes Users and API keys
/api/v1/ios/… iOS Shortcut release metadata — unauthenticated This page
/api/v1/system/status Version, health, pool census, storage, and whether the browser container, the downloader and the transcriber answer Operations
/api/v1/system/capabilities Which operations each platform serves, which of them are session-bound, and what a guest identity loses on each. Any credential — every role, the demo account included, and any API key This page

Archive endpoints answer from local storage: no identity is spent, nothing is fetched, and a post that has since been deleted is still there with availability saying so. That makes GET /api/v1/archive the right call when you want what the instance already knows, and /parse the right call when you need it fresh.

GET /api/v1/system/capabilities answers, per platform, the operations it serves — each with its scheduler endpoint name and session_bound, which marks the ones that answer only about the identity sending them and so are refused without identity. It reads the same table the content routes refuse from with UNSUPPORTED_CONTENT, so the two cannot disagree, and it never changes while the process runs. Ask it once rather than learning the platform differences one refusal at a time; the Playground greys its catalogue from it.

Each operation also carries login and paging_login: what a guest identity loses on it — none, preferred or required — on the first page and on every page after it, paging_login being null where later pages are no different. They are what decides which identities rotation sends. required goes to logged-in identities only and answers IDENTITY_LOGIN_REQUIRED when rotation has none; preferred tries a login first and falls back to a guest; none goes to guests first while pool.<platform>.guests_first is on, and to logins and guests alike while it is off. Today douyin.author_posts and bilibili.comments are preferred with paging_login: required, bilibili.comment_replies is none with paging_login: required, and every other operation is none throughout. The console reads them to mark, in an identity’s rotation checklist, the endpoints that need a login.

The pool’s own routes live under /api/v1/admin/identities and are described beside the console pages that use them, in Identities and proxies. These are the ones a script that keeps a pool reaches for, and the fields that tell one identity from another. Every one of them needs the admin or identity:manage scope as well as the role shown.

Call Role What it takes and gives
GET /api/v1/admin/identities Viewer and up The list. Each row carries note, and account_id with account_seen_at — the account the last session check named, and when; null until a check has named one. serves=<endpoint>, such as douyin.author_posts, keeps the identities rotation may hand out for it: those with no rotation list and those whose list names it. Reserved ones never match, and guests do not where the endpoint needs a login from its first page
PATCH /api/v1/admin/identities/{id} Operator {"note": "..."} sets the note, {"note": null} or a blank one clears it. One line, at most 200 characters once trimmed, or INVALID_PARAM. The field is required, so {} is refused rather than read as clearing. Answers with the identity as the list shows it; audited as identity.note_changed, without the text
GET /api/v1/admin/identities/coverage Viewer and up; not the demo account Per platform: guests_first, reserved (identities serving only requests that name them) and endpoints, one entry per operation with the capability fields above plus ready (active or degraded identities rotation may send there), signed_in (the logins among them), cooling and dedicated (ready ones limited to a list naming it). Counted the way the scheduler counts; a request with identity= is limited by none of it
POST /api/v1/admin/identities/import Operator Takes an optional note, under the same rules as the PATCH
POST /api/v1/admin/identities/export, POST /api/v1/admin/identities/import/bundle Operator Each exported entry carries its note, and a restore stores it with the identity. A note the restoring build would refuse is dropped rather than costing the identity. Rotation does not travel
GET /api/v1/admin/logs/requests Every role, the demo account included Each row carries identity_note, the note on the identity that served it — null when it has none, and always null for the demo account, because a note is where somebody writes whose account a login is

Bilibili goes through the same routes, parameters and envelope as the other two platforms: {platform} is bilibili and the scope is bilibili:read. What differs is the shape of its ids, the set of operations it has, and three endpoints that behave in ways a client should know before relying on them.

The parameter names are the API’s, not the platform’s, so they keep the spelling the other platforms gave them:

What On Bilibili Parameter
A video A bvid such as BV1xx411c7mD. An av number (av170001 or bare 170001) is accepted and converted to the bvid by arithmetic, with no request aweme_id
An author The numeric mid sec_user_id
A comment The comment’s id comment_id
A folder The folder’s id collection_id
A season The season_id /user/mixes returns as mix_id mix_id
A live room The number in a live.bilibili.com link room_id

Results always carry the bvid as content_id, whichever form you sent. An author’s mid comes back in both uid and sec_uid, as on Douyin, so the sec_uid of any result is always the id the author endpoints accept. Bilibili has no handles: unique_id is null.

Links that url and /parse recognise:

Kind Forms
Video www.bilibili.com/video/BV…, www.bilibili.com/video/av…, m.bilibili.com/video/…, the embed player player.bilibili.com/player.html?bvid=…
Short link b23.tv/<code>, bili2233.cn/<code> — followed by the worker, like every short link
Author space.bilibili.com/<mid> (also with /video or /upload), m.bilibili.com/space/<mid>
Folder space.bilibili.com/<mid>/favlist?fid=<id>, www.bilibili.com/list/ml<id>
Live room live.bilibili.com/<room>, live.bilibili.com/h5/<room>

Share and tracking parameters such as vd_source, spm_id_from, buvid and share_source are dropped; p (the part) and t (the start time) are kept. bilibili.tv, the international site, is a different service and is not supported.

Endpoint On Bilibili
video One video, completed by a second request — see below
video/comments Served, hottest first or, with sort=new, newest first; a guest identity gets far less — see below
video/comments/replies Served, paged by page number; a guest gets the first page only — see below
user The profile
user/posts The uploader’s videos; the endpoint Bilibili guards hardest — see below
user/collections An author’s public folders
collection/posts One folder’s videos, paged by page number
video/parts A video’s parts — see below
popular The four site-curated lists — see below
video/related The 40 videos the player lists beside one, in one answer
user/mixes The uploader’s seasons. Its space lists older “series” beside them, which are a different id /mix/posts cannot read, so they are left out — the paging still counts them, so a last page can be empty
mix/posts One season’s videos, all of them in one answer whatever page is asked
search/posts, search/users The search page’s video and user tabs, paged by number for a guest too; the highlighting around matched words is taken out
live A live room; while it is on, a second request fetches its pull URLs
trending The hot search under the search box: board=hot only
user/likes, user/reposts, user/bookmarks, user/followers, user/following, collection, music, music/posts, hashtag, hashtag/posts UNSUPPORTED_CONTENT, naming the platform and the capability it lacks

POST /api/v1/parse recognises Bilibili links like any other, live.bilibili.com/<room> included. A folder link is answered with the folder’s first page of videos, since Bilibili has no separate folder detail.

Bilibili’s detail answer carries no playable file. So the instance makes a second request, playurl, for the first part of the video, and folds the stream into the result. That request is scheduled, signed and written to the request log like any other, under its own endpoint name bilibili.content_streams — expect two rows per video on the Logs page. If it fails, the detail is still returned, just without media.video: losing the stream should not cost you the title, author and counts that did arrive.

The stream is one progressive MP4, not DASH, so a download is one file. Measured, a guest got up to 1080p on older uploads and 720p on a current one. A video in several parts is one post, and the stream is the first part’s unless part names another — see A video’s parts.

A Bilibili upload can come in several parts (分P) — a series cut into episodes, a lecture into chapters. It is still one post with one bvid, one title and one set of counts, and /video lists its parts as parts: each one’s 1-based part number, cid, title, duration_ms, width and height. A single-part video lists its one part; on Douyin and TikTok parts is empty. GET /api/v1/bilibili/video/parts answers the same list without the rest of the detail, from a lighter unsigned request.

part=N on /video makes the stream that part’s instead of the first’s, and its width and height that part’s too. A link’s ?p=N means the same and is read the same way, so the URL a player is on asks for what it plays; a link and a part that disagree are refused. A part past the last is INVALID_PARAM, naming how many parts there are. A read of a later part is not written to the archive: the archive keeps the post with its first part’s file, which is what its link plays and what a plain download stores.

part on POST /api/v1/downloads stores one part — see One part of a multi-part video.

GET /api/v1/bilibili/popular reads the lists the site itself curates. board picks which, and each pages the way the site does:

board What it is Pages
hot (default) What is popular now By page number; count up to 20. Past the end the site keeps saying there is more and returns an empty page, so an empty page is reported as the end
weekly The weekly selection (每周必看), 50 videos an issue, published on Fridays By issue, newest first. The first page is the newest issue, read off the index of issues; the cursor is the next older issue’s number, and passing an issue number starts there. Issue 1 is the last page. An issue that does not exist is NOT_FOUND
precious The editors’ all-time selection (入站必刷) One page, about a hundred videos
rank The site-wide top 100 (排行榜) One page

Each item is a post in the same shape as user/posts — title, author, counts, cover, duration — and, as with every list, is written to the archive. All four answered a guest identity when measured (October 2026); the ranking does so only with the canvas report the rank page sends, which this instance sends. The lists are the same for everyone, so a repeat inside cache.list_ttl is free. Douyin and TikTok answer UNSUPPORTED_CONTENT.

Measured against the live site, Bilibili gives a guest identity the three hottest comments — the three newest with sort=new — and tells it that is the last page. Passed on as it stands, that would report a video with thousands of comments as having three. So the endpoint prefers a signed-in (imported) Bilibili identity for the first page and requires one for every later page — IDENTITY_LOGIN_REQUIRED when rotation has none. A first page a guest served carries meta.guest_fallback, so three comments from a guest cannot be mistaken for a video that only has three. count is accepted and ignored here: Bilibili’s comment endpoint takes no page size. A cursor belongs to the order it was read in, so send the same sort with it. Replies are the same one level down: a guest gets the first page — up to 20 — and an empty page after it while page one counts more, so later pages of replies require a signed-in identity too. Every comment endpoint Bilibili has, v4’s page-numbered /x/v2/reply included, answers a guest this way (measured 2026-10-18). Importing a signed-in identity is covered in Bilibili identities.

user/posts is signed (WBI) and sent with the canvas/WebGL report (dm_img_*) the space page itself sends. Even so, it is the endpoint Bilibili guards hardest: a burst of requests from one egress gets HTTP 412 pages for a few minutes regardless of headers (measured). This instance reports those as UPSTREAM_RISK_CONTROL and cools the identity. Spreading requests out and using proxies helps; the endpoint already has the slowest token bucket of any Bilibili endpoint. Measured again later the same day, once the egress had been busy: about half the requests got 412 even at one every few seconds — on a fresh guest identity or an old one, with or without a bili_ticket cookie — and the unsigned recArchivesByKeywords list fared no better. The limit is on the egress, and the token bucket is kept per identity, so more guest identities on the same egress add no headroom. Proxies do. See Troubleshooting.

user/collections takes an author — a mid or a space link — and returns every public folder in one answer. That is one page: cursor and count are accepted and ignored. An author with no public folders returns an empty page. A folder that does not exist is NOT_FOUND: Bilibili answers it with an empty data, which is not how it answers an empty folder. collection/posts pages by page number, so its cursor reads "2", "3", … — send it back as given all the same.

The page size ceiling on Bilibili is 20; a count above it is a 400.

Six endpoints deliberately answer with something else, in four shapes. Do not send them through your envelope parser.

Endpoint Content type Why
GET /healthz, GET /readyz plain JSON object Probes for a load balancer; /healthz touches no dependency, /readyz answers 503 when Postgres or Redis is down
GET /api/v1/tasks/{task_id}/events text/event-stream Server-sent events
GET /api/v1/archive/export application/x-ndjson One post per line, streamed a page at a time; capped at 50,000 rows; needs archive:export
POST /api/v1/archive/export application/x-ndjson The same, for the posts listed in items rather than for a filter
GET /api/v1/downloads/{download_id}/files/{name} the file’s own type The stored bytes, with a Content-Disposition that makes a browser save them

Submit a link, poll the task, read the result. All three clients do the same thing.

The polling version, using python3 to read one field out of the JSON:

#!/usr/bin/env bash
set -euo pipefail
BASE="${DTK_BASE_URL:-http://127.0.0.1:8000}"
KEY="$DTK_API_KEY"
LINK="https://www.douyin.com/video/7123456789012345678"
field() { python3 -c 'import json, sys
value = json.load(sys.stdin)
for key in sys.argv[1:]:
value = value[key]
print(value)' "$@"; }
task=$(curl -sS -X POST "$BASE/api/v1/parse" \
-H "X-API-Key: $KEY" \
-H 'Content-Type: application/json' \
-d "{\"url\": \"$LINK\"}" | field data task_id)
echo "task $task"
for _ in $(seq 1 60); do
body=$(curl -sS "$BASE/api/v1/tasks/$task" -H "X-API-Key: $KEY")
state=$(printf '%s' "$body" | field data state)
case "$state" in
done) printf '%s' "$body" | python3 -m json.tool; exit 0 ;;
failed) printf '%s' "$body" | python3 -m json.tool; exit 1 ;;
esac
sleep 2
done
echo "still running after two minutes; task $task is still valid" >&2
exit 1

The synchronous one-liner, for when you just want the answer in a terminal:

Terminal window
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse?wait=20" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://www.douyin.com/video/7123456789012345678"}' \
| python3 -m json.tool

Remember that ?wait=20 can still come back 202 — that is the task still running, not a failure.

"""Submit a link, poll the task, print the result. Needs: pip install httpx"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("DTK_BASE_URL", "http://127.0.0.1:8000")
HEADERS = {"X-API-Key": os.environ["DTK_API_KEY"]}
RETRYABLE = {
"RATE_LIMITED",
"QUEUE_FULL",
"IDENTITY_POOL_EXHAUSTED",
"ENDPOINT_CIRCUIT_OPEN",
"UPSTREAM_RISK_CONTROL",
"SIGNING_FAILED",
"UPSTREAM_UNREACHABLE",
"DOWNLOADER_UNAVAILABLE",
"TRANSCRIBER_UNAVAILABLE",
"INTERNAL",
}
class DtkError(RuntimeError):
def __init__(self, error: dict, request_id: str | None = None) -> None:
super().__init__(f"{error['code']}: {error.get('message', '')}")
self.code = error["code"]
self.retry_after = error.get("retry_after")
self.details = error.get("details")
self.request_id = request_id
def unwrap(response: httpx.Response) -> dict:
"""Return `data`, or raise the envelope's error."""
body = response.json()
if not body.get("success"):
raise DtkError(body["error"], response.headers.get("X-Request-ID"))
return body["data"]
def submit(client: httpx.Client, url: str) -> str:
"""Queue a parse and return the task id, retrying while the instance pushes back."""
for attempt in range(5):
try:
return unwrap(
client.post("/api/v1/parse", json={"url": url})
)["task_id"]
except DtkError as exc:
if exc.code not in RETRYABLE or attempt == 4:
raise
time.sleep(exc.retry_after or 2 ** attempt)
raise RuntimeError("unreachable")
def collect(client: httpx.Client, task_id: str, deadline: float = 120.0) -> dict:
"""Poll until the task settles. A failed task is HTTP 200 with state 'failed'."""
delay, end = 0.5, time.monotonic() + deadline
while time.monotonic() < end:
task = unwrap(client.get(f"/api/v1/tasks/{task_id}"))
if task["state"] == "done":
# The result is nested: envelope.data.data
return task["data"]
if task["state"] == "failed":
raise DtkError(task["error"])
time.sleep(delay)
delay = min(delay * 2, 5.0)
raise TimeoutError(f"task {task_id} still running; it remains valid, poll it again")
def main() -> None:
with httpx.Client(base_url=BASE, headers=HEADERS, timeout=30.0) as client:
task_id = submit(client, "https://www.douyin.com/video/7123456789012345678")
print("task", task_id)
result = collect(client, task_id)
print(result.get("title") or result.get("description"))
if __name__ == "__main__":
main()

Two things in that code are the point of it: unwrap branches on success and on error.code, never on the message; and collect treats a failed task as data rather than as an HTTP failure, because that is what it is.

// Node 18+ or any modern browser (see "Calling from a browser" for CORS).
const BASE = process.env.DTK_BASE_URL ?? "http://127.0.0.1:8000";
const HEADERS = { "X-API-Key": process.env.DTK_API_KEY, "Content-Type": "application/json" };
const RETRYABLE = new Set([
"RATE_LIMITED", "QUEUE_FULL", "IDENTITY_POOL_EXHAUSTED", "ENDPOINT_CIRCUIT_OPEN",
"UPSTREAM_RISK_CONTROL", "SIGNING_FAILED", "UPSTREAM_UNREACHABLE", "DOWNLOADER_UNAVAILABLE",
"TRANSCRIBER_UNAVAILABLE", "INTERNAL",
]);
class DtkError extends Error {
constructor(error, requestId) {
super(`${error.code}: ${error.message ?? ""}`);
this.code = error.code;
this.retryAfter = error.retry_after ?? null;
this.details = error.details ?? null;
this.requestId = requestId ?? null;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function unwrap(response) {
const body = await response.json();
if (!body.success) throw new DtkError(body.error, response.headers.get("X-Request-ID"));
return body.data;
}
async function submit(url) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const data = await unwrap(await fetch(`${BASE}/api/v1/parse`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ url }),
}));
return data.task_id;
} catch (err) {
if (!(err instanceof DtkError) || !RETRYABLE.has(err.code) || attempt === 4) throw err;
await sleep(1000 * (err.retryAfter ?? 2 ** attempt));
}
}
}
async function collect(taskId, deadlineMs = 120_000) {
let delay = 500;
const end = Date.now() + deadlineMs;
while (Date.now() < end) {
const task = await unwrap(await fetch(`${BASE}/api/v1/tasks/${taskId}`, { headers: HEADERS }));
if (task.state === "done") return task.data; // envelope.data.data
if (task.state === "failed") throw new DtkError(task.error);
await sleep(delay);
delay = Math.min(delay * 2, 5000);
}
throw new Error(`task ${taskId} still running; it remains valid, poll it again`);
}
const taskId = await submit("https://www.douyin.com/video/7123456789012345678");
console.log("task", taskId);
console.log(await collect(taskId));

Cross-origin requests are refused by default: security.cors_allow_origins is empty, which means same-origin only, and a preflight simply finds no OPTIONS route to answer it. An operator who wants browser access lists the exact origins in that setting.

  • Methods answered cross-origin: GET, POST, PUT, DELETE, OPTIONS.
  • Headers a browser caller can read: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After. Without these exposed, the correlation id and the rate-limit budget are invisible to fetch(), which is most of what they exist for.
  • Setting the origin list to * forces credentials off. An operator who typed * asked for an open anonymous API, not an open authenticated one — and a browser would reject the pair anyway.

Do not ship an API key in a page you do not control: anyone who reads the page holds the key and can spend your identity pool. Put a small server of your own in front, or open exactly the endpoints you need with api.public_endpoints and accept the anonymous rate limit.

Everything here is read from the code; the settings marked (setting) are editable at runtime on the console’s Settings page — see Configuration reference.

Thing Value
?wait= ceiling (setting api.max_wait_seconds) 30 seconds
Default rate limit (setting api.default_rate_limit_per_min) 120 requests/minute
Queue ceiling (setting sched.queue_max) 500 tasks
QUEUE_FULL retry hint (setting sched.max_wait_seconds) 10 seconds
Task result retention (setting retention.task_result_hours) 24 hours
Cache TTL, one post (setting cache.content_ttl) 1800 seconds
Cache TTL, author profile (setting cache.author_ttl) 900 seconds
Cache TTL, any paged list (setting cache.list_ttl) 300 seconds
Coalescing claim lifetime 90 seconds
Platform page size (count) default 20, maximum 50 or the platform’s own lower ceiling (35 on TikTok, 20 on Bilibili)
Archive page size (limit) default 50, maximum 200
Downloads / admin listing (limit) default 100, maximum 500
Batch items per request 50
Request body ceiling 1 MiB (1,048,576 bytes)
url parameter length 4096 characters
cursor length 512 characters
aweme_id / mix_id / comment_id length 64 characters
sec_user_id length 256 characters
callback_url length 2048 characters
proxy length 512 characters
SSE stream timeout 1–300 seconds, default 300
SSE heartbeat ~15 seconds
Webhook attempts / timeout / backoff 3 / 10s / 2s then 8s
Archive export cap 50,000 rows

One documentation wrinkle to know about: the API document’s own front page (visible at /swagger) says a callback_url host “must be on the operator’s security.url_allowlist”. That list governs which hosts a short link may redirect through during expansion; it has nothing to do with callbacks. The actual gate on a callback is security.enable_task_webhook plus the https and non-private-address checks described above.