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.
What this guide covers
Section titled “What this guide covers”| 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.
Base URL and versioning
Section titled “Base URL and versioning”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:
export DTK_BASE_URL=http://127.0.0.1:8000export DTK_API_KEY=dtk_0a1b2c3d4e5f_REPLACE_WITH_YOUR_KEYPath 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.
Authentication
Section titled “Authentication”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:
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.
Scopes
Section titled “Scopes”| 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:readkey still cannot reach identity management orarchive: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.
Opened endpoints
Section titled “Opened endpoints”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”.
The response envelope
Section titled “The response envelope”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.
A worked success
Section titled “A worked success”Submitting a link returns 202 with a task id:
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" }}A worked failure
Section titled “A worked failure”Send a URL the allowlist does not admit and the same envelope carries the error, with HTTP 400:
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.
Correlation
Section titled “Correlation”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.
Content type
Section titled “Content type”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.
The asynchronous model
Section titled “The asynchronous model”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.
1. Poll the task
Section titled “1. Poll the task”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.
2. Let the server wait — ?wait=
Section titled “2. Let the server wait — ?wait=”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.
3. Get called back — callback_url
Section titled “3. Get called back — callback_url”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 isINVALID_PARAMoncallback_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.
The event stream
Section titled “The event stream”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.
Batch submission
Section titled “Batch submission”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.
When the queue is full
Section titled “When the queue is full”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.
Pagination
Section titled “Pagination”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_cursortimestamp, 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
countor 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.
Response language
Section titled “Response language”Two error codes are the same in every language; the sentence beside them is not. Resolution order for one request:
?lang=query parameter —enorzhAccept-Languageheader — matched on the primary subtag, sozh-CN,zh-Hansandzh-TWall resolve tozh;*resolves to the configured defaultapi.default_language(defaulten)
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.
Errors
Section titled “Errors”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.
retry_after
Section titled “retry_after”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.
retryable
Section titled “retryable”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.
The codes a client must handle
Section titled “The codes a client must handle”Anything else can reasonably be treated as “log it and stop”, but these four decisions have to be in your code:
- 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 ofUPSTREAM_UNREACHABLEwill not clear by waiting if this server cannot reach the platform at all - see Troubleshooting. Honourretry_afterwhere present; use exponential backoff where it is not. - 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. - Fix the credential:
UNAUTHENTICATED,FORBIDDEN_SCOPE.detailscarries both sides:required_scopesorrequired_rolesfor what the endpoint wants, andhave_role,have_scopesandviafor what you sent.viais 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. - Resubmit the work:
TASK_NOT_FOUND. The result window has passed; the task id is dead.
Rate limits
Section titled “Rate limits”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.
?include_raw=
Section titled “?include_raw=”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.
?identity= and ?proxy=
Section titled “?identity= and ?proxy=”Both change how a request is made rather than what it asks for, and both are gated.
?identity=<uuid>
Section titled “?identity=<uuid>”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:managescope and at least theoperatorrole. - The id is checked at submission for existence, retirement and platform, so a mistyped uuid is an immediate
400or404naming 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.
?proxy=<url>
Section titled “?proxy=<url>”Sends the upstream request through an egress you supply, replacing the identity’s own exit.
- Refused unless an administrator sets
security.request_proxytopublicorany. The default isdeny, and an unrecognised value is treated asdeny— a typo in configuration must not be the thing that opens a network. publicaccepts 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 — sohttp://proxy.example.com:8080is sent on ashttp://<its address>:8080. Anhttpsproxy keeps its name, because its certificate is verified against it.anyaccepts 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_PARAMwithdetails.reason— one ofrequest_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.
?explain=
Section titled “?explain=”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:manageand theoperatorrole, because the answer contains a credential. Adouyin:readkey 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, withmethod,url,headers,cookie_header,identity_id,signer,endpoint,proxyandbody. - 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:manageand 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.
The endpoint families
Section titled “The endpoint families”{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.
Search, sounds, hashtags and live rooms
Section titled “Search, sounds, hashtags and live rooms”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 answersUNSUPPORTED_CONTENT. Search draws from the slowest bucket the scheduler has. - Sounds and hashtags. A sound’s id is the
music.music_idof any post that uses it; a hashtag’s is in a Douyin hashtag link, and on TikTok/hashtaglooks one up byname(a TikTok tag link carries only the name)./hashtag/poststakes 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 isNOT_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 —
/videois 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 inlive.bilibili.com/<n>. While a room is live the answer carriesviewer_count,started_atwhere the platform states it, andstreams, one per protocol (flvorhls) 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 itsviewer_countis 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 awebcast.amemv.com/douyin/webcast/reflow/<live_id>link, which/live,/parse,dtk fetchand the MCPparse_urlall answer. However the room was asked for,room_idin 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 answerslive_idwithUNSUPPORTED_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/livefor 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 bothis_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 nullauthorhere, and so is a viewer the platform names to a guest only by a masked nickname.
- A broadcast’s own id. On Douyin and TikTok a room that is on also answers with
- Trending boards are lists of phrases, not posts: each line carries its
rank, the heat the platform prints, andweb_url, the platform’s own page for it. Douyin’s main board opens with a line pinned above the ranking, which has arankand heat ofnull.
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.
Everything else
Section titled “Everything else”| 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
Section titled “Bilibili”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.
Ids and parameters
Section titled “Ids and parameters”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.
What it serves
Section titled “What it serves”| 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.
A video costs two requests
Section titled “A video costs two requests”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 video’s parts
Section titled “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.
The popular boards
Section titled “The popular boards”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.
Comments: a guest gets three
Section titled “Comments: a guest gets three”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.
An uploader’s video list
Section titled “An uploader’s video list”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.
Folders and page size
Section titled “Folders and page size”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.
Responses that are not the envelope
Section titled “Responses that are not the envelope”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 |
A complete worked example
Section titled “A complete worked example”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 bashset -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, sysvalue = 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 2done
echo "still running after two minutes; task $task is still valid" >&2exit 1The synchronous one-liner, for when you just want the answer in a terminal:
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.toolRemember that ?wait=20 can still come back 202 — that is the task still running, not a failure.
Python (httpx)
Section titled “Python (httpx)”"""Submit a link, poll the task, print the result. Needs: pip install httpx"""
from __future__ import annotations
import osimport 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.
JavaScript (fetch)
Section titled “JavaScript (fetch)”// 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));Calling from a browser
Section titled “Calling from a browser”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 tofetch(), 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.
Limits and defaults
Section titled “Limits and defaults”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.
Where to go next
Section titled “Where to go next”- Quick start — if you have no running instance yet, this is where the base URL and the first key come from
- Concepts — identities, the scheduler, the circuit breaker, and why a request costs what it costs
- Configuration reference — every setting named on this page
- Users and API keys — creating and scoping the key these examples use
- Playground and tools — the console front end for
explain, signing and link identification - MCP and AI agents — the same capabilities as agent tools
- CLI reference — the same capabilities from a terminal
- Troubleshooting — what to do when a code above keeps coming back