# Use curl and jq with the VPSNine API

> Drive the VPSNine API from a shell with curl and jq: filter servers, take snapshots and wait for them, read errors, page invoices, edit the firewall.

Source: https://vpsnine.com/tutorials/use-curl-and-jq-with-the-vpsnine-api · Updated: 2026-10-21

Difficulty: beginner · Time: about 20 minutes · Tested on: Ubuntu 24.04 LTS, Debian 12 Oldstable

Prerequisites:

- An API token from the VPSNine panel
- curl 7.76 or later and jq 1.6 or later

To use the VPSNine API from a shell, send `curl` requests with an `Authorization: Bearer` header to `https://my.vpsnine.com/api/v1` and pipe the JSON answers through `jq`. This tutorial builds a two-line helper, then uses it to list and filter servers, take a snapshot and wait for it, handle errors, follow pages of invoices and change the firewall without overwriting someone else's edit.

Every command here was run against a local copy of the VPSNine API on Ubuntu 24.04 (curl 8.5, jq 1.7) and Debian 12 (curl 7.88, jq 1.6). Outputs are from that run, with documentation addresses.

## Install curl and jq

On Ubuntu and Debian:

```bash
sudo apt update
sudo apt install -y curl jq
```

On AlmaLinux, Rocky Linux and Fedora, `sudo dnf install -y curl jq`. On macOS, `brew install jq` (curl is already there). Check the versions: the helper below needs curl 7.76 or later for `--fail-with-body`.

```bash
curl --version | head -1
jq --version
```

## Keep the token out of your history

