# 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.

Source: https://vpsnine.com/tutorials/script-vpsnine-with-the-typescript-sdk · Updated: 2026-10-21

Difficulty: intermediate · Time: about 30 minutes · Tested on: Debian 12 Oldstable

Prerequisites:

- 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](https://vpsnine.com/help/use-the-openapi-spec) and will be published as `@vpsnine/sdk`; until then, the [API reference](https://my.vpsnine.com/api) shows each call in TypeScript for when it is, and the same calls work today with `fetch` as in the [API quickstart](https://vpsnine.com/help/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

```bash
mkdir vps9-scripts && cd vps9-scripts
npm init -y
npm pkg set type=module
npm install @vpsnine/sdk
npm install -D tsx typescript @types/node
```

`tsx` 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:

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

```ts
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:

```ts
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`);
```

```bash
npx tsx inventory.ts
```

```text
worker-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 RAM
```

Every 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:

```ts
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}`);
```

```bash
npx tsx safe-reboot.ts web-1
```

```text
deleted the old snapshot before-reboot-202610212315
snapshot before-reboot-202610212316 started
web-1 is running
```

A 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:

```ts
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}`);
```

```bash
ORDER_KEY=worker-1-2026-10-21 npx tsx order.ts worker-1
```

```text
invoice VPS9-000006: paid
worker-1 is running at 203.0.113.6
```

An 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:

```ts
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)}`);
```

```text
not found (request req_kf7bctzwvvhqpn2xvd7t6mmb)
paid invoices: $1049.95
```

Every 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`, `PUT` and `DELETE` on `429`, `500`, `502`, `503` and `504`, and when the network fails or a try times out.
- `orderServer`, or any `POST` you give an `idempotencyKey`, in the same cases.
- Any other `POST` (a reboot, a snapshot) only on `429`, 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](https://vpsnine.com/help/idempotent-orders-with-idempotency-key)
- [Use the OpenAPI spec](https://vpsnine.com/help/use-the-openapi-spec) to generate a client in another language.
- [Manage VPSNine servers with Ansible](https://vpsnine.com/tutorials/manage-vpsnine-servers-with-ansible), for what happens inside the servers.
