Sonarr and Radarr, step by step

There are three ways to use Sonarr and Radarr with zurg. Pick the one that matches the account you have.

Guide Who searches zurg acts as You need
Usenet Sonarr and Radarr a SABnzbd a Usenet account and Usenet indexers in Prowlarr or the *arrs
Torrents Sonarr and Radarr a qBittorrent a debrid account and torrent indexers in Prowlarr or the *arrs
zurg searches zurg no download client at all a Usenet account and Usenet indexers in zurg's own config

The first two can run side by side. They share the same __magic__ folder.

Point Sonarr and Radarr at zurg and they grab from Usenet without a Usenet client — no download, no unpack, no disk. Every step below, with the screen you should be looking at.

This is the Usenet half. SABnzbd carries NZBs and nothing else; the torrent half is a separate endpoint answering the same two clients as a qBittorrent, and the two share this folder — see Sonarr & Radarr for torrents.

Everything below was captured against zurg nightly-993-g99bcf9a4, Sonarr 4.0.19.2979 and Radarr 6.3.0.10514, on a live install with a real Usenet account. Host and user names have been changed to you@zurg-server. Indexer names read my-indexer. API keys and library counts are illustrative. Nothing else in the captured output was altered.

Before you start

Three things have to be true. Check all three now — every one of them fails later as something that looks unrelated.

  1. zurg has an nzb provider. Without one there is nothing to read what the endpoint writes, and every job sits queued for ever. zurg says so at startup. See Usenet.
  2. magic.enabled is true. Without it the completed directory does not exist and every import fails. See __magic__.
  3. The mount is visible to the *arr at the path zurg reports. If Sonarr runs in a container, read step 8 before you configure anything.

Check the first two straight out of the config file, in zurg's working directory:

$ grep -A6 '^providers:' config.yml
providers:
- type: nzb
  nntp:
    host: news.example.com
    port: 563
    tls: true
    username: <redacted>
    password: <redacted>

$ grep -A4 '^magic:' config.yml
magic:
  enabled: true
  allow_delete: false
  sidecar_max_mb: 32
  sidecar_budget_mb: 2048

allow_delete: false is the safe default and does not affect imports — a delete inside __magic__ hides the entry rather than removing content from the account.

1. Turn the endpoint on

Two ways, same result. Both write to config.yml and both need a restart: the routes that serve __magic__ and the SABnzbd endpoint are registered once, when zurg starts.

Option A — the dashboard

Open zurg's dashboard, click Config, and scroll to __magic__ & Download Clients. Turn on Serve __magic__ and SABnzbd Endpoint, then set SABnzbd Categories to tv, movies.

The zurg dashboard's Quick Links
The zurg dashboard's Quick Links

Config is where the setting lives; Magic is where you will watch grabs arrive later.

The magic and download clients block on the config page
The magic and download clients block on the config page

Leave SABnzbd Completed Directory empty unless your *arr mounts zurg somewhere other than zurg does — see step 8. The orange Restart Required chip is telling the truth: nothing here takes effect until zurg restarts.

Option B — config.yml

Add the block by hand and restart zurg.

magic:
  enabled: true

sabnzbd:
  enabled: true
  api_key: ""          # empty = zurg generates one and keeps it
  categories: [tv, movies]
  complete_dir: ""     # empty = <mount_path>/__magic__/__all__

The categories are not folders. They all resolve to the same place — a job's folder is the release's own folder under __magic__/__all__. The list exists only so Sonarr and Radarr stop warning about a category they cannot find. If Sonarr's category is tv, tv must be in this list.

Restart zurg, then confirm the provider took:

The providers block on the zurg config page
The providers block on the zurg config page

One nzb row is all the endpoint needs.

2. Find the API key

The API key is the entire authentication on this endpoint, so get it right once and don't paste it anywhere public.

If you left api_key empty, zurg generated one on first start, wrote it to data/sabnzbd-apikey, and logged it once:

$ cat data/sabnzbd-apikey
0123456789abcdef0123456789abcdef

$ journalctl -u zurg-usenet | grep 'generated API key'
SABnzbd: generated API key 0123456789… — paste it into Sonarr or Radarr,
or pin it as sabnzbd.api_key in config.yml

