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:
sudo apt update
sudo apt install -y curl jqOn 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.
curl --version | head -1
jq --versionKeep 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:
install -m 600 /dev/null ~/.vps9-token
nano ~/.vps9-token
export VPS9_TOKEN="$(cat ~/.vps9-token)"
export API=https://my.vpsnine.com/api/v1A helper for every call
Put this in your shell, or at the top of a script:
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:
api /me | jq .{
"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:
api /servers | jq -r '.data[] | [.hostname, .state, .ipv4, (.tags | join(","))] | @tsv'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,webOnly the servers tagged prod (set tags in the panel, or with vps9 servers label):
api /servers | jq -r '.data[] | select(.tags | index("prod")) | .hostname'Most endpoints want a server's id, not its name. Look it up once:
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:
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):
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'{
"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:
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'snap_u3fvfxqp3dljr2u6 before-deploy-20261021-2313 readyThe 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:
if ! out="$(api /servers/srv_doesnotexist 2>/dev/null)"; then
echo "failed: $(jq -r '.error.code + ": " + .error.message + " (" + .error.requestId + ")"' <<<"$out")"
fifailed: 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:
curl -sS -o /dev/null -D - -H "Authorization: Bearer $VPS9_TOKEN" "$API/me" | grep -i '^x-'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: 60Prices need no token at all:
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=:
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
doneAmounts are integer cents. subtotalCents is the price; totalCents is what was left to pay after account credit. What all your paid invoices came to:
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:
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:
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)"'
donestaging: rebootA 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 uses this helper to snapshot before each deploy.
- The vps9 CLI cheat sheet, when you'd rather not write the requests yourself.
- The API reference lists every endpoint with its curl example.