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.
-
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’sDownloadStationTaskProxyV2.cs(see above). -
ds2-post-json-sid-query(new — dvcol/synology-download extension contract, fetched 2026-07-04 fromsynology-download2.service.tsandsynology.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 actualcreateTask()/query()code, not just a user’s paraphrase:- POST, with
_sidin the query string only (_body_params = { _sid }, kept out of the body) — mirrors our own DS2 file-upload path’ssidInQuery: true. - All other params (
api,method,version,type,url,create_list,destination) go url-encoded into the POST body. urlis JSON-encoded as an array:JSON.stringify(urls.map(sanitizeUrl))→ literally["magnet:..."](brackets and quotes included, then url-encoded as a body value).typeis built viastringifyKeys(_request, true), whosetrueflag strongly implies the same JSON-encoded-string-literal treatment we already use for DS2 file uploads (type→"url", quotes included).destinationis 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
_sidand JSON-quoted body values — instead of split across GET/POST as the Prowlarr-only implementation was.
- POST, with
-
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,_sidin the body (default). Confirmed to fix the originally reported error 120 for a real user, but usesuri(noturl) as the DS2 field name and was never cross-checked against the file-upload path.
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 anappendFileLast hook that runs after all other params are appended.
DS2 (SYNO.DownloadStation2.Task):
_sidgoes 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, anddestinationare sent as JSON-encoded string literals — i.e. the raw form value fortypeis the 6-character string"file"(quotes included), andfileis 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— notfile. Thefileform field is a separate JSON-array reference pointing at thefileDatafield name, not the bytes themselves. create_listis the bare (unquoted) string"false".
SYNO.DownloadStation.Task, legacy):
_sidstays in the form body (normal case).- Form fields, in order:
api,version,method,destination(optional), then the file last, under field namefile(matches the official public docs — notype, nocreate_list, no JSON quoting). - Uses API version 2 for this call specifically (
preferredVersion: 2, passed as arequestTaskApioverride), 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:
request.downloadPath(per-request override), thenthis.downloader.downloadPath(this downloader’s configured path), thengetNasDefaultDestination()—SYNO.DownloadStation.Info.getconfig’sdefault_destination, cached for the life of the client instance (added to theensureApiInfoAPI descriptor query).
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)
ForcreateUrlDownload, both API versions unified on:
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 reorderaddFileUpload 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.