Canadian KVM VPS in Montréal from 8.50 CAD/month

Driving a VPS by API: create, poll and destroy from your code

A machine ordered by script lives for the length of a job, then disappears. Keys and scopes, a catalogue readable without one, idempotent creation, action polling and a destruction trap: thirty-five lines of script, at 0.018 CAD an hour.

HTTP request ordering a virtual machine, and the machine delivered

A machine ordered by hand sticks around. A machine ordered by script lives for the length of a job: a build, a render, a load test, a migration. It is born when the work arrives and dies with it, and hourly billing is what makes that sensible — an hour of Nano costs 0.018 CAD, two cents for work that would otherwise have tied up a server for a month.

The FFxF API does exactly what the console does, under the same rules: same catalogue, same quota, same credit, same refusals. What the browser will not do, the API will not do either, and for the same stated reason. This guide starts from an empty account and ends on a complete script that orders a machine, works on it and destroys it — including when the work fails.

1. A key, with only the scopes you need

Keys are created in the console, under Account → API keys. A key is shown once: nothing can read it back afterwards, only its prefix stays visible in the list. Each key carries scopes, and the right move is to tick as few as possible — a monitoring key has no business holding vms.destroy. A call outside its scopes answers 403 insufficient_scope, naming the one that was missing.

bash
export FFXF_TOKEN='ffxf_live_…'
export API=https://api.ffxf.net/v1
export AUTH="Authorization: Bearer $FFXF_TOKEN"

curl -s "$API/account" -H "$AUTH" | jq '{balance: .data.credit.balance,
                                         burn:    .data.hourly.burn_rate,
                                         runway:  .data.hourly.runway_hours,
                                         quota:   .data.quota}'

GET /v1/account is the first call a well-behaved script makes: it returns the balance, the current hourly burn, the remaining runway and the quota. Deciding before ordering beats discovering the refusal halfway through a pipeline.

2. The catalogue needs no key

Regions, plans and images are public. Identifiers are stable slugs — nano, debian-13, montreal — not internal numbers that would shift at the next hypervisor rebuild.

bash
curl -s "$API/plans" | jq -r '.data[] |
  "\(.slug)\t\(.vcpu) vCPU  \(.memory_mb/1024) GB  \(.prices[]|select(.currency=="CAD")|.hourly) CAD/h"'
nano     1 vCPU  2 GB   0.018 CAD/h
starter  2 vCPU  4 GB   0.033 CAD/h
pro      4 vCPU  8 GB   0.062 CAD/h
scale    8 vCPU  16 GB  0.116 CAD/h

# Images available for a given plan: not everything fits everywhere.
curl -s "$API/images?plan=nano" | jq -r '.data[].slug' | head

That filter matters: a Windows image needs more disk than a Nano has, and the catalogue says so before the order rather than after. Ordering an incompatible image answers 422 image_incompatible_with_plan.

3. Ordering, without risking a duplicate

POST /v1/vms requires an Idempotency-Key header. That is not a formality: a script that loses its connection mid-response does not know whether the machine exists. With the same key, the second attempt returns the first response — and Idempotency-Replayed: true — instead of creating a second machine. One value per order, not per run: a build number makes an excellent key.

bash
BUILD=4821
curl -s -X POST "$API/vms" -H "$AUTH" \
     -H "Idempotency-Key: build-$BUILD" \
     -H 'Content-Type: application/json' \
     -d '{"plan":"nano","region":"montreal","image":"debian-13",
          "hostname":"runner-'"$BUILD"'","billing":"hourly",
          "ssh_keys":["SHA256:0mR1vP…"],"password_delivery":"none"}'
bash
{ "data": {
    "vm":     { "id": 4312, "hostname": "runner-4821", "status": "provisioning",
                "billing": { "mode": "hourly", "hourly": { "rate": "0.018",
                             "hours_billed": 1, "amount_billed": "0.018" } } },
    "action": { "id": 90112, "type": "create", "status": "running", "vm_id": 4312 },
    "invoice": null } }

The answer is a 202: the machine is ordered, not yet delivered. An hour is paid at its start, so the first one is owed from the order. An hourly order is accepted only when the balance covers the first 24 hours — otherwise 402 insufficient_credit, with the missing amount to the cent.

To check without committing anything, the dry_run field runs every control and returns the price, the credit required and the runway that would be left. Useful in staging, and useful for explaining a refusal to whoever called.

bash
curl -s -X POST "$API/vms" -H "$AUTH" -H "Idempotency-Key: probe-$BUILD" \
     -H 'Content-Type: application/json' \
     -d '{"plan":"nano","region":"montreal","image":"debian-13",
          "hostname":"probe","billing":"hourly","dry_run":true}' \
  | jq '.data | {ok: .would_succeed, needed: .required_credit, after: .runway_hours_after}'

4. Waiting for delivery

Provisioning, rebooting, reinstalling: anything that touches the hypervisor is asynchronous. Those calls return an action object, which you poll until its status leaves queued and then running. No action stays open forever: after six hours without a conclusion it turns to error, and the loop ends.

bash
wait_action() {
  local id=$1 status
  for _ in $(seq 1 120); do
    status=$(curl -s "$API/actions/$id" -H "$AUTH" | jq -r .data.status)
    case "$status" in
      completed) return 0 ;;
      error)     curl -s "$API/actions/$id" -H "$AUTH" | jq -r .data.error.message >&2
                 return 1 ;;
    esac
    sleep 5
  done
  echo "action $id still open after 10 minutes" >&2; return 1
}