Illustrative key — yours will differ. The file survives restarts, so the key is stable once generated.

If you set api_key yourself, it is in config.yml and no file is written. The dashboard tells you which of the two you are in, at the bottom of the block in step 1 — it reads either pinned in config.yml under sabnzbd.api_key or generated and kept in data/sabnzbd-apikey.

This endpoint is outside zurg's basic auth. Neither Sonarr nor Radarr ever sends basic auth to a download client, and their HTTP layer turns a 401 into "unable to connect" with nothing actionable in it — so zurg deliberately exempts /api. The API key is the whole gate. Treat the port the way you treat the rest of zurg's: on a trusted network, or behind something that is.

3. Prove it answers

Do this before touching Sonarr. It takes ten seconds and it separates "zurg is wrong" from "the *arr is wrong" for the rest of the setup.

$ SAB=0123456789abcdef0123456789abcdef
$ ZURG=192.168.88.245:9996

$ curl -s "http://$ZURG/api?mode=version&apikey=$SAB&output=json"
{"version":"4.5.1"}

$ curl -s "http://$ZURG/api?mode=get_config&apikey=$SAB&output=json" \
    | jq '.config.misc.complete_dir, [.config.categories[].name]'
"/mnt/zurg_usenet/__magic__/__all__"
[ "*", "tv", "movies" ]

complete_dir is the path zurg will hand the clients — check now that it is a path they can open.

Then check that a wrong key is actually refused:

$ curl -s "http://$ZURG/api?mode=queue&apikey=wrongkey&output=json"
{"status":false,"error":"API Key Incorrect"}

$ curl -s "http://$ZURG/api?mode=queue&output=json"
{"status":false,"error":"API Key Required"}

mode=version is not an authentication test. It answers {"version":"4.5.1"} even with a wrong key — that is how real SABnzbd behaves, and zurg copies it. Use mode=queue or mode=get_config when what you actually want to test is the key.

Both URL shapes work, so either is fine in the client's URL Base field:

Path URL Base in Sonarr / Radarr Answers
/api (leave empty) yes
/sabnzbd/api /sabnzbd yes

If /api returns a plain 404, sabnzbd.enabled is still false or zurg has not been restarted since you changed it.

4. Make the root folders

Make them before you add them, because the *arr's Add Root Folder dialog browses the live filesystem and will not offer a folder that does not exist.

$ mkdir -p /mnt/zurg_usenet/__magic__/tv /mnt/zurg_usenet/__magic__/movies

$ ls -d /mnt/zurg_usenet/__magic__/tv /mnt/zurg_usenet/__magic__/movies
/mnt/zurg_usenet/__magic__/movies
/mnt/zurg_usenet/__magic__/tv

Substitute your own mount_path for /mnt/zurg_usenet. The folder names are yours to choose; tv and movies just match the categories.

The root folder goes beside the completed directory, not at it and not above it. zurg hands the clients __magic__/__all__, which is your library as __all__ lists it. Your root folders sit next to it, at __magic__/tv and __magic__/movies. Not __magic__/__all__, not __magic__ itself and not /mnt/zurg_usenet. Both clients raise a health check when a root folder is the download client's output folder, and Radarr also raises one when a root folder contains it. Siblings trip neither, and a manual import pointed at the completed directory then has one release to look at rather than your whole library.

This is what getting it wrong looks like. Both are real, from the two clients. They were taken before the completed directory moved down to __magic__/__all__, so the paths in them are one level up from yours — the two rules they show are the same ones:

Sonarr health check warning about a root folder at the completed directory
Sonarr health check warning about a root folder at the completed directory

Sonarr with the completed directory itself added as a root folder — it warns because the root folder is the output folder.

Radarr health check warning about a root folder above the completed directory
Radarr health check warning about a root folder above the completed directory

Radarr with /mnt/zurg_usenet added — it warns for the parent too, which Sonarr does not. Move the root folder beside the completed directory and both warnings go.

5. Add the client in Sonarr

