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.

Tested on Ubuntu 24.04 LTS, Debian 12 Oldstable

Before you start

  • 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): 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 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 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

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee