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.
Base URL
Section titled “Base URL”All API paths are rooted at /api/v1. The Caddy edge proxies the full path verbatim - do not strip or add the prefix.
| Environment | Base URL |
|---|---|
| Production / staging | https://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.
Authentication credentials
Section titled “Authentication credentials”There are two credential channels. Use one per request, never both on the same request.
| Channel | Header | When to use |
|---|---|---|
| Bearer JWT | Authorization: Bearer <token> | Scripts, server-side integrations, API clients |
| API key | X-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.
Worked example: login to list devices
Section titled “Worked example: login to list devices”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”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:
# Only required when require_mfa is trueTOKEN=$(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')Step 2 - verify your identity
Section titled “Step 2 - verify your identity”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/.
Step 3 - list sites
Section titled “Step 3 - list sites”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:
SITE_ID=$(curl -s "https://freesdn.example.com/api/v1/sites/" \ -H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')Step 4 - list devices, filtered by site
Section titled “Step 4 - list devices, filtered by site”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:
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.
Key endpoints at a glance
Section titled “Key endpoints at a glance”This table covers the practical surface for day-to-day API use. The full spec is at /api/v1/openapi.json.
| Method | Path | Purpose | Min permission |
|---|---|---|---|
POST | /auth/login | JSON login; returns tokens or MFA challenge | public |
POST | /auth/login/mfa | Complete MFA step; returns tokens | public |
POST | /auth/refresh | Rotate refresh → new token pair | valid refresh token |
GET | /auth/me | Current user profile + permissions | authenticated |
POST | /auth/logout | Revoke current session | authenticated |
GET | /sites/ | List sites (org-scoped) | authenticated |
GET | /devices/ | List devices (?site_id=) | device:read |
GET | /discovery/scans/history | Scan history | discovery:run |
POST | /discovery/scan | Start a discovery scan | discovery:run |
GET | /api-keys/ | List your API keys | authenticated |
POST | /api-keys/ | Create an API key | authenticated |
GET | /health | Container 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.
Site filtering
Section titled “Site filtering”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.
# Devices in a specific sitecurl -s "https://freesdn.example.com/api/v1/devices/?site_id=$SITE_ID" \ -H "Authorization: Bearer $TOKEN"
# Switches in a specific sitecurl -s "https://freesdn.example.com/api/v1/switches/?site_id=$SITE_ID" \ -H "Authorization: Bearer $TOKEN"Cookie-based calls and CSRF
Section titled “Cookie-based calls and CSRF”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:
- After login the server sets
freesdn_csrfas a readable (non-httpOnly) cookie at path/. - Your JavaScript reads it and echoes it in the
X-CSRF-Tokenheader on every mutation.
// Read the CSRF token once after loginfunction getCsrf() { return document.cookie .split('; ') .find(c => c.startsWith('freesdn_csrf=')) ?.split('=')[1] ?? '';}
// Use it on every mutating requestasync 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.
The staged-write apply flow
Section titled “The staged-write apply flow”Write operations for network devices do not touch the live device immediately. Every mutation goes through a two-step pipeline:
- Stage - post the change; it is written to the database as a pending record.
- 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.
Step 1 - stage a change
Section titled “Step 1 - stage a change”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|deleteAll 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|deleteCheck 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:
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.
Step 2 - review pending changes
Section titled “Step 2 - review pending changes”List all pending changes for a controller + site:
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):
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:
- The server must be running with
ADAPTER_READ_ONLY=false(set in your.envfile). - The request body must contain
"force": true.
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 prefix | Required permission | Notes |
|---|---|---|
vpn.* | vpn:write | - |
firewall.*, opnsense.*, pfsense.* | firewall:write | - |
proxmox.* | hypervisor:write | - |
mikrotik.*, unifi.* | network:write | Some 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”curl -s -X POST \ "https://freesdn.example.com/api/v1/gateway-vpn/changes/$CHANGE_ID/discard?force=false" \ -H "Authorization: Bearer $TOKEN"Error shape
Section titled “Error shape”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"} ] }}| Code | Meaning |
|---|---|
400 | Invalid input or business-rule violation |
401 | Missing, expired, or revoked credential |
403 | Credential valid but permission or CSRF check failed |
404 | Resource not found (also used on cross-tenant probes to avoid leaking existence) |
409 | Conflict (e.g. API key limit reached, slug already exists) |
413 | Request 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 |
422 | Pydantic validation failure - check details |
423 | Account locked after repeated failed login attempts (unlocks after 30 minutes) |
429 | Rate limit exceeded; check Retry-After header |
501 | Feature not available in this release (SAML SSO; Access Control door lock/unlock) |
502 | Adapter could not reach the downstream device |
504 | Adapter timed out connecting to the downstream device |
See Errors and Status Codes for the full reference.
Rate limits
Section titled “Rate limits”The API applies a sliding-window rate limit per authenticated user (or per IP for unauthenticated callers). Default limits:
| Scope | Limit |
|---|---|
| General API | 600 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.
Request tracing
Section titled “Request tracing”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.
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-IDOpenAPI spec and interactive docs
Section titled “OpenAPI spec and interactive docs”| URL | Available | Notes |
|---|---|---|
/api/v1/openapi.json | Non-production only | Machine-readable OpenAPI 3.1 spec; same gate as Swagger/ReDoc - requires ENABLE_DOCS=true in a non-production environment |
/api/v1/docs | Non-production only | Swagger UI; disabled in production unconditionally - ENABLE_DOCS=true only takes effect in non-production environments |
/api/v1/redoc | Non-production only | ReDoc; same gate |
# Download the spec for local usecurl https://freesdn.example.com/api/v1/openapi.json -o freesdn-api.jsonNext steps
Section titled “Next steps”- Authentication - full token lifecycle, MFA, SSO, and session management
- API Keys - create scoped long-lived credentials for automation
- Pagination and Filtering - page, per_page, site_id, search
- Rate Limiting - limits, headers, and configuration
- Errors and Status Codes - canonical error shape and all codes
- WebSockets - real-time event stream for live device updates
- Modules - per-module endpoint tables (switches, cameras, VoIP, firewall, hypervisor, etc.)
- Security - multi-tenancy model, RBAC, and write-safety guarantees