A brand-new Sonarr blocks everything behind a first-run screen. Pick Forms (Login Page), set a username and password, and click Save.

Sonarr's first-run authentication screen
Sonarr's first-run authentication screen

It will not let you past until Authentication Method is something other than None.

The same screen filled in
The same screen filled in

After Save you are signed out once and land on the login page.

Sonarr's login page
Sonarr's login page

Settings → Download Clients

Go to Settings → Download Clients. On a fresh install there is nothing here but the + card.

Sonarr's empty download clients page
Sonarr's empty download clients page

Click the + under "Download Clients" — not the one further down under "Remote Path Mappings".

Choose SABnzbd from the Usenet column.

The add download client picker with SABnzbd visible
The add download client picker with SABnzbd visible

zurg impersonates SABnzbd specifically, so nothing else in this list will work.

The dialog opens with SABnzbd's own defaults — localhost, port 8080, no key.

The untouched SABnzbd download client dialog
The untouched SABnzbd download client dialog

What to put in each field

Field Value Why
Name zurg (Usenet) Anything. It is only a label.
Enable on Leave it checked.
Host 192.168.88.245 The host zurg runs on, as the *arr can reach it. In Docker, see the warning below.
Port 9996 zurg's normal port — 9999 on a default install.
Use SSL off Only if you put zurg behind TLS yourself.
API Key from step 2 The whole gate.
Username empty Mutually exclusive with the key. Only the key is accepted.
Password empty Same.
Category tv Must appear in sabnzbd.categories.
Recent / Older Priority Default zurg has nothing to apply a priority to.
Remove Completed on Lets the client clean up its own job records. Leave it on.
Remove Failed on Same.
The SABnzbd dialog filled in
The SABnzbd dialog filled in

The API key shown is illustrative — paste your own.

In Docker, "localhost" and the hostname both lie. Inside a container, localhost is the container, and the machine's own hostname usually resolves to 127.0.1.1 — which is also the container. Use the host's LAN or Tailscale IP, never localhost and never the bare hostname, even when both work fine from your shell.

Click Test. A green tick means all four of the client's checks passed: version, API key, global config, and the category.

The dialog footer showing a successful test
The dialog footer showing a successful test

Now click Save.

The saved download client card in Sonarr
The saved download client card in Sonarr

6. Add the root folder in Sonarr

Go to Settings → Media Management and scroll to the bottom.

The empty root folders section in Sonarr
The empty root folders section in Sonarr

Click Add Root Folder. A file browser opens on the container's filesystem.

Sonarr's file browser at the filesystem root
Sonarr's file browser at the filesystem root

Type the path into the box at the top. If the mount is genuinely visible to Sonarr, what is inside __magic__ appears as you type — __all__ with every release in it, and whatever you made beside it. That is the fastest proof you will get that the bind mount is correct.

The file browser listing what is inside magic
The file browser listing what is inside magic

If this is empty or errors, stop and fix the mount before going further.

The path finished at magic/tv
The path finished at magic/tv

Finish the path at __magic__/tv and click Ok.

The root folder added, showing free space and unmapped folders
The root folder added, showing free space and unmapped folders

Free Space reading a plausible number means Sonarr can stat the mount; Unmapped Folders counts releases already sitting there.

7. Do the same in Radarr

Identical, with two values changed: the category is movies and the root folder is __magic__/movies.

Radarr's login page
Radarr's login page

Radarr's first run works the same way — set Forms auth, then sign in.

Radarr's add download client picker
Radarr's add download client picker

Settings → Download Clients → +, then SABnzbd under Usenet.

Radarr's SABnzbd dialog filled in with the movies category
Radarr's SABnzbd dialog filled in with the movies category

Same host, same port, same key. Only Category differs — movies, not tv.

Radarr's dialog footer showing a successful test
Radarr's dialog footer showing a successful test

Test, green tick, Save.

The saved download client card in Radarr
The saved download client card in Radarr

Then Settings → Media Management → Root Folders → Add Root Folder, and add /mnt/zurg_usenet/__magic__/movies.

