> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# PaperMC Download API v3: Why Your "latest" One-Liner Returns 404
- URL: https://blog.sudowl.com/papermc-download-api-v3/
- Published: 2026-07-23T10:00:00.000Z
- Updated: 2026-10-11T10:16:27.000Z
- Description: 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.
- Author: Lukas
- Tags: Minecraft, Gameserver, Linux

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:

```bash
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

```bash
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):

```json
{
  "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:

```bash
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.

```bash
#!/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:

```ini
# /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](/systemd-service-unit-file/), and if you'd rather update on a schedule than on start, a [systemd timer](/systemd-timers-instead-of-cron/) 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:

```bash
/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](/velocity-minecraft-proxy-setup-from-scratch/), and if you're still on BungeeCord or Waterfall, there's a [migration guide to Velocity](/migrate-bungeecord-waterfall-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](/minecraft-java-server-linux/), and if you're still deciding between Paper, Purpur and friends, see the [server software comparison](/minecraft-server-software-comparison/).

---

### Related posts

- [Migrating from BungeeCord/Waterfall to Velocity — A Practical Walkthrough](https://blog.diengdoh.com/migrate-bungeecord-waterfall-to-velocity/?ref=blog.sudowl.com)
- [Hosting a Forge / NeoForge Modpack Server on Linux — The Pragmatic Guide](https://blog.diengdoh.com/forge-neoforge-modpack-server-linux/?ref=blog.sudowl.com)
- [LuckPerms on BungeeCord and Velocity — Permissions That Actually Scale](https://blog.diengdoh.com/luckperms-bungeecord-velocity-setup/?ref=blog.sudowl.com)