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.

Share
PaperMC Download API v3: Why Your "latest" One-Liner Returns 404

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:

  • id is the build number. Build 60 of Paper 1.21.8 here.
  • channel is STABLE or 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 -f makes 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 -e exits non-zero if the selection is null, i.e. no stable build exists for that version. Without it you'd try to download the URL null.
  • I filter for STABLE from the builds list instead of trusting builds/latest. In my checks builds/latest returned 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 means mv is 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

  1. Grep your scripts, Dockerfiles and Ansible roles for api.papermc.io/v2. Anything with latest in the path is dead.
  2. Replace with the fill v3 builds endpoint and a pinned version.
  3. Add curl -f everywhere — a 404 must be a failure, not a file.
  4. Verify the SHA-256 from the same JSON.
  5. Atomic mv from a temp file.
  6. 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.


Related posts