One zurg, two clients, one queue. zurg's queue and history are filtered by category and nothing else. Two Sonarrs both using tv will see each other's jobs; Sonarr on tv and Radarr on movies will not. Keep one client per category and this never comes up.

8. If your *arr is in Docker

Three separate problems. The second one costs a download on every import. The third does not bite until the first time zurg restarts.

The path zurg reports must be a path the client can open

If the container mounts the library somewhere other than zurg does, every import fails with download doesn't contain intermediate path or a remote-path health check. Fix it either way — both are fine:

  • Set sabnzbd.complete_dir to the path the client sees. Simplest when one client, or several that agree, mount the library at the same place.
  • Add a remote path mapping in the *arr. Use this when different clients mount it differently.
sabnzbd:
  complete_dir: "/data/zurg/__magic__/__all__"   # what the *arr sees, not what zurg sees

For the second, go to Settings → Download Clients and use the + under Remote Path Mappings — the lower one on the page, not the one you used in step 5.

The add remote path mapping dialog
The add remote path mapping dialog

Host must be exactly the host string you typed into the download client. Remote Path is what zurg reports; Local Path is where the client sees it.

One volume for the download folder and the root folders

An import out of __magic__/__all__ into __magic__/tv is free because it is a rename. A rename only works inside one mount. Give the container the two folders as two volumes and it sees two filesystems. Both are zurg's mount underneath and it still does not matter.

    volumes:                                        # do not do this
      - /mnt/zurg_usenet/__magic__/__all__:/downloads
      - /mnt/zurg_usenet/__magic__/tv:/tv

The kernel refuses the rename inside the container. Sonarr and Radarr then copy the file and delete the original without saying so. Every import downloads the whole release from your account and writes it to zurg's disk. The import still succeeds. It is just slow. One 1.46 GB episode took 50 seconds and 2.9 GB of traffic this way. With one volume it took one second and read nothing.

Give the container one volume that holds zurg's whole mount. Reach the download folder and the root folders through it. The bind in the next section does exactly that. A remote path mapping or complete_dir then points the client at __all__ inside that same volume.

A manual import set to Hardlink/Copy Files copies too. Pick Move Files.

Bind the mount's parent, not the mountpoint

This is the one that costs an afternoon. It is also the one volume the section above asks for. Restarting zurg unmounts /mnt/zurg_usenet and mounts it again. A container that bound the mountpoint itself keeps the fuse connection it was started with — which is now dead. Every read then answers Socket not connected, and because both clients check free space on the root folder before they grab, every release is silently rejected:

FreeSpaceSpecification: Socket not connected

Nothing in either client says the mount is stale, the folder still appears inside the container, and an interactive grab pushed straight at the client still works. What it looks like is an *arr that has quietly stopped searching.

Bind the parent with rslave, and the remount arrives as a sub-mount event the container follows:

    volumes:
      - type: bind
        source: /mnt          # the parent, not /mnt/zurg_usenet
        target: /mnt
        bind:
          propagation: rslave

Check a container after any zurg restart. You want a size, not an error:

$ docker exec radarr df -h /mnt/zurg_usenet
Filesystem      Size  Used Avail Use% Mounted on
zurg{PGSq4}:    1.0P     0  1.0P   0% /mnt/zurg_usenet

The petabyte is zurg reporting a virtual filesystem, not a disk — that is expected, and it is what satisfies the *arr's free-space check.

9. Watch one grab go through

Add one thing, grab it by hand, and follow it the whole way. Everything below is a real grab through the setup above.

Grab it

Open a movie (or an episode), click Interactive Search, and pick a release.

Radarr's interactive search results
Radarr's interactive search results

The Source column reads nzb — that is the protocol zurg can take.

Click the circled-arrow button at the end of the row to grab it. If the release does not match cleanly, the client asks you to confirm the mapping first:

Radarr's Override and Grab dialog
Radarr's Override and Grab dialog

Override and Grab appears when the client wants you to confirm what the release is. Check the mapping and click Grab Release. A normal automatic grab never shows this.

It lands in the queue

Radarr's queue immediately after the grab
Radarr's queue immediately after the grab

