Skip to main content

Synology Download Station API — undocumented behavior notes

SYNO.DownloadStation2.Task (DSM 7, “DS2”) and SYNO.DownloadStation.Task (legacy, “v1”) are not documented in Synology’s public API reference beyond the parameter names. The exact request shape (HTTP method, multipart field order, value encoding, where _sid goes) was reverse-engineered. This doc records what we tried, what turned out to be wrong, and the contract currently implemented in server/downloaders/synology.ts, so a future debugging session doesn’t have to redo the research.

Current implementation (verified against Prowlarr’s live client)

Source: DownloadStationTaskProxyV2.cs, DownloadStationTaskProxyV1.cs, DiskStationProxyBase.cs (fetched 2026-07-02). Prowlarr is a live, widely-deployed *arr project handling this exact API, so its shape is trusted over our own guesses — but it has not been tested against a real Synology device from inside this codebase. If a user reports error 101/120 again, re-check this first.

Add by URL/magnet — v1 (legacy)

Sent as GET, bare string query params, no JSON quoting: uri=<url>&destination=<dir> (destination omitted if blank). Not in dispute — only DS2 has competing evidence (see below).

Add by URL/magnet — DS2, three candidate contracts (runtime fallback chain)

Unlike every other call in this file, DS2’s URL/magnet create request has two independent, plausible-but-unconfirmed sources that disagree, plus the earlier superseded guess. Rather than pick one, createUrlTask (in server/downloaders/synology.ts) tries all three in order at runtime, logging each attempt via downloadersLogger ("Trying Synology DS2 create-task request variant" / "...succeeded" / "...failed, trying next fallback"), so a real-device test run produces a definitive answer instead of another guess. First variant to succeed wins; if a variant throws (including a success:false Synology error response), the next is tried.
  1. ds2-get-bare (current default order — Prowlarr’s proxy contract, unchanged from before): GET, type=url&url=<url>&create_list=false&destination=<dir> (destination omitted if blank), bare strings, no JSON quoting. Source: Prowlarr’s DownloadStationTaskProxyV2.cs (see above).
  2. ds2-post-json-sid-query (new — dvcol/synology-download extension contract, fetched 2026-07-04 from synology-download2.service.ts and synology.service.ts). This is a live, purpose-built Synology Download Station browser extension — arguably a more direct source than Prowlarr (a generic *arr client juggling many download clients). Verified in its actual createTask()/query() code, not just a user’s paraphrase:
    • POST, with _sid in the query string only (_body_params = { _sid }, kept out of the body) — mirrors our own DS2 file-upload path’s sidInQuery: true.
    • All other params (api, method, version, type, url, create_list, destination) go url-encoded into the POST body.
    • url is JSON-encoded as an array: JSON.stringify(urls.map(sanitizeUrl)) → literally ["magnet:..."] (brackets and quotes included, then url-encoded as a body value). type is built via stringifyKeys(_request, true), whose true flag strongly implies the same JSON-encoded-string-literal treatment we already use for DS2 file uploads (type → "url", quotes included). destination is treated the same way in our implementation for consistency with the file-upload path.
    • This would make DS2’s contract internally consistent — URL-add and file-upload both POST with query-string-only _sid and JSON-quoted body values — instead of split across GET/POST as the Prowlarr-only implementation was.
  3. ds2-post-uri-bare (superseded Phase 1 guess, kept as last-resort fallback — see “Phase 1” below): POST, type=url&uri=<url>&destination=<dir>, bare strings, _sid in the body (default). Confirmed to fix the originally reported error 120 for a real user, but uses uri (not url) as the DS2 field name and was never cross-checked against the file-upload path.
If a real device test shows one of these consistently winning (or all three failing with a new error code), collapse buildDs2UrlCreateVariants back down to just that one variant and fold the result into this doc.

Add by file upload (torrent/NZB) — both API versions

Sent as POST multipart/form-data. Synology’s official (v1) docs state the uploaded file must be the last parameter in the body — our client honors this for both versions via an appendFileLast hook that runs after all other params are appended. DS2 (SYNO.DownloadStation2.Task):
  • _sid goes in the query string, not the form body (sidInQuery: true). This is presumably Synology’s own workaround for the same “file must be last” constraint — keeping identity out of the body entirely.
  • Form fields, in order: api, version, method, type, file, create_list, destination (optional), then the file itself last.
  • type, file, and destination are sent as JSON-encoded string literals — i.e. the raw form value for type is the 6-character string "file" (quotes included), and file is the literal string ["fileData"] (brackets and quotes included). This is not a bug in our code; it matches Prowlarr’s proxy byte-for-byte and is presumed to be a real DS2 API quirk (POST body values may be JSON-decoded server-side while GET query values are not).
  • The actual file bytes are uploaded under the field name fileData — not file. The file form field is a separate JSON-array reference pointing at the fileData field name, not the bytes themselves.
  • create_list is the bare (unquoted) string "false".
