errno

Redmine REST: POST /uploads.json returns 404, and the API key only works in the query string

· in APIs and automation · tested on Hosted Redmine instance, REST API, curl and Python requests

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 422404. 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:

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.

redmine api curl automation