Activity → Queue immediately after the grab. Note the progress bar: it is full, and Time Left is a dash. There is no download to measure, so a job is queued or completed and nothing in between. The other rows here belong to a second Radarr pointed at the same zurg on the same category — see the note in step 7.

zurg writes the NZB and reads it

Aug 25 14:44:03 zurg-server zurg[3034965]: INFO  nzb  Loaded NZB
  Last.Week.Tonight.with.John.Oliver.S13E22.480p.WEB.x264-RMTeam.nzb: 4 files
Aug 25 14:50:28 zurg-server zurg[3034965]: INFO  nzb  Loaded NZB
  Untold.The.Testimony.of.Vince.Young.2026.1080p.NF.WEB-DL.DD+5.1.Atmos.H.264-playWEB.nzb: 9 files

Loaded NZB … : N files is the line that says the NZB parsed. If a job sits queued for ever, this line is what is missing.

The import happens

Radarr's history showing Grabbed then Movie Imported
Radarr's history showing Grabbed then Movie Imported

Activity → History. Two events, seconds apart: Grabbed, then Movie Imported.

The Radarr movie page showing Downloaded status
The Radarr movie page showing Downloaded status

Status: Downloaded, Size 4.7 GiB — and nothing was downloaded.

Prove the file is real

The import moved the file into the root folder. Read both ends of it and ask ffprobe what it is:

$ ls -la "/mnt/zurg_usenet/__magic__/movies/Untold - The Testimony of Vince Young (2026)/"
-rw-r--r-- 1 you you 4920958828 Aug 25 14:53 Untold.The.Testimony…playWEB.mkv

$ dd if="$F" bs=1M count=1 >/dev/null
1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.0429446 s, 24.4 MB/s

$ ffprobe -v error -show_entries format=duration,size,format_name \
    -of default=noprint_wrappers=1 "$F"
format_name=matroska,webm
duration=4709.440000
size=4920958828

A 78-minute Matroska, streamed out of Usenet articles on demand. The bytes arrive when something reads them, not when the *arr imports.

10. Adding a release by hand

Sometimes Sonarr or Radarr never grabs the release you want. The indexers it searches might not carry it, or a quality profile or custom format turns it down. You can hand zurg the NZB yourself under the category of the app that should import it. That app then imports it as if it had grabbed it.

If the release is on an indexer the app searches, try the app first. Interactive Search lists rejected releases too. Grabbing one sends it to zurg like any other grab. Radarr grabbed an IMAX release its profile turned down for scoring 0 against a required custom format, and imported it.

$ SAB=0123456789abcdef0123456789abcdef
$ ZURG=192.168.88.245:9996

# an NZB file you already have (the upload field is called name, as in SABnzbd)
$ curl -s "http://$ZURG/api?mode=addfile&apikey=$SAB&cat=movies" \
    -F 'name=@Movie.Title.2024.1080p.WEB-DL.nzb'
{"status":true,"nzo_ids":["SABnzbd_nzo_…"]}

# or a link to one, which zurg downloads itself
$ curl -s -G "http://$ZURG/api" \
    --data-urlencode mode=addurl --data-urlencode "apikey=$SAB" --data-urlencode cat=movies \
    --data-urlencode 'name=https://indexer.example/getnzb/<id>?r=<indexer-key>' \
    --data-urlencode nzbname=Movie.Title.2024.1080p.WEB-DL
{"status":true,"nzo_ids":["SABnzbd_nzo_…"]}

--data-urlencode keeps the link's own ? and & inside name. The job shows up in the app's queue on its next check, within a minute.

Five things decide what happens next.

  1. The film or show has to be in that app already. It imports only what it can match to something it manages.

  2. The name has to say what it is. The job takes the NZB's filename, or nzbname when you pass one. Without nzbname, addurl uses the filename the indexer serves. Give the name a year.

  3. The category picks the app. cat=movies reaches the Radarr whose download client uses movies. Each app only looks at its own category.

  4. The quality profile does not block it. The file you already have can. Radarr checks neither the allowed qualities nor a minimum custom format score on import. It imported a 720p BluRay at score 0 into a profile that only allows 2160p and requires a score of 100. It also imported a 1080p WEBRip into a film whose profile only allows 720p. It then took a 1080p BluRay over it as an upgrade and deleted the WEBRip. A WEB-DL added after that stopped in Activity as Not an upgrade for existing movie file. Activity → Queue → Manual Import brings it in anyway and replaces the file.

  5. A failure is yours to clear. The app never blocklists or removes a download it did not grab. A release whose articles had aged off the news servers came back Failed from zurg and stayed in Radarr's queue as Download has failed wasn't grabbed by Radarr, skipping automatic download handling. Remove it from the queue and pick another release.