Make a token under **tokens** in the panel ([how](https://vpsnine.com/help/api-tokens)): read only is enough until the snapshot step. Save it in a file only you can read, paste the token in with the editor, and load it from there:

```bash
install -m 600 /dev/null ~/.vps9-token
nano ~/.vps9-token
export VPS9_TOKEN="$(cat ~/.vps9-token)"
export API=https://my.vpsnine.com/api/v1
```

## A helper for every call

Put this in your shell, or at the top of a script:

```bash
api() {
  local path=$1; shift
  curl -sS --fail-with-body -H "Authorization: Bearer $VPS9_TOKEN" "$@" "$API$path"
}
```

`api /me` calls `GET /me`. Anything after the path goes to curl, so `api /servers/srv_…/actions -X POST …` works too. `--fail-with-body` makes curl exit non-zero on a 4xx or 5xx while still printing the API's JSON error, so `set -e` scripts stop where something went wrong.

Check it:

```bash
api /me | jq .
```

```json
{
  "id": "usr_t6phiz3jiexcvtzn",
  "email": "you@example.com",
  "emailVerified": true,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "account": {
    "id": "usr_t6phiz3jiexcvtzn",
    "name": "you@example.com",
    "role": "owner"
  }
}
```

## List and filter servers

One line per server, tab-separated, with `@tsv`:

```bash
api /servers | jq -r '.data[] | [.hostname, .state, .ipv4, (.tags | join(","))] | @tsv'
```

```text
staging	running	203.0.113.4	staging,web
db-1	running	203.0.113.3	prod,db
web-1	running	203.0.113.2	prod,web
```

Only the servers tagged `prod` (set tags in the panel, or with `vps9 servers label`):

```bash
api /servers | jq -r '.data[] | select(.tags | index("prod")) | .hostname'
```

Most endpoints want a server's id, not its name. Look it up once:

```bash
SERVER_ID="$(api /servers | jq -r --arg h web-1 '.data[] | select(.hostname == $h) | .id')"
echo "$SERVER_ID"
```

`--arg` passes the hostname into jq as a string, so a name never needs quoting inside the filter. Then pick fields from one server, including its latest events:

```bash
api "/servers/$SERVER_ID" | jq '{hostname, state, ipv4, events: [.events[:3][] | "\(.at) \(.action)"]}'
```

## Send a body with jq

Let `jq -n` build request bodies: it quotes everything correctly, whatever is in your variables. This takes a snapshot named after the time (it needs a read-and-write token):

```bash
jq -n --arg name "before-deploy-$(date -u +%Y%m%d-%H%M)" '{name: $name}' |
  api "/servers/$SERVER_ID/snapshots" -X POST -H "Content-Type: application/json" --data @- | jq '.snapshot'
```

```json
{
  "id": "snap_u3fvfxqp3dljr2u6",
  "label": "before-deploy-20261021-2313",
  "state": "creating",
  "createdAt": "2026-10-21T23:13:00.673Z",
  "automatic": false
}
```

Always send `-H "Content-Type: application/json"` with a body. Without it, `curl --data` sends a form, and the API refuses it with `415 unsupported_media_type`.

## Wait for an action to finish

A snapshot, a reboot or a firewall change answers `202` straight away and finishes in the background. The server's `pendingAction` goes back to `null` when it's done, so poll every few seconds:

```bash
for _ in $(seq 1 60); do
  [ "$(api "/servers/$SERVER_ID" | jq -r '.pendingAction')" = "null" ] && break
  sleep 5
done
api "/servers/$SERVER_ID/snapshots" | jq -r '.data[] | [.id, .label, .state] | @tsv'
```

```text
snap_u3fvfxqp3dljr2u6	before-deploy-20261021-2313	ready
```

The loop gives up after 5 minutes rather than spinning forever if something goes wrong. Every 5 seconds is 12 requests a minute, well under the 60 a token gets. A server keeps 3 snapshots you take yourself; a fourth answers `422 limit_reached` until you delete one.

## Read errors

On an error the helper exits non-zero and prints the JSON error, which has a stable `code`, a sentence for people and the `requestId` to quote to support:

```bash
if ! out="$(api /servers/srv_doesnotexist 2>/dev/null)"; then
  echo "failed: $(jq -r '.error.code + ": " + .error.message + " (" + .error.requestId + ")"' <<<"$out")"
fi
```

```text
failed: not_found: No such server. (req_yht3m4zxj2d3qxjf227vqzyt)
```

The `2>/dev/null` hides curl's own one-line message; the API's JSON is still captured. [Rate limits and request ids](https://vpsnine.com/help/api-rate-limits-and-request-ids) has a version of `api` that waits and retries on `429`.

## Look at the headers

`-D -` prints the answer's headers. Each one carries the request id and where you stand against the rate limit:

```bash
curl -sS -o /dev/null -D - -H "Authorization: Bearer $VPS9_TOKEN" "$API/me" | grep -i '^x-'
```

```text
X-Content-Type-Options: nosniff
X-Robots-Tag: noindex, nofollow
X-Request-Id: req_a3o27yn6jdcya34oexlef5bg
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 56
X-RateLimit-Reset: 60
```

Prices need no token at all:

```bash
curl -sS "$API/prices" | jq -r '.plans[] | "\(.name)\t$\(.monthlyCents / 100) a month"'
```

## Follow pages of invoices

`GET /invoices` answers a page at a time, with `next` set until the last page. Pass it back as `before=`:

```bash
next=""
while :; do
  page="$(api "/invoices?limit=50${next:+&before=$next}")"
  jq -r '.data[] | [.number, .status, (.totalCents / 100)] | @tsv' <<<"$page"
  next="$(jq -r '.next // empty' <<<"$page")"
  [ -n "$next" ] || break
done
```

Amounts are integer cents. `subtotalCents` is the price; `totalCents` is what was left to pay after account credit. What all your paid invoices came to:

```bash
api "/invoices?limit=200" | jq '[.data[] | select(.status == "paid") | .subtotalCents] | add / 100'
```

## Change the firewall safely

`PUT /servers/{id}/firewall` replaces the whole rule list, so read it, change it, and send it back with the `ETag` you read as `If-Match`. If anyone saved in between, you get `412 firewall_changed` instead of silently overwriting their rules:

```bash
api "/servers/$SERVER_ID/firewall" -D /tmp/fw.headers | jq '{policy, rules}'
ETAG="$(grep -i '^etag:' /tmp/fw.headers | cut -d' ' -f2 | tr -d '\r')"

api "/servers/$SERVER_ID/firewall" |
  jq 'if any(.rules[]; .ports == "443") then . else .rules += [{action: "allow", protocol: "tcp", ports: "443", source: null}] end | {policy, rules}' |
  api "/servers/$SERVER_ID/firewall" -X PUT -H "Content-Type: application/json" -H "If-Match: $ETAG" --data @- |
  jq '{status, rules}'
```

The `if any(...)` keeps a second run from adding the same rule twice. The answer's `status` is `applying`, then `applied` a few seconds later. The [panel firewall guide](https://vpsnine.com/help/server-firewall) explains policies and rules.

## Act on several servers

Reboot every server tagged `staging`, one request each:

```bash
for id in $(api /servers | jq -r '.data[] | select(.tags | index("staging")) | .id'); do
  api "/servers/$id/actions" -X POST -H "Content-Type: application/json" -d '{"action":"reboot"}' |
    jq -r '"\(.hostname): \(.pendingAction)"'
done
```

```text
staging: reboot
```

A server runs one action at a time; a second one while it's busy answers `409 action_pending`.

## Troubleshooting

**`curl: option --fail-with-body: is unknown`.** Your curl is older than 7.76 (Ubuntu 20.04, Debian 11). Use `--fail` instead: you lose the JSON error on failures, but the exit code still works.

**`{"error":{"code":"unsupported_media_type",…}}`.** A request with a body is missing `-H "Content-Type: application/json"`.

**`jq: error … Cannot iterate over null`.** The call failed and returned an error object instead of `data`. Run the `api` call alone to see the error.

**`412 firewall_changed`.** Someone saved the firewall after you read it. Run the read, change and save again.

## Next steps

- [Deploy on push with GitHub Actions](https://vpsnine.com/tutorials/deploy-on-push-github-actions-vpsnine-api) uses this helper to snapshot before each deploy.
- The [vps9 CLI cheat sheet](https://vpsnine.com/help/vps9-cli-cheat-sheet), when you'd rather not write the requests yourself.
- The [API reference](https://my.vpsnine.com/api) lists every endpoint with its curl example.
