Skip to content
Farlec docs

API reference

Automate Cloud Machines with the Farlec HTTP API.

7 min read Updated

Authentication#

Create tokens under Developer → API Tokens in the dashboard — that is the only place tokens are issued. A token has a name, a set of permissions, an optional machine restriction, an optional expiry and an optional source-IP allowlist. Permissions pick an access level per resource — write includes read:

ResourceLevelsCovers
machinesnone, read, writeMachines and their observation; write adds create, power and every change
templatesnone, read, writeThe templates you can deploy; write adds creating and editing your own
billingnone, readBalance, usage, invoices

The catalog (plans, images, regions) is readable by every token. Grant only what the job needs: a CI deploy job wants machines: write, a bookkeeping export wants billing: read alone.

The secret is shown once and looks like farlec_… — store it immediately. Rotate re-issues the secret (the old one stops working immediately; name, permissions, restrictions and expiry survive), revoke when done. An expired token fails authentication exactly like a revoked one.

Send the token as a bearer credential:

bash
curl https://farlec.com/api/v1/machines \
  -H "Authorization: Bearer farlec_YOUR_TOKEN"

A token acts as you, with never more rights than the dashboard gives: machine-restricted tokens see only their machines, a call outside the token's permissions answers 403 insufficient_scope, and IP-pinned tokens only work from their allowed addresses. Tokens restricted to specific machines cannot create machines at all.

Rate limits#

240 requests per minute per token. Beyond it the API answers 429 with a Retry-After header — back off and retry.

Errors#

