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
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
media:read Download records and stored files
media:write Starting, pinning and cancelling a download
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 douyin:read and tiktok: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

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 or TikTok 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, 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 or TikTok 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 are not exempt, because one operator’s bulk download would otherwise starve every read on the instance.

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
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 and TikTok by an offset; both 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 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 or TikTok 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 lacks the scope or role; details.required says which
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
IDENTITY_POOL_EXHAUSTED 503 yes No identity free; retry_after estimates the recovery
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

NOT_CONFIGURED and DOWNLOADER_UNAVAILABLE look similar and are not: the first means this install never had the component, so no amount of waiting will help; the second means 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, INTERNAL, DOWNLOADER_UNAVAILABLE. 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. A loop on one of these burns identities to be told the same thing.
  3. Fix the credential: UNAUTHENTICATED, FORBIDDEN_SCOPE. Read details.required — it names the scope or role you are missing.
  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
cache.list_ttl 300s (5 min) Anything paged

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; 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, host_missing, port_invalid, host_not_public. 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 and proxy.
  • 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 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 or tiktok 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 either read scope Any supported link, or the whole share text around one
POST /api/v1/tasks/batch either 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
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 Paged. See the platform note below
GET /api/v1/{platform}/mix/posts {platform}:read mix_id — Douyin mix_info, TikTok playlistId. Paged
GET /api/v1/{platform}/user/followers tiktok:read TikTok only
GET /api/v1/{platform}/user/following tiktok:read TikTok only

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. user/likes is offered on both, 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.

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). 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) 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, recheck, backfill Downloads, library and watchlist
/api/v1/downloads/… Media stored on the operator’s disk Downloads, library and watchlist
/api/v1/admin/… Identities, proxies, keys, users, settings, logs, watchlist, backup Operations, Users and API keys
/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 Operations

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.

Five 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
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",
"DOWNLOADER_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", "DOWNLOADER_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
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.