Redmine REST: POST /uploads.json returns 404, and the API key only works in the query string
Symptom
Attaching a file to a Redmine issue over REST is documented as a two-step dance. First you push the raw
bytes to /uploads.json, which answers with a token. Then you create or update the issue and reference
that token in an uploads array. Step two is the interesting one, so that is where I expected trouble.
Step one never got that far:
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "https://redmine.example.com/uploads.json?key=<API_KEY>" \
-H 'Content-Type: application/octet-stream' \
--data-binary @report.pdf
404
Not 401, not 403, not 422 — 404. And since every attachment upload in the REST API starts by
obtaining a token from exactly this endpoint, that single status code means there is no way to attach a file
to an issue through the API on this instance. Not a harder way, not a slower way: none.
(Do not type the key on the command line the way that snippet suggests. The next-but-one section explains why, and what to do instead. I am showing the request shape first because that is what you will be comparing against your own.)
What it is not
It is not a permissions problem. The obvious reading of a failure on an upload endpoint is that the API
key belongs to a user who may comment but not attach. That reading does not survive the first check: the
very same key creates issues and updates fields on the same instance without complaint. A Redmine that
dislikes your key answers 401; one that dislikes your role answers 403. Neither is what came back.
It is not a wrong path or a typo. /uploads.json is the documented path, and the 404 is returned for the
endpoint itself rather than for some object inside it — there is no id in that URL to get wrong. Adding or
removing the .json suffix, switching the content type, and shrinking the body to a single byte all produce
the same status.
What is left is the boring explanation: on that deployment the upload route is not enabled, or not routed at all. This is something you can only find out empirically, because the API reference describes attachment upload unconditionally, with no hint that an administrator can make it disappear. Documentation describes the software; a 404 describes the installation in front of you.
Before you iterate, keep the key out of your history
You are about to send the same request ten or twenty times with small variations, so fix the secret handling
before you start rather than after. A key pasted into the command line lands in your shell history file and
is visible in ps output to every other user on the box for the lifetime of the request.
curl has a built-in answer — a config file holding the whole URL, including the query string:
umask 077
cat > ~/.redmine-curl <<'EOF'
url = "https://redmine.example.com/issues/123.json?key=REPLACE_WITH_KEY"
EOF
chmod 600 ~/.redmine-curl
curl -sS --config ~/.redmine-curl
In a script, read the key from a mode-600 file into a variable at the top and never echo it. Both
approaches keep the secret out of history and out of the process table, and cost one line. The same applies
in Python: load the key from a file or the environment, and keep it out of anything you log.
What I measured
Three calls were enough to map what the integration could and could not do. I now run them against any unfamiliar Redmine before writing a line of integration code.
1. Which transport authenticates. This one surprised me. The key in the query string works:
curl -sS -o /dev/null -w '%{http_code}\n' \
"https://redmine.example.com/issues.json?limit=1&key=<API_KEY>"
200
The same key sent the documented header way does not:
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'X-Redmine-API-Key: <API_KEY>' \
"https://redmine.example.com/issues.json?limit=1"
401
A 401 from a header-authenticated request is the perfect trap, because it points you straight at the key:
you regenerate it, you check the user, you ask an administrator whether API access is enabled. The key was
fine all along. If a reverse proxy or a WAF in front of the application strips unknown request headers, the
application never sees your credential and correctly reports that it saw none. Test both transports on any
Redmine you automate against before you conclude that your key is wrong.
2. Whether the upload endpoint exists. A one-byte body is enough; you are probing routing, not content:
printf 'x' | curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "https://redmine.example.com/uploads.json?key=<API_KEY>" \
-H 'Content-Type: application/octet-stream' --data-binary @-
3. Which user the key resolves to. Worth knowing before you file anything under the wrong name:
curl -sS "https://redmine.example.com/my/account.json?key=<API_KEY>" | jq '.user.login'
The fix: reference the artefact instead of pushing it
Once the upload endpoint is known dead, the honest fix is to stop trying to move bytes into Redmine. Put the artefact where it already lives — a shared drive, an object store, a build server’s artefact URL — and reference it from the issue as a path or link:
curl -sS -X PUT "https://redmine.example.com/issues/1567.json?key=<API_KEY>" \
-H 'Content-Type: application/json' \
-d '{"issue":{"notes":"Report generated: https://artifacts.example.com/builds/8412/report.pdf\n\nsha256: see build log"}}'
On an instance you do not administer, this is not a temporary hack waiting for a proper solution. It is the stable contract, because the upload route may stay off forever and nobody will tell you when it changes. It also has a property the upload flow lacks: the artefact keeps a single canonical location instead of being duplicated into a ticket database.
What still works, and is usually enough
Everything else I needed from the API was available:
POST /issues.jsoncreates issues.PUT /issues/<id>.jsonwith{"issue": {"notes": "..."}}adds a journal note — this is the workhorse for automation that reports progress.PUTalso updates the fields that wrapper libraries habitually omit:parent_issue_id,due_date,estimated_hours. That gap is its own story, in the wrapper that hid those fields.
So the integration lost exactly one capability, and it lost it in the first five minutes instead of after a day of building an attachment pipeline against an endpoint that is not there.
What to remember
An HTTP 404 on a documented endpoint is a deployment fact, not a bug in your request. The instinct to keep
editing the request — another content type, another path spelling, another auth header — assumes the route
exists and you are addressing it badly. When the status is 404 rather than 400, 401, 403 or 422,
the server is telling you it has nothing at that address, and no amount of request polishing will conjure it.
Treat third-party API documentation as the superset and your particular instance as the subset. Plugins,
reverse proxies, configuration flags and version drift all subtract from the documented surface, and none of
them subtract visibly. Measure the subset first: a handful of curl calls that check auth transports, probe
the endpoints you depend on, and confirm which user your key resolves to.
The corollary is about error codes themselves. Distinguishing 401 (I do not accept this credential) from
403 (I accept it but not for this) from 404 (there is nothing here) is what turns an afternoon of
guessing into three requests. When the code contradicts your theory, the code is right.