Every error is a JSON envelope with a stable machine-readable code — and so is every other /api/* response: unknown paths and wrong verbs answer the same envelope (not_found, method_not_allowed) instead of an HTML page.

json
{
	"error": {
		"code": "insufficient_scope",
		"message": "this token cannot perform this operation"
	}
}

Known codes: unauthorized, insufficient_scope, token_ip_restricted, machine_restricted, not_found, forbidden, invalid_input, invalid_state, region_unavailable, label_taken, name_taken, limit_reached, syntax_invalid, image_unknown, unsupported_type, too_large, machine_not_found, peer_already_linked, peer_limit_reached, peer_not_linked, node_full, schedule_conflict, schedule_limit, wallet_empty, method_not_allowed, internal. Mutations are asynchronous — accepted requests answer 202 with the machine JSON, and the change lands as the operation queue works through it.

Machines#

The Capability column names what the endpoint needs — machines:write needs the machines permission at write, and so on (table above).

Method & pathCapabilityPurpose
GET /api/v1/machinesmachines:readList visible machines
POST /api/v1/machinesmachines:writeCreate (202)
GET /api/v1/machines/{id}machines:readMachine detail
POST /api/v1/machines/{id}/startmachines:writePower on (202)
POST /api/v1/machines/{id}/stopmachines:writePower off (202)
POST /api/v1/machines/{id}/restartmachines:writeRestart (202)
POST /api/v1/machines/{id}/resizemachines:writePlan switch — body {"plan_id": "…"} (202, owner only)
POST /api/v1/machines/{id}/reset-passwordmachines:writeWrite a new root password
POST /api/v1/machines/{id}/reinstallmachines:writeWipe + re-provision, identity kept — body {"password": "…", "image_id"|"template_id": "…", "template_variables"?: {…}} (202, owner only)
POST /api/v1/machines/{id}/renamemachines:writeBody {"label": "…"} — hostname charset, unique per owner (200, owner only)
GET/POST /api/v1/machines/{id}/peersmachines:read / machines:writeList private-network peers; link with {"peer_machine_id": "…"} (owner only)
DELETE /api/v1/machines/{id}/peers/{peerMachineId}machines:writeUnlink two machines (owner only)
POST /api/v1/machines/{id}/routesmachines:writePublish a web route with {"port": N} (202)
DELETE /api/v1/machines/{id}/routes?port=Nmachines:writeUnpublish a route (202)
POST /api/v1/machines/{id}/public-portsmachines:writePublish a raw forward {"proto": "tcp"|"udp", "port": N} (202, host port auto-allocated)
DELETE /api/v1/machines/{id}/public-ports?proto=tcp&port=Nmachines:writeUnpublish a raw forward (202)
GET/POST /api/v1/machines/{id}/backupsmachines:read / machines:writeList snapshots; start a manual run (202)
PUT /api/v1/machines/{id}/backups/schedulemachines:writeSet the daily schedule — body {"time": "03:30", "tz": "Europe/Paris"}; empty body clears it
POST /api/v1/machines/{id}/backups/{backupId}/restoremachines:writeIn-place restore (202, owner only)
GET/POST /api/v1/machines/{id}/power-schedulesmachines:read / machines:writeList; create start/stop/restart schedules (owner only)
PATCH /api/v1/machines/{id}/power-schedules/{scheduleId}machines:writePartial update (owner only)
DELETE /api/v1/machines/{id}/power-schedules/{scheduleId}machines:writeDelete a schedule (owner only)

Power schedules take {action, time, tz, days, enabled?} — action is start/stop/restart, time a local HH:MM, tz an IANA zone, days weekday indexes 0–6 (Monday first). At most 3 schedules per machine, and only one action per machine-minute (schedule_conflict otherwise). Peers must be your own live machines (same owner — the platform supplies the addresses); a link is symmetric, joins exactly two machines (links don't chain) and works cross-region over the private fabric. Reinstall keeps the machine's identity (UUID, IPs, routes, ports, backups) and boots fresh from the chosen image or template — see the machines doc; free machines stay pinned to alpine.

Machine JSON is grouped by concern — identity, plan, image, template (with its setup progress), and one network block: private_v4, public_v6, ssh {host, port} (connect with your own key: ssh root@<host> -p <port>), web_routes (upstream port paired with its minted host) and public_ports. Platform internals never appear. The network block is null for a deleted machine — its addresses, ports and routes are released back to the pools at delete and must never be treated as connection info. Backup rows carry id, created_at, kind, status, size_bytes, used_bytes.

Create body#

json
{
	"label": "web-1",
	"plan_id": "…",
	"image_id": "…",
	"region": "ca",
	"app_ports": [3000],
	"password": "…",
	"ssh_pubkey": "ssh-ed25519 AAAA…",
	"template_id": "…",
	"template_variables": { "listen_port": "3000" }
}

Only label is loosely required by good sense — the platform validates plan, image and region against the catalog. template_id accepts the template's 5-digit code or its id. When template_id is set, image_id must be the template's pinned OS (a mismatch is rejected, never overridden). password and ssh_pubkey are create-time only and never stored. Template deploys also inject the fixed variables into the script (FC_PRIVATE_IPV4, FC_PUBLIC_IPV6, FC_HOSTNAME, FC_UUID, FC_REGION, FC_WEB_ROUTES, FC_ROUTE_<PORT>) — see the templates doc; declared variables cannot use those names. A template that declares public ports opens them on the machine's shared public IPv4 at create (pool-allocated host ports; they ride the machine JSON as public_ports). The response includes the machine with its plan snapshot:

json
{
	"machine": {
		"id": "…",
		"label": "web-1",
		"region": "ca",
		"state": "provisioning",
		"viewer_role": "owner",
		"plan": { "name": "Nano", "is_free": false, "cpus": 0.5, "mem_mb": 512 },
		"image": "alpine",
		"template": null,
		"network": null,
		"created_at": "2026-09-15T12:00:00.000Z"
	}
}

Observation#

Read-only passthroughs to the hypervisor's metrics (the obs:read capability — any token with machines read or write carries it): the customer-relevant subset of what the dashboard polls (no host process internals, no internal ids):

PathReturns
GET /api/v1/machines/{id}/obs/statsLive state/uptime + memory/disk/network counters
GET /api/v1/machines/{id}/obs/healthGuest agent health snapshot
GET /api/v1/machines/{id}/obs/metricsTime series; supports ?since=, ?until=, ?limit=
GET /api/v1/machines/{id}/obs/console-logConsole tail; ?bytes= (default 32768)
GET /api/v1/machines/{id}/obs/logsVMM journal tail (host-side unit output, not the guest journal); ?tail= (1–10000, default 200), ?since= (journalctl syntax, e.g. -10m)

Catalog#

GET /api/v1/catalog (catalog:read) — the deploy page's sources, so plan_id, image_id and region are discoverable with a token alone:

json
{
	"plans": [
		{
			"id": "…",
			"name": "Nano",
			"cpus": 0.5,
			"mem_mb": 512,
			"disk_mb": 5120,
			"bw_limit_mbps": 25,
			"is_free": false,
			"rate_per_min": "344"
		}
	],
	"images": [{ "id": "…", "name": "Alpine", "slug": "alpine", "description": "…" }],
	"regions": [{ "id": "ca", "label": "Canada", "roles": ["paid", "free"] }]
}

rate_per_min is an integer unit string (see Billing). Free-tier deploys need a region whose roles include free; rancherd stays the enforcer of image-region fit.

Templates#

Method & pathCapabilityPurpose
GET /api/v1/templatestemplates:readThe deployable set — official, public and your own
GET /api/v1/templates/{ref}templates:readOne template, including its script body
POST /api/v1/templatestemplates:writeCreate a private template (201)
PATCH /api/v1/templates/{ref}templates:writeChange only the fields you send
DELETE /api/v1/templates/{ref}templates:writeDelete one of your templates
POST /api/v1/templates/{ref}/publishtemplates:writeSubmit for the community catalog (pending until approved)
POST /api/v1/templates/{ref}/withdrawtemplates:writeTake a pending submission back to private
PUT /api/v1/templates/{ref}/imagetemplates:writeUpload the background image as the raw request body
DELETE /api/v1/templates/{ref}/imagetemplates:writeRemove the background image

{ref} is the template's 5-digit code or its id — the same handle template_id accepts when you deploy. Every row carries id, code, name, summary, description, is_official, visibility, image_slug (the pinned OS), min_cpus/min_mem_mb/min_disk_mb floors, tags, declared ports, the variables spec, image_url, created_at and updated_at; the detail and write responses add body. The list is unpaged, like the deploy wizard's.

You can only change your own templates — official and other people's rows answer not_found. Bodies use the same snake_case fields as the response:

bash
curl https://farlec.com/api/v1/templates \
  -H "Authorization: Bearer farlec_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "redis",
    "summary": "Redis on Alpine",
    "image_slug": "alpine",
    "tags": ["database"],
    "min_mem_mb": 512,
    "ports": [{ "proto": "tcp", "port": 6379 }],
    "variables": [
      { "key": "redis_password", "label": "Password", "type": "password", "required": true }
    ],
    "body": "#!/bin/sh\napk add redis\necho \"requirepass $FC_REDIS_PASSWORD\" >> /etc/redis.conf\nrc-update add redis && rc-service redis start\n"
  }'

name, body, image_slug and 1–3 tags are required on create; the rest is optional. Scripts are checked with sh -n on every save (syntax_invalid carries the shell's message), and the template limits from the templates doc apply (limit_reached at your cap). PATCH takes any subset of the same fields plus "image": null to drop the background image; unknown fields are rejected. Editing never resets review — a public template stays public.

Upload the background image as the raw body with its type in Content-Type (PNG, JPEG, WebP or GIF, up to 5 MB, 10 uploads an hour):

bash
curl -X PUT https://farlec.com/api/v1/templates/04217/image \
  -H "Authorization: Bearer farlec_YOUR_TOKEN" \
  -H "Content-Type: image/png" \
  --data-binary @banner.png

Billing#

Billing reads need the billing:read capability (the billing permission at read). Money figures are integer unit strings — 1 unit = $1e-10, 1 credit = 10,000,000,000 units. Never parse them as floats.

PathReturns
GET /api/v1/billing/balanceledger_units, pending_units, effective_units, arrears_units
GET /api/v1/billing/usageCurrent-cycle per-machine accruals, burn rate, caps
GET /api/v1/billing/invoicesTop-up invoices; ?page= + ?limit= (1–100)
json
{
	"ledger_units": "120000000000",
	"pending_units": "0",
	"effective_units": "120000000000",
	"arrears_units": "0"
}