Measured on 2026-10-10 with Radarr 6.3, using both addfile and addurl, including a profile that requires a custom format built from a release name. Every import was a rename into __magic__/movies and nothing was copied. Sonarr was not tested.

Thanks to .telmen on Discord for asking how to do this with Usenet.

Watching from zurg's side

zurg's /magic/ page lists every NZB the endpoint has ever been handed. It is read-only — a job is removed by the client that created it, and the record is kept for a week after that so a poll racing a delete answers the same way twice.

The magic page header showing placements and tombstones
The magic page header showing placements and tombstones

Placements are files the clients moved; tombstones are the job folders they deleted afterwards. A rising sidecars number is the warning sign: it means a client is copying rather than moving.

The SABnzbd jobs table on the magic page
The SABnzbd jobs table on the magic page

State is the whole story — Queued means zurg is not done with the release yet, whether because the library has not listed it, its file sizes have not settled, or its articles have not been checked; Completed means it is ready to import; and Release names the folder the client imports from.

That Release column is read off the library at every poll, not stored. Rename the release in zurg's dashboard, let a repair rebuild it, or let zurg add a suffix because two releases share a name — the job follows it.

What works, and what does not

Works:

  • The connection test, all four checks, in both clients.
  • Grabbing, queue, history, import by rename, and the post-import cleanup.
  • Re-grabbing the same release. The job id comes from the NZB's filename, so it names the same job rather than a second one. A re-grab of a release an earlier import already emptied is reported Failed. Its folder under __magic__/__all__ has nothing left to import and Failed makes the client blocklist it and take another. The grab that did the emptying keeps reporting Completed until the client deletes it.
  • Removing a job from the queue, with or without deleting the NZB.
  • mode=addurl, for a human or an automation passing a URL instead of uploading. Neither *arr uses it.

Does not:

  • No progress. A job is queued or completed; there is no percentage between, because there is no download to measure. The queue reports the NZB's declared size so the row is not blank.
  • No pause, resume, speed limit, priority or post-processing script. SABnzbd has all of these; zurg has nothing to apply them to.
  • del_files on a history delete is ignored. The client means "the job folder is yours again", but the file it just imported is still served out of that NZB. The folder it wanted gone is gone — it deleted that itself, through the mount.
  • Basic auth cannot protect this endpoint. The API key is the only gate.

The failure signal. A release with nothing importable in it is reported Failed, and both clients blocklist it and grab an alternative. That covers a release whose files are all broken, deleted or filtered away; one holding nothing but .par2, .sfv and sidecars; one whose every file is a shape nothing plays, which is what an obfuscated post naming its RAR set .z001….z133 looks like; and an archive already opened and found to hold no media. A RAR set is content, not scaffolding: zurg streams the video straight out of it. If a release really does carry something zurg has no extension for, widen addl_playable_extensions rather than blocklisting it.

The articles are checked before a job is reported finished. zurg waits until the library lists the release and its file sizes have settled. Then it asks the news servers about the start and the end of each content file. That is the first sixteen articles and the last one. It also downloads the first article of each file. That is up to sixty-four of them plus a few more of a release with only a few files. Some news servers still say an article exists after its content was taken down and only a download shows that. A downloaded article also shows it belongs to this post and not to another upload. The job stays Queued until that answers. Damage the sample does not reach is not caught here. It turns up on the read that reaches it. A release whose articles are gone gets a PAR2 repair first and is checked again once the repair has run. One that is whole again carries on. One still missing articles, or one nothing can repair, is reported Failed with a count of what is missing. That is a download failure, which is the one the clients act on: they blocklist it and grab an alternative unattended, rather than importing something that will not play.

