API reference
Automate Cloud Machines with the Farlec HTTP API.
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:
| Resource | Levels | Covers |
|---|---|---|
machines | none, read, write | Machines and their observation; write adds create, power and every change |
templates | none, read, write | The templates you can deploy; write adds creating and editing your own |
billing | none, read | Balance, 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:
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.
{
"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 & path | Capability | Purpose |
|---|---|---|
GET /api/v1/machines | machines:read | List visible machines |
POST /api/v1/machines | machines:write | Create (202) |
GET /api/v1/machines/{id} | machines:read | Machine detail |
POST /api/v1/machines/{id}/start | machines:write | Power on (202) |
POST /api/v1/machines/{id}/stop | machines:write | Power off (202) |
POST /api/v1/machines/{id}/restart | machines:write | Restart (202) |
POST /api/v1/machines/{id}/resize | machines:write | Plan switch — body {"plan_id": "…"} (202, owner only) |
POST /api/v1/machines/{id}/reset-password | machines:write | Write a new root password |
POST /api/v1/machines/{id}/reinstall | machines:write | Wipe + re-provision, identity kept — body {"password": "…", "image_id"|"template_id": "…", "template_variables"?: {…}} (202, owner only) |
POST /api/v1/machines/{id}/rename | machines:write | Body {"label": "…"} — hostname charset, unique per owner (200, owner only) |
GET/POST /api/v1/machines/{id}/peers | machines:read / machines:write | List private-network peers; link with {"peer_machine_id": "…"} (owner only) |
DELETE /api/v1/machines/{id}/peers/{peerMachineId} | machines:write | Unlink two machines (owner only) |
POST /api/v1/machines/{id}/routes | machines:write | Publish a web route with {"port": N} (202) |
DELETE /api/v1/machines/{id}/routes?port=N | machines:write | Unpublish a route (202) |
POST /api/v1/machines/{id}/public-ports | machines:write | Publish a raw forward {"proto": "tcp"|"udp", "port": N} (202, host port auto-allocated) |
DELETE /api/v1/machines/{id}/public-ports?proto=tcp&port=N | machines:write | Unpublish a raw forward (202) |
GET/POST /api/v1/machines/{id}/backups | machines:read / machines:write | List snapshots; start a manual run (202) |
PUT /api/v1/machines/{id}/backups/schedule | machines:write | Set the daily schedule — body {"time": "03:30", "tz": "Europe/Paris"}; empty body clears it |
POST /api/v1/machines/{id}/backups/{backupId}/restore | machines:write | In-place restore (202, owner only) |
GET/POST /api/v1/machines/{id}/power-schedules | machines:read / machines:write | List; create start/stop/restart schedules (owner only) |
PATCH /api/v1/machines/{id}/power-schedules/{scheduleId} | machines:write | Partial update (owner only) |
DELETE /api/v1/machines/{id}/power-schedules/{scheduleId} | machines:write | Delete 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#
{
"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:
{
"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):
| Path | Returns |
|---|---|
GET /api/v1/machines/{id}/obs/stats | Live state/uptime + memory/disk/network counters |
GET /api/v1/machines/{id}/obs/health | Guest agent health snapshot |
GET /api/v1/machines/{id}/obs/metrics | Time series; supports ?since=, ?until=, ?limit= |
GET /api/v1/machines/{id}/obs/console-log | Console tail; ?bytes= (default 32768) |
GET /api/v1/machines/{id}/obs/logs | VMM 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:
{
"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 & path | Capability | Purpose |
|---|---|---|
GET /api/v1/templates | templates:read | The deployable set — official, public and your own |
GET /api/v1/templates/{ref} | templates:read | One template, including its script body |
POST /api/v1/templates | templates:write | Create a private template (201) |
PATCH /api/v1/templates/{ref} | templates:write | Change only the fields you send |
DELETE /api/v1/templates/{ref} | templates:write | Delete one of your templates |
POST /api/v1/templates/{ref}/publish | templates:write | Submit for the community catalog (pending until approved) |
POST /api/v1/templates/{ref}/withdraw | templates:write | Take a pending submission back to private |
PUT /api/v1/templates/{ref}/image | templates:write | Upload the background image as the raw request body |
DELETE /api/v1/templates/{ref}/image | templates:write | Remove 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:
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):
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.pngBilling#
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.
| Path | Returns |
|---|---|
GET /api/v1/billing/balance | ledger_units, pending_units, effective_units, arrears_units |
GET /api/v1/billing/usage | Current-cycle per-machine accruals, burn rate, caps |
GET /api/v1/billing/invoices | Top-up invoices; ?page= + ?limit= (1–100) |
{
"ledger_units": "120000000000",
"pending_units": "0",
"effective_units": "120000000000",
"arrears_units": "0"
}