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.
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.
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.
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"}'
{ "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.
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.
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.
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.
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.
#!/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-LimitandX-RateLimit-Remaining; a refusal returns429 rate_limitedwithRetry-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 acase, 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/accountread before ordering.Idempotency-Keyderived from the work, not from the run.- Action polled with a time bound, never an endless loop.
- Destruction
trapinstalled before the creation call. - Errors handled by code, not by message.
X-Request-Idlogged.
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.