Skip to content

Using the API

The FreeSDN REST API lets you build integrations, automation scripts, and operator tooling against the same surface the web UI uses. This page walks you through the full practical loop: authenticate, read data, stage a network change, review it, and push it to a device.

All API paths are rooted at /api/v1. The Caddy edge proxies the full path verbatim - do not strip or add the prefix.

EnvironmentBase URL
Production / staginghttps://freesdn.example.com/api/v1
Dev tier (local)http://localhost:8000/api/v1
Dev tier (frontend HMR)http://localhost:5173 proxied by Vite to the above

Trailing slashes are optional. A middleware normaliser rewrites the path server-side so both /sites and /sites/ route identically - no 307 redirect is issued.


There are two credential channels. Use one per request, never both on the same request.

ChannelHeaderWhen to use
Bearer JWTAuthorization: Bearer <token>Scripts, server-side integrations, API clients
API keyX-API-Key: <full-secret>Long-lived automation; key is scoped at creation time

The web UI uses httpOnly cookies set automatically at login. Cookie-based callers additionally need a CSRF token - see Cookie calls and CSRF below.

See API Keys for how to create and scope a key, and Authentication for the full token lifecycle.


This section walks through the minimal end-to-end script flow for a Bearer-token caller.

Step 1 - log in and capture the access token

Section titled “Step 1 - log in and capture the access token”
Terminal window
RESPONSE=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"login": "alice@example.com", "password": "CorrectHorse99!"}')
TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
REFRESH=$(echo "$RESPONSE" | jq -r '.refresh_token')

If the account has MFA enabled, the response contains "require_mfa": true and an mfa_token instead of an access token. You must complete the MFA step before continuing:

Terminal window
# Only required when require_mfa is true
TOKEN=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/login/mfa \
-H "Content-Type: application/json" \
-d "{\"mfa_token\": \"$MFA_TOKEN\", \"code\": \"123456\"}" \
| jq -r '.access_token')
Terminal window
curl -s https://freesdn.example.com/api/v1/auth/me \
-H "Authorization: Bearer $TOKEN" | jq '{role, permissions}'

The response includes your role and the effective permission set. Check this before writing automation that assumes a specific permission. To see which sites you have access to, query GET /api/v1/sites/.

Terminal window
curl -s "https://freesdn.example.com/api/v1/sites/" \
-H "Authorization: Bearer $TOKEN" | jq '.items[] | {id, name, slug}'

Save a site UUID for the next steps:

Terminal window
SITE_ID=$(curl -s "https://freesdn.example.com/api/v1/sites/" \
-H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')
Terminal window
curl -s "https://freesdn.example.com/api/v1/devices/?site_id=$SITE_ID" \
-H "Authorization: Bearer $TOKEN" | jq '.items[] | {id, name, status}'

Without site_id, you receive all devices in your organization (subject to your site access grants). See Pagination and Filtering for full query parameter reference.

Step 5 - refresh the access token when it expires

Section titled “Step 5 - refresh the access token when it expires”

Access tokens expire after 30 minutes. Use the refresh token to rotate without re-entering credentials:

Terminal window
NEW=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH\"}")
TOKEN=$(echo "$NEW" | jq -r '.access_token')
REFRESH=$(echo "$NEW" | jq -r '.refresh_token')

Refresh tokens are single-use - the old one is invalidated the moment you call /auth/refresh. Store the new refresh_token immediately or you will need to log in again.


This table covers the practical surface for day-to-day API use. The full spec is at /api/v1/openapi.json.

MethodPathPurposeMin permission
POST/auth/loginJSON login; returns tokens or MFA challengepublic
POST/auth/login/mfaComplete MFA step; returns tokenspublic
POST/auth/refreshRotate refresh → new token pairvalid refresh token
GET/auth/meCurrent user profile + permissionsauthenticated
POST/auth/logoutRevoke current sessionauthenticated
GET/sites/List sites (org-scoped)authenticated
GET/devices/List devices (?site_id=)device:read
GET/discovery/scans/historyScan historydiscovery:run
POST/discovery/scanStart a discovery scandiscovery:run
GET/api-keys/List your API keysauthenticated
POST/api-keys/Create an API keyauthenticated
GET/healthContainer health probe (no auth)-