v1 (SYNO.DownloadStation.Task, legacy):
  • _sid stays in the form body (normal case).
  • Form fields, in order: api, version, method, destination (optional), then the file last, under field name file (matches the official public docs — no type, no create_list, no JSON quoting).
  • Uses API version 2 for this call specifically (preferredVersion: 2, passed as a requestTaskApi override), even though other legacy calls (list/pause/resume/delete/URL-add) use version 3. Prowlarr’s proxy does the same version split; the reason isn’t documented anywhere, just replicated.

Ordering is fixed on a session-timeout retry

requestApi retries once on error 106 (session expired) by re-authenticating and re-calling itself with the same options object. Previously this reused the same FormData instance passed in by addFileUpload, which appendApiParams/appendFileLast had already mutated in place — a retry would re-append api/version/method/etc. a second time and append the file field twice, so it was no longer strictly last. The retry path now rebuilds a fresh FormData before recursing whenever options.body instanceof FormData; appendFileLast is a closure over the file bytes, not over the FormData’s prior state, so re-running it against a fresh instance is safe and restores the “file must be last” guarantee on retry too.

Missing destination causes an opaque error 120 on DS2 create

A user report showed all three DS2 create-task variants above failing identically with Synology error 120. Log analysis of the actual request params showed none of the three attempts included a destination field at all — getSynologyDestination() only checked request.downloadPath and the downloader’s configured downloadPath, both empty for that downloader, so destination was omitted entirely rather than falling back to anything. Community reports (a SickChill issue, a Prowlarr issue) show this exact shape of error 120 is Synology’s generic parameter-validation rejection, and a missing required destination — with no NAS-side default configured — is a documented real-world cause. Important: this incident is not evidence about which of the three DS2 variants above is correct — all three failed for the identical missing-destination reason, so it can’t discriminate between them. That question (which variant a real device actually accepts) is still open, pending a real-device retest with a destination present. Prowlarr’s own proxy (GetDownloadDirectory() → GetDefaultDir()) doesn’t stop at “no configured path” either — it queries the NAS’s own Download Station default destination via SYNO.DownloadStation.Info getconfig before giving up. Our client now does the same:
  1. request.downloadPath (per-request override), then
  2. this.downloader.downloadPath (this downloader’s configured path), then
  3. getNasDefaultDestination() — SYNO.DownloadStation.Info.getconfig’s default_destination, cached for the life of the client instance (added to the ensureApiInfo API descriptor query).
If none of the three resolve, addDownload now fails fast with an actionable success: false message instead of sending a request Synology will reject with an opaque code. Additionally, SynologyErrorResponse now captures the optional field-level errors array Synology returns for validation failures ({ name, reason } per field — e.g. destination: required), and buildSynologyErrorMessage surfaces that detail when present, plus has an explicit case 120 fallback message when it isn’t. This makes future validation failures self-diagnosing from the logged error alone, rather than requiring another round of log archaeology.

Superseded approaches (kept for fallback reference)

If the contract above turns out not to work for a given device/DSM version, here is what was tried before and rejected, in case reverting to a simpler shape is worth testing:

Phase 1 (root-caused from a live error-120 report, applied first)

For createUrlDownload, both API versions unified on:
This fixed the original bug (url field name + JSON.stringify([url]) value + a stray create_list field that DS2 rejected as error 120), but used uri as the DS2 field name and POST. Prowlarr’s proxy uses url (not uri) for DS2 and sends it as a GET, with create_list=false explicitly included. If the current GET-based fix regresses on some devices, this POST/uri variant is the next thing to try — it’s simpler and was confirmed to fix the originally reported error 120, just not verified against the DS2 file-upload path.

Phase 2 original spec (literal field order, not adopted for DS2)

The initial ask was to reorder addFileUpload so all API params are appended before a single file field, appended last, with the DS2 type value corrected from bt/nzb — but keeping the field named file throughout and _sid in the form body for both API versions. This is plausible for v1 (and is what’s implemented above), but Prowlarr’s evidence suggests DS2 specifically needs the fileData-plus-JSON-array-reference structure and query-string _sid described above, not just a reordered file field with a corrected type value. If the current DS2 upload path fails, trying type: "file" with the bytes still under file (no fileData split, no query _sid) would be the intermediate step to test before reverting further.