A check that cannot finish does not wait for ever. Sometimes the check cannot complete at all. The connection pool may be down or the account throttled or the walk out of time. That is not an answer. The job stays Queued and is asked about again a minute later. zurg counts the attempts on the job so a restart does not forget them. After thirty failed attempts in a row that span at least three hours the job moves to history as Failed. The message carries the count and the span and the last error. An outage of outright refusals reaches thirty inside an hour and does not fail the queue for it.

A release that leaves the library fails its job. A release can be deleted from the dashboard or have its NZB removed from the watch directory or be dropped by every account. Its job stays Queued for fifteen minutes and then moves to history as Failed. The client blocklists it and grabs another. The wait is there for restarts. The library unlists every release while it loads and that must not read as a deletion. Nothing fails on this ground in the first fifteen minutes after zurg starts. A job whose release the library never listed is not affected because it is still arriving.

Troubleshooting

What the *arr says What it means
"Unable to connect to SABnzbd" zurg is not reachable at that host and port, or something in front of it answered a non-200. Check sabnzbd.enabled — while it is off, /api is a plain 404. In Docker, check you did not use localhost or the bare hostname.
"API Key Incorrect" / "API Key Required" The key does not match. It is in config.yml if you set one, in data/sabnzbd-apikey if zurg generated it.
"Test was aborted due to an error" Something other than an authentication failure. zurg logs every request it refused — grep the log for SABnzbd:.
"Category … does not exist" The category in the *arr is not in sabnzbd.categories. Add it and restart zurg.
"Downloads in root folder" A root folder is at or above the completed directory. Move it beside it — __magic__/tv, not __magic__/__all__, not __magic__ and not /mnt/zurg_usenet.
"Remote path mapping" / "download doesn't contain intermediate path" The path zurg reports is not a path the client can open. See step 8.
Imports succeed but take as long as a download and zurg's disk fills up The client is copying out of __magic__/__all__ instead of renaming. Its container has the download folder and the root folders on two volumes. Or a manual import was set to copy. See one volume for the download folder and the root folders.
Every release rejected, nothing in the log A stale bind mount. Run docker exec <client> df -h <mount>; if it says Socket not connected, bind the parent with rslave and recreate the container.
A job stays queued for a poll or two after the release appears Expected, and it settles once per release. An NZB does not state how long a file is; the estimate is being replaced by the exact length from the PAR2 index or one article's own header. Reporting Completed early is what makes the client throw File move incomplete, data loss may have occurred.
A job sits queued for ever The release never appeared in the library. Check that the NZB parsed — Loaded NZB <name>: N files in the log — and that the nzb provider is configured at all.
"The release holds no files that can be imported" The library holds the release but there is nothing in it a client could import. An NZB of nothing but recovery volumes is exactly this, and so is an obfuscated post whose RAR set is named .z001….z133. Widen addl_playable_extensions if the release really does carry content zurg has no extension for.
Finished releases stay queued during a large burst More than sixty jobs are ready at once. Sonarr and Radarr only ask for the newest sixty history entries, so zurg holds the surplus in the queue and advances one into history each time an import clears a slot. Expected, and it drains itself.
"The release could not be checked against the news servers" The article check failed to complete thirty times in a row across at least three hours. Nothing was found missing. The news account could not be asked. The message carries the last error. Look at the account first and re-grab the release once it answers.
"The release was in the library and has since been removed from it" The library listed the release once and has not listed it for fifteen minutes. Something deleted it and the log says Release <name> is gone from every account. The client blocklists it and grabs another rather than waiting for a folder that is not coming back.

Where the state lives

Path What it is
nzbs/<name>.nzb The NZB itself, named once and never renamed — the Usenet backend derives the release's identity from that filename, so renaming it orphans the release and every placement made out of it.
data/sabnzbd-jobs.json Which NZBs were grabbed, under which category, and when.
data/sabnzbd-apikey A generated API key. Not written when sabnzbd.api_key is set.
data/magic.json The __magic__ table — every placement and tombstone the imports created.