Five seconds between polls is plenty: a machine is delivered in about fifteen seconds. The read bucket allows 120 calls a minute, but nothing says you have to spend them.

5. Driving the machine

Power changes all go through one endpoint, with the type in the body. shutdown asks the guest to stop cleanly, stop cuts the power — the second is only justified when the first does not finish.

bash
curl -s -X POST "$API/vms/4312/actions" -H "$AUTH" \
     -H 'Content-Type: application/json' -d '{"type":"reboot"}'

# Reinstall on another image: the disk is destroyed and rebuilt.
curl -s -X POST "$API/vms/4312/actions" -H "$AUTH" \
     -H 'Content-Type: application/json' \
     -d '{"type":"reinstall","image":"ubuntu-24-04",
          "ssh_keys":["SHA256:0mR1vP…"],"password_delivery":"none"}'

# Metrics: a series of points, the vCPU share runs from 0 to 1.
curl -s "$API/vms/4312/metrics?timeframe=hour" -H "$AUTH" \
  | jq -r '.data.series[-1] | "\(.time)  cpu \(.cpu)  mem \(.memory_bytes)"'

curl -s "$API/vms/4312/network" -H "$AUTH" | jq -r '.data.ipv4[].address'

A machine's history is on GET /v1/vms/{id}/actions, and it is complete: a click in the console shows up there too, with source: console. Handy when a colleague rebooted the machine while your script was waiting.

6. Destroying, and counting

Destruction asks for the exact hostname as a confirm parameter. That is deliberate: a numeric id can be off by one digit without anyone noticing, a hostname cannot.

bash
curl -s -X DELETE "$API/vms/4312?confirm=runner-4821" -H "$AUTH"

curl -s "$API/usage?vm_id=4312&group_by=vm" -H "$AUTH" \
  | jq -r '.data[] | "\(.hostname)  \(.hours) h  \(.amount) \(.currency)"'
runner-4821  1 h  0.018 CAD

Every hour started is owed. A machine created at 14:05 and destroyed at 14:40 costs one hour, not thirty-five minutes — the meter follows the minute of creation, not the round hour. GET /v1/usage returns the detail line by line, from the same source as the invoice.

7. The whole thing as a script

A trap on EXIT is what separates an ephemeral runner from a surprise invoice: the machine is destroyed even if the work fails, even if the script is interrupted.

bash
#!/usr/bin/env bash
set -euo pipefail

API=https://api.ffxf.net/v1
AUTH="Authorization: Bearer $FFXF_TOKEN"
NAME="runner-$(date +%s)"
VM=""

destroy() {
  [ -n "$VM" ] || return 0
  echo "destroying $NAME"
  curl -s -X DELETE "$API/vms/$VM?confirm=$NAME" -H "$AUTH" > /dev/null
}
trap destroy EXIT

VM=$(curl -s -X POST "$API/vms" -H "$AUTH" -H "Idempotency-Key: $NAME" \
          -H 'Content-Type: application/json' \
          -d "{\"plan\":\"nano\",\"region\":\"montreal\",\"image\":\"debian-13\",
               \"hostname\":\"$NAME\",\"billing\":\"hourly\",
               \"ssh_keys\":[\"$FFXF_SSH_FINGERPRINT\"],\"password_delivery\":\"none\"}" \
     | jq -r .data.vm.id)

until [ "$(curl -s "$API/vms/$VM" -H "$AUTH" | jq -r .data.status)" = "running" ]; do
  sleep 5
done

IP=$(curl -s "$API/vms/$VM" -H "$AUTH" | jq -r .data.ipv4)
until ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 \
          root@"$IP" true 2>/dev/null; do sleep 3; done

ssh root@"$IP" 'apt-get -qq update && apt-get -qq install -y build-essential'
ssh root@"$IP" 'bash -s' < ./job.sh
scp root@"$IP":/tmp/result.tar.gz ./

Thirty-five lines, a dedicated machine for the length of a job, 0.018 CAD on the meter. The same frame scales to a fleet: loop over the orders, keep the ids, destroy at the end of the batch.

8. The guardrails worth knowing

They exist so that a runaway script costs a refusal rather than an invoice. All of them are readable in the response instead of guessed.

  • Rate: per key, 120 reads a minute, 20 actions a minute, 10 creations or reinstalls an hour. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a refusal returns 429 rate_limited with Retry-After.
  • Quota: five machines per account by default; deleted ones do not count. Beyond that, 409 quota_reached. The ceiling is raised on request.
  • Credit: 24 hours in advance at order time, and a monthly budget cap at 120% of the expected spend.
  • Stable codes: a refusal carries a machine-readable code — insufficient_credit, hostname_taken, out_of_stock — to be handled with a case, not by reading the message.
  • Traceability: every response carries an X-Request-Id. Quoting it in a ticket saves half a day of back-and-forth.

Checklist

  • Key created with only the scopes needed, stored outside the repository.
  • GET /v1/account read before ordering.
  • Idempotency-Key derived from the work, not from the run.
  • Action polled with a time bound, never an endless loop.
  • Destruction trap installed before the creation call.
  • Errors handled by code, not by message.
  • X-Request-Id logged.

The reference for all twenty-eight endpoints, the full image catalogue and the OpenAPI contract are in the API documentation. The contract can be handed straight to a client generator: there is no SDK to wait for.

Support & discussions

Technical questions, incident reports, or infrastructure discussions, the team is reachable on Discord, Telegram, X, and IRC.