Script your infrastructure with the TypeScript SDK
Use @vpsnine/sdk from TypeScript to list servers, snapshot before a reboot, order a server safely and handle errors, with retries and idempotency keys built in.
Tested on Debian 12 Oldstable
Before you start
- Node.js 20 or later
- A VPSNine API token (read and write for the snapshot and order scripts)
To script VPSNine in TypeScript, create a Vps9 client from @vpsnine/sdk, which reads your token from VPS9_TOKEN, and call one typed method per API endpoint: listServers(), createSnapshot(), serverAction(), orderServer() and so on. The SDK retries what is safe to retry, sends an Idempotency-Key with every order, and throws typed errors that carry the API's request id.
This tutorial writes four small scripts: an inventory, a snapshot-then-reboot with snapshot rotation, a safe order, and error handling with paging.
The SDK isn't on npm yet. It's generated from the OpenAPI spec and will be published as @vpsnine/sdk; until then, the API reference shows each call in TypeScript for when it is, and the same calls work today with fetch as in the API quickstart. We ran every script below with Node.js 22 on Debian 12 against a local copy of the VPSNine API, with the SDK built from its source, and type-checked them with tsc --strict.
Set up a project
mkdir vps9-scripts && cd vps9-scripts
npm init -y
npm pkg set type=module
npm install @vpsnine/sdk
npm install -D tsx typescript @types/nodetsx runs TypeScript files directly. The SDK has no dependencies of its own; it uses the fetch built into Node.js 18 and later (and Deno, Bun and browsers, though the API doesn't allow calls from web pages on other sites).
Load your token into the environment:
export VPS9_TOKEN="$(cat ~/.vps9-token)"Shared helpers
Save this as lib.ts. It makes the client, finds a server by hostname or id, and waits until a server has no action running:
import { Vps9, type Server } from "@vpsnine/sdk";
export const vps9 = new Vps9(); // reads VPS9_TOKEN
/** Finds one of your servers by hostname or id. */
export async function findServer(name: string): Promise<Server> {
const { data } = await vps9.listServers();
const s = data.find((x) => x.id === name || x.hostname === name);
if (!s) throw new Error(`no server called ${name}`);
return s;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Reads the server every 5 seconds until it has no action pending, for up to `minutes`. */
export async function settled(id: string, minutes = 10): Promise<Server> {
const until = Date.now() + minutes * 60_000;
for (;;) {
const s = await vps9.getServer(id);
if (s.pendingAction === null && s.state !== "provisioning") return s;
if (Date.now() > until) throw new Error(`${s.hostname} is still busy (${s.state}, ${s.pendingAction})`);
await sleep(5_000);
}
}The API answers 202 for work that happens on the server and finishes it in the background; pendingAction goes back to null when it's done. Polling every 5 seconds stays well inside the 60 requests a minute each token gets.
An inventory
inventory.ts lists every server and adds up their memory:
import { vps9 } from "./lib.ts";
const { data: servers } = await vps9.listServers();
for (const s of servers) {
console.log([s.hostname.padEnd(24), s.state.padEnd(9), (s.ipv4 ?? "-").padEnd(16), s.plan.padEnd(9), s.tags.join(",")].join(" "));
}
const ram = servers.reduce((sum, s) => sum + s.memoryMiB, 0) / 1024;
console.log(`${servers.length} servers, ${ram} GiB of RAM`);npx tsx inventory.tsworker-1 running 203.0.113.6 budget
preview-7 running 203.0.113.5 budget
db-1 running 203.0.113.3 standard prod,db
web-1 running 203.0.113.2 standard prod,web
4 servers, 12 GiB of RAMEvery field is typed from the spec, so your editor completes s. with hostname, state, ipv4, tags and the rest, and state is a union of the real states.
Snapshot, then reboot
safe-reboot.ts takes a snapshot, checks that it's ready, and only then reboots. A server keeps three snapshots you take yourself, so the script first deletes the oldest one it made (named before-reboot-…) when there's no room, and refuses to touch snapshots you took by hand:
import { findServer, settled, vps9 } from "./lib.ts";
const PREFIX = "before-reboot-";
const name = process.argv[2];
if (!name) throw new Error("usage: safe-reboot.ts <hostname>");
const server = await findServer(name);
// A server keeps 3 snapshots you take yourself. Make room by deleting the oldest one this script took.
const manual = (await vps9.listSnapshots(server.id)).data.filter((x) => !x.automatic);
if (manual.length >= 3) {
const oldest = manual.find((x) => x.label.startsWith(PREFIX));
if (!oldest) throw new Error(`${server.hostname} already has 3 snapshots of its own; delete one first`);
await vps9.deleteSnapshot(server.id, oldest.id);
console.log(`deleted the old snapshot ${oldest.label}`);
await settled(server.id);
}
const label = PREFIX + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
const { snapshot } = await vps9.createSnapshot(server.id, { name: label });
console.log(`snapshot ${label} started`);
await settled(server.id);
const taken = (await vps9.listSnapshots(server.id)).data.find((x) => x.id === snapshot.id);
if (taken?.state !== "ready") throw new Error(`snapshot ended as ${taken?.state ?? "missing"}; not rebooting`);
await vps9.serverAction(server.id, { action: "reboot" });
const after = await settled(server.id);
console.log(`${after.hostname} is ${after.state}`);npx tsx safe-reboot.ts web-1deleted the old snapshot before-reboot-202610212315
snapshot before-reboot-202610212316 started
web-1 is runningA running server shuts down for the snapshot and starts again, then reboots, so expect a minute or two offline.
Order a server safely
order.ts orders a Budget server with every SSH key on your account. orderServer always sends an Idempotency-Key; passing your own from ORDER_KEY means that if the script itself is run again after a crash, it gets the same order back instead of a second server:
import { randomUUID } from "node:crypto";
import { settled, vps9 } from "./lib.ts";
const hostname = process.argv[2] ?? "worker-1";
const keys = await vps9.listSshKeys();
const placed = await vps9.orderServer(
{ plan: "budget", image: "debian-12", hostname, sshKeyIds: keys.data.map((k) => k.id) },
{ idempotencyKey: process.env.ORDER_KEY ?? randomUUID() },
);
console.log(`invoice ${placed.invoice.number}: ${placed.invoice.status}`);
if (placed.invoice.status !== "paid") {
console.log(`pay it at ${placed.payUrl}; the server is built once it is paid`);
process.exit(0);
}
// The order doesn't name its server; find it by hostname, newest first.
let id: string | undefined;
for (let i = 0; i < 24 && !id; i++) {
id = (await vps9.listServers()).data.find((s) => s.hostname === hostname)?.id;
if (!id) await new Promise((r) => setTimeout(r, 5_000));
}
if (!id) throw new Error(`${hostname} hasn't appeared yet; check the panel`);
const s = await settled(id, 15);
console.log(`${s.hostname} is ${s.state} at ${s.ipv4}`);ORDER_KEY=worker-1-2026-10-21 npx tsx order.ts worker-1invoice VPS9-000006: paid
worker-1 is running at 203.0.113.6An order that account credit covers is paid at once and built straight away. Otherwise the script prints the invoice's address and stops; the server is built once it's paid. Orders open at launch: until then the API answers 403 staff_only.
Errors and paging
errors.ts shows the typed errors, then walks every page of invoices:
import { Vps9ApiError, Vps9NotFoundError, Vps9RateLimitError } from "@vpsnine/sdk";
import { vps9 } from "./lib.ts";
try {
await vps9.getServer("srv_doesnotexist");
} catch (err) {
if (err instanceof Vps9NotFoundError) console.log(`not found (request ${err.requestId})`);
else if (err instanceof Vps9RateLimitError) console.log(`slow down: retry in ${err.retryAfter} s`);
else if (err instanceof Vps9ApiError) console.log(`${err.status} ${err.code}: ${err.message} (${err.requestId})`);
else throw err;
}
let total = 0;
for await (const inv of vps9.listInvoicesAll()) {
if (inv.status === "paid") total += inv.subtotalCents;
}
console.log(`paid invoices: $${(total / 100).toFixed(2)}`);not found (request req_kf7bctzwvvhqpn2xvd7t6mmb)
paid invoices: $1049.95Every error answer throws a Vps9ApiError with the API's code, status, message and requestId; subclasses such as Vps9NotFoundError, Vps9ConflictError and Vps9RateLimitError let you catch one kind. No answer at all (a timeout, a dropped connection) throws Vps9ConnectionError. listInvoicesAll() follows the next cursor for you.
What the SDK retries
You don't write retry loops. The SDK tries again, twice by default, when it's safe:
GET,PUTandDELETEon429,500,502,503and504, and when the network fails or a try times out.orderServer, or anyPOSTyou give anidempotencyKey, in the same cases.- Any other
POST(a reboot, a snapshot) only on429, which means nothing happened. After a 5xx it may have happened, so it isn't sent twice.
It waits for Retry-After when the API sends one, or backs off from half a second up to 8 seconds. Change the defaults with new Vps9({ maxRetries: 4, timeoutMs: 10_000 }), or per call with { maxRetries: 0 } as the last argument.
Check it worked
Run the inventory with a read-only token: it should list your servers. Run safe-reboot.ts with that same token and it should stop at the snapshot with Vps9PermissionError and the code insufficient_scope, before it changes anything. With a read-and-write token, the server's Snapshots tab in the panel shows the new before-reboot-… snapshot.
Troubleshooting
Vps9AuthenticationError: Send a valid API token. VPS9_TOKEN is empty in this shell, or the token was revoked. echo ${#VPS9_TOKEN} should print 57.
Vps9ConnectionError or 404 not_found on every call. VPS9_API (or baseUrl) points somewhere else, such as a test copy of the API that isn't running. The SDK accepts the panel's address (https://my.vpsnine.com, the form the vps9 CLI uses) or the full API address (https://my.vpsnine.com/api/v1) and adds /api/v1 when it's missing. Unset VPS9_API to use VPSNine itself.
Vps9ConflictError with limit_reached. The server already has three snapshots you took yourself. Delete one, or let the script's naming take over rotation.
ERR_MODULE_NOT_FOUND for ./lib.ts. Run the scripts with npx tsx, not plain node, and keep "type": "module" in package.json.
Next steps
- Idempotent orders with Idempotency-Key
- Use the OpenAPI spec to generate a client in another language.
- Manage VPSNine servers with Ansible, for what happens inside the servers.