PaperMC Download API v3: Why Your "latest" One-Liner Returns 404
The old api.papermc.io v2 "latest" URL returns 404. How to download Paper and Velocity from the fill v3 API with curl, jq and a SHA-256 check.
I had a provisioning script that did the lazy thing every Minecraft admin has done at some point: one curl to a "latest" URL, save it as server.jar, restart. It worked for ages. Then it didn't, and the failure mode was the worst kind — the script happily wrote a tiny error body to disk and the server refused to start with a corrupt-jar error.
The URL in question:
curl -o paper.jar \
https://api.papermc.io/v2/projects/paper/versions/latest/builds/latest/downloads/paper.jar
# -> HTTP 404
That pattern is dead. PaperMC's downloads moved to a new service called fill, with a v3 API, and the download files themselves live on a separate host addressed by their SHA-256. Once you know the shape it's actually nicer than the old one: you get the checksum in the same response, so there's no excuse for not verifying.
TL;DR: Ask https://fill.papermc.io/v3/projects/<project>/versions/<VERSION>/builds/latest, read the download URL from .downloads."server:default".url and the hash from .downloads."server:default".checksums.sha256, download, verify with sha256sum -c, only then swap the jar. Send a real User-Agent with contact info. Pin the version, don't ask for "latest" Minecraft.
The working request
UA="my-server-updater/1.0 (admin@example.com)"
curl -s -A "$UA" \
https://fill.papermc.io/v3/projects/paper/versions/1.21.8/builds/latest | jq .
What comes back (trimmed):
{
"id": 60,
"channel": "STABLE",
"downloads": {
"server:default": {
"name": "paper-1.21.8-60.jar",
"checksums": {
"sha256": "8de7c52c3b02403503d16fac58003f1efef7dd7a0256786843927fa92ee57f1e"
},
"size": 52811717,
"url": "https://fill-data.papermc.io/v1/objects/8de7c52c.../paper-1.21.8-60.jar"
}
}
}
Three things to notice:
idis the build number. Build 60 of Paper 1.21.8 here.channelisSTABLEor something else (beta/experimental builds exist). You want stable on a production server.- The download key is literally
server:default. The colon means you have to quote it in jq:.downloads."server:default".url. Forgetting the quotes gives you a jq syntax error that looks like your JSON is broken. It isn't.
The download URL points to fill-data.papermc.io/v1/objects/<sha256>/<name>.jar — content-addressed storage. The hash is in the path and in the checksums field.
Listing versions
To see what versions exist for a project:
curl -s -A "$UA" https://fill.papermc.io/v3/projects/paper | jq '.versions'
curl -s -A "$UA" https://fill.papermc.io/v3/projects/velocity | jq '.versions'
Versions come back grouped by family — "1.21": ["1.21.11", "1.21.10", ...] for Paper, "3.0.0": [...] and newer groups for Velocity. Those lists include release candidates, pre-releases and -SNAPSHOT versions mixed in with real releases. So "take the first entry" is not a safe way to find the newest stable version. That's one more reason I pin the version by hand.
There's also a per-version endpoint, /v3/projects/paper/versions/<VERSION>, which in my testing returned support status and the minimum Java version plus recommended JVM flags. Handy to glance at before an upgrade — if the minimum Java jumped, your systemd unit is about to fail.
The script
This is what replaced my one-liner. It downloads to a temp file, verifies the hash, and only then atomically moves the jar into place. If anything fails, the old jar stays untouched and the script exits non-zero, which is exactly what you want when it's called from a timer.
#!/usr/bin/env bash
# update-paper.sh <project> <version> <target-dir>
# e.g. update-paper.sh paper 1.21.8 /opt/minecraft
# update-paper.sh velocity <version> /opt/velocity
set -euo pipefail
PROJECT="${1:?project (paper|velocity|...)}"
VERSION="${2:?version, e.g. 1.21.8}"
TARGET="${3:?target dir}"
UA="my-server-updater/1.0 (admin@example.com)"
API="https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION}/builds"
# Build list is newest first; take the newest STABLE one
BUILD_JSON="$(curl -fsS -A "$UA" "$API" \
| jq -e '[.[] | select(.channel == "STABLE")][0]')" || {
echo "no STABLE build for ${PROJECT} ${VERSION}" >&2; exit 1; }
BUILD_ID="$(jq -r '.id' <<<"$BUILD_JSON")"
URL="$(jq -r '.downloads."server:default".url' <<<"$BUILD_JSON")"
SHA="$(jq -r '.downloads."server:default".checksums.sha256' <<<"$BUILD_JSON")"
NAME="$(jq -r '.downloads."server:default".name' <<<"$BUILD_JSON")"
CURRENT="${TARGET}/.${PROJECT}-build"
if [[ -f "$CURRENT" && "$(cat "$CURRENT")" == "${VERSION}-${BUILD_ID}" ]]; then
echo "${PROJECT} ${VERSION} build ${BUILD_ID} already installed"
exit 0
fi
TMP="$(mktemp "${TARGET}/.${NAME}.XXXXXX")"
trap 'rm -f "$TMP"' EXIT
curl -fsS -A "$UA" -o "$TMP" "$URL"
echo "${SHA} ${TMP}" | sha256sum -c --quiet -
mv -f "$TMP" "${TARGET}/${PROJECT}.jar"
trap - EXIT
echo "${VERSION}-${BUILD_ID}" > "$CURRENT"
echo "installed ${NAME}"
Why it looks like this:
curl -fmakes HTTP errors fail the command instead of saving the error page as your jar. That's the exact bug that bit me with the old URL — without-f, a 404 is a "successful" download.jq -eexits non-zero if the selection isnull, i.e. no stable build exists for that version. Without it you'd try to download the URLnull.- I filter for
STABLEfrom the builds list instead of trustingbuilds/latest. In my checksbuilds/latestreturned a stable build, but I don't want my update path to depend on that staying true. The filter costs nothing. - Temp file in the same directory, then
mv. Same filesystem meansmvis a rename, so the server never sees a half-written jar. - A marker file with version and build. Re-running the script when nothing changed is a no-op, so you can run it on a schedule without re-downloading 50 MB every time.
Wiring it into systemd
I don't let the updater restart the server by itself. Swapping a jar under a running JVM is fine on Linux (the open file stays valid), and the new build takes effect on the next restart. I run the updater right before the server starts:
# /etc/systemd/system/minecraft.service (excerpt)
[Service]
User=minecraft
WorkingDirectory=/opt/minecraft
ExecStartPre=/usr/local/bin/update-paper.sh paper 1.21.8 /opt/minecraft
ExecStart=/usr/bin/java -Xms4G -Xmx4G -jar paper.jar --nogui
One catch: if the PaperMC API is unreachable, ExecStartPre fails and the server doesn't start. If that's not acceptable for you, prefix it with a dash — ExecStartPre=-/usr/local/bin/update-paper.sh ... — and systemd ignores the failure and starts with the jar you already have. I use the dash. A dead download API should never take my server down. The anatomy of unit files is in systemd service unit files, and if you'd rather update on a schedule than on start, a systemd timer with the same script works fine.
Gotcha: the User-Agent
Send a real User-Agent with a way to contact you. PaperMC asks API users to identify themselves, and a default curl/8.x string is the kind of anonymous traffic that tends to get rate-limited or blocked first when someone hammers an API. -A "my-server-updater/1.0 (admin@example.com)" costs you nothing. Use your own contact, obviously.
Gotcha: "latest" Minecraft is not a version you want
The old URL had versions/latest in it, and I think that was the real design mistake, not just the deprecated endpoint. Auto-jumping Minecraft versions breaks plugins, can change world data, and is generally a one-way trip. Builds within one Minecraft version are safe to auto-update — those are bug fixes and security patches. Versions are a deliberate decision you make after checking your plugins. The script takes the version as an argument for exactly that reason.
For Velocity it's similar: the version list mixes releases and -SNAPSHOT versions. Pin a release version, let the script pick the newest stable build of it.
Gotcha: jq on a minimal box
Minimal server images often don't ship jq. apt install jq and you're done. If you really can't install it, you can scrape the URL with grep -o, but then you're parsing JSON with regex and will eventually get bitten. Install jq.
Velocity works the same way
Same endpoints, different project name:
/usr/local/bin/update-paper.sh velocity <velocity-version> /opt/velocity
The jar ends up as velocity.jar in the target directory. If you're building a proxy from scratch, the rest of the setup is in setting up a Velocity proxy from scratch, and if you're still on BungeeCord or Waterfall, there's a migration guide to Velocity.
Checklist for fixing an old script
- Grep your scripts, Dockerfiles and Ansible roles for
api.papermc.io/v2. Anything withlatestin the path is dead. - Replace with the fill v3 builds endpoint and a pinned version.
- Add
curl -feverywhere — a 404 must be a failure, not a file. - Verify the SHA-256 from the same JSON.
- Atomic
mvfrom a temp file. - Add a User-Agent with contact info.
None of this is hard. The annoying part is that a broken download script often fails silently until the next restart, which is usually the moment you least want surprises. If you're setting up a fresh Paper server, the base install is in Minecraft Java server on Linux, and if you're still deciding between Paper, Purpur and friends, see the server software comparison.