Module-specific endpoints (switches, access points, cameras, VoIP, firewall, hypervisor) live under /api/v1/{module-prefix}/. See the relevant module documentation for per-module endpoint tables.


Most collection endpoints accept ?site_id=<UUID>. Supplying it limits the response to resources in that site. If you do not supply it you receive everything in your organization that you are permitted to see.

Terminal window
# Devices in a specific site
curl -s "https://freesdn.example.com/api/v1/devices/?site_id=$SITE_ID" \
-H "Authorization: Bearer $TOKEN"
# Switches in a specific site
curl -s "https://freesdn.example.com/api/v1/switches/?site_id=$SITE_ID" \
-H "Authorization: Bearer $TOKEN"

If you are building a browser-based client that sends requests with credentials: 'include' (so the httpOnly freesdn_access cookie is sent automatically), you must also supply a CSRF token on every non-safe request (POST, PUT, PATCH, DELETE).

FreeSDN uses a double-submit cookie pattern:

  1. After login the server sets freesdn_csrf as a readable (non-httpOnly) cookie at path /.
  2. Your JavaScript reads it and echoes it in the X-CSRF-Token header on every mutation.
// Read the CSRF token once after login
function getCsrf() {
return document.cookie
.split('; ')
.find(c => c.startsWith('freesdn_csrf='))
?.split('=')[1] ?? '';
}
// Use it on every mutating request
async function patchSite(siteId, body) {
return fetch(`/api/v1/sites/${siteId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrf(),
},
body: JSON.stringify(body),
});
}

Mutating requests that carry both a freesdn_access cookie and a Bearer token or X-API-Key header still require the CSRF token. The bypass for API-key and Bearer-only callers only applies when the cookie is absent.

The following paths are exempt from CSRF enforcement (they are public flows that cannot carry a pre-login cookie): /auth/login, /auth/register, /auth/token, /auth/refresh, /auth/login/mfa, /auth/password/reset-request, /auth/password/reset, and the OIDC, SAML, and LDAP SSO callbacks.


Write operations for network devices do not touch the live device immediately. Every mutation goes through a two-step pipeline:

  1. Stage - post the change; it is written to the database as a pending record.
  2. Apply - explicitly push the pending record to the device after operator review.

This is the default for all adapter write surfaces. The ADAPTER_READ_ONLY env var (and the legacy OMADA_READ_ONLY) both default to true, so a deployment that has not explicitly opted into writes will stage every change and never push it.

The URL pattern varies by vendor family. Two forms exist:

Omada-family adapters (gateway-vpn, gateway-firewall, gateway-bulk, gateway-firmware, gateway-profiles, gateway-routing) include a site segment because Omada is inherently site-scoped:

POST /api/v1/gateway-<area>/{controller_id}/sites/{site_id}/changes/{feature}?operation=create|update|delete

All other vendor adapters (MikroTik, OPNsense, pfSense, OpenWrt, UniFi) are controller-scoped and omit the site segment:

POST /api/v1/gateway-<vendor>-<area>/{controller_id}/changes/{feature}?operation=create|update|delete

Check the per-vendor endpoint tables in the relevant module documentation for the exact prefix to use.

Example - stage a WireGuard listen-port update on an OPNsense controller:

Terminal window
CHANGE=$(curl -s -X POST \
"https://freesdn.example.com/api/v1/gateway-opnsense-vpn/$CONTROLLER_ID/changes/opnsense.vpn.wireguard?operation=update" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"payload": {"listen_port": 51820},
"notes": "Standardise WireGuard port across all sites"
}')
CHANGE_ID=$(echo "$CHANGE" | jq -r '.id')
echo "Staged change $CHANGE_ID"

A successful stage returns HTTP 201 with the PendingChangeResponse object. The live device is untouched.

List all pending changes for a controller + site:

Terminal window
curl -s "https://freesdn.example.com/api/v1/gateway-vpn/$CONTROLLER_ID/sites/$SITE_ID/changes?status=pending" \
-H "Authorization: Bearer $TOKEN" | jq '.[] | {id, feature, operation, created_at, notes}'

Or fetch all pending changes for a specific gateway device in one query (useful for the UI pending-changes drawer):

Terminal window
curl -s "https://freesdn.example.com/api/v1/gateway-vpn/changes/by-gateway/$GATEWAY_ID?vendor=opnsense&status=pending" \
-H "Authorization: Bearer $TOKEN"

Supported vendor values: mikrotik, pfsense, opnsense, openwrt, unifi.

Step 3 - apply the change to the live device

Section titled “Step 3 - apply the change to the live device”

Two conditions must both be true:

  1. The server must be running with ADAPTER_READ_ONLY=false (set in your .env file).
  2. The request body must contain "force": true.
Terminal window
curl -s -X POST \
"https://freesdn.example.com/api/v1/gateway-vpn/changes/$CHANGE_ID/apply" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"force": true}'

If ADAPTER_READ_ONLY is still true, the endpoint returns an error - it will not silently do nothing.

The apply endpoint resolves the required permission dynamically from the change’s feature field:

Feature prefixRequired permissionNotes
vpn.*vpn:write-
firewall.*, opnsense.*, pfsense.*firewall:write-
proxmox.*hypervisor:write-
mikrotik.*, unifi.*network:writeSome destructive sub-features escalate to controller:write
system.*, monitoring.*controller:write-
(default)network:write-

Catastrophic features (VM destroy, node shutdown, firmware upgrades, factory resets, config restores, and similar) additionally require at minimum the site_admin role. This gate is also enforced at stage time - a lower-tier operator cannot queue a catastrophic change for a higher-tier operator to apply later.

Step 4 - discard a change you will not apply

Section titled “Step 4 - discard a change you will not apply”
Terminal window
curl -s -X POST \
"https://freesdn.example.com/api/v1/gateway-vpn/changes/$CHANGE_ID/discard?force=false" \
-H "Authorization: Bearer $TOKEN"

All errors return a consistent JSON envelope:

{
"error": {
"code": 403,
"message": "Permission denied: requires 'device:write'",
"request_id": "ext-a1b2c3d4"
}
}

Validation errors (HTTP 422) include a details array:

{
"error": {
"code": 422,
"message": "Validation error",
"request_id": "ext-...",
"details": [
{"field": "body.listen_port", "message": "value must be between 1 and 65535", "type": "value_error"}
]
}
}
CodeMeaning
400Invalid input or business-rule violation
401Missing, expired, or revoked credential
403Credential valid but permission or CSRF check failed
404Resource not found (also used on cross-tenant probes to avoid leaking existence)
409Conflict (e.g. API key limit reached, slug already exists)
413Request body exceeds the 1 MiB limit - note: this response uses {"detail": "request body exceeds 1048576-byte limit"} (not the standard error envelope), because it is returned by the body-size middleware before request-ID injection and exception handlers run
422Pydantic validation failure - check details
423Account locked after repeated failed login attempts (unlocks after 30 minutes)
429Rate limit exceeded; check Retry-After header
501Feature not available in this release (SAML SSO; Access Control door lock/unlock)
502Adapter could not reach the downstream device
504Adapter timed out connecting to the downstream device

See Errors and Status Codes for the full reference.


The API applies a sliding-window rate limit per authenticated user (or per IP for unauthenticated callers). Default limits:

ScopeLimit
General API600 requests / minute, burst 120 / second
Auth endpoints (/auth/*)5 requests / minute

Responses include X-RateLimit-Limit and X-RateLimit-Remaining headers. On limit, the server returns HTTP 429 with a Retry-After header.

See Rate Limiting for configuration options.


Every response carries an X-Request-ID header. Include a X-Request-ID header in your requests to inject your own correlation ID (alphanumeric + -_, max 128 characters). Client-supplied IDs are prefixed ext- in logs so you can distinguish them from server-generated IDs.

Terminal window
curl -s https://freesdn.example.com/api/v1/devices/ \
-H "Authorization: Bearer $TOKEN" \
-H "X-Request-ID: my-trace-abc123" \
-v 2>&1 | grep X-Request-ID

URLAvailableNotes
/api/v1/openapi.jsonNon-production onlyMachine-readable OpenAPI 3.1 spec; same gate as Swagger/ReDoc - requires ENABLE_DOCS=true in a non-production environment
/api/v1/docsNon-production onlySwagger UI; disabled in production unconditionally - ENABLE_DOCS=true only takes effect in non-production environments
/api/v1/redocNon-production onlyReDoc; same gate
Terminal window
# Download the spec for local use
curl https://freesdn.example.com/api/v1/openapi.json -o freesdn-api.json