Skip to content

API Overview

Nearly every interaction between the Portlama management UI and the backend happens through a JSON REST API protected by mTLS client certificates. The exceptions are the invitation acceptance flow (/api/invite/*) and the agent enrollment endpoint (/api/enroll), which are public.

In Plain English

When you open the Portlama management panel in your browser, the page you see is a single-page application (SPA) built with React. Every button you click — creating a tunnel, adding a user, renewing a certificate — sends a request to the panel server's REST API running on the same machine. The server processes the request, makes changes to the system (writing config files, reloading services), and sends back a JSON response.

The API sits behind nginx, which enforces mTLS on the panel vhost — meaning your browser must present a valid client certificate before the connection is even established. Without that certificate, the TLS handshake fails and no HTTP traffic reaches the API at all.

There are two exceptions to the mTLS requirement. The /api/invite/* routes are registered in a public context without mTLS middleware, so that invited users can accept their invitation and set a password without needing a client certificate. The /api/enroll endpoint is also public, allowing agents to complete hardware-bound certificate enrollment using a token previously generated by an admin.

Base URL

The API is served by the Fastify panel server at 127.0.0.1:3100. In production, nginx reverse-proxies to this address. You never call port 3100 directly — all requests go through nginx on port 9292.

https://<droplet-ip>:9292/api/...

If a domain has been configured and provisioned:

https://panel.<your-domain>/api/...

Both URLs reach the same server. The IP-based URL always works, even if DNS is misconfigured.

Authentication

mTLS Client Certificates

All API endpoints except /api/invite/* and /api/enroll require a valid mTLS client certificate. nginx verifies the certificate at the TLS layer and forwards the result to the panel server via the X-SSL-Client-Verify header.

Browser ──HTTPS + client cert──▶ nginx (port 9292)
  nginx checks ssl_client_verify
    ✓ SUCCESS → proxies to 127.0.0.1:3100 with X-SSL-Client-Verify: SUCCESS
    ✗ FAILED  → TLS handshake rejected, no HTTP traffic reaches the API

In development mode (NODE_ENV=development), the mTLS check is bypassed. A warning is logged once on startup.

Rejection response (403):

json
{
  "error": "mTLS certificate required",
  "details": {
    "hint": "Access to the Portlama panel requires a valid client certificate."
  }
}

No Session Tokens (with One Exception)

The client certificate is the primary authentication mechanism — there are no login endpoints and no bearer tokens. If the certificate is valid, every request is authorized. This is the same model used by LXD.

The one exception is the optional two-factor authentication (2FA) feature. When 2FA is enabled, admin requests must also carry a valid portlama_2fa_session cookie. This cookie is issued by POST /api/settings/2fa/verify after the admin presents a correct TOTP code, and it is HMAC-SHA256 signed, HttpOnly, Secure, SameSite=Strict with a 12-hour absolute expiry and 2-hour inactivity timeout. If a request arrives with a valid mTLS certificate but without a valid 2FA session cookie, the API returns:

json
{
  "error": "2fa_required"
}

with HTTP 401. The 2FA status and verification endpoints are themselves exempt from the 2FA session requirement so that the admin can check status and submit a code without already having a session.

Content Type

All request and response bodies use application/json unless explicitly noted otherwise:

  • File downloads (plist, certificates) return their native MIME type
  • File uploads use multipart/form-data
  • WebSocket connections use the standard WebSocket upgrade handshake

Requests with a JSON body must include the Content-Type: application/json header.

Error Format

Every error response follows a consistent contract:

json
{
  "error": "Human-readable error summary",
  "details": {}
}
FieldTypePresenceDescription
errorstringAlwaysA short, human-readable error message
detailsobjectOptionalAdditional structured information about the error

Validation Errors (400)

All request bodies are validated with Zod schemas at the route level. When validation fails, the error handler returns a 400 with the Zod issues:

json
{
  "error": "Validation failed",
  "details": {
    "issues": [
      {
        "path": ["subdomain"],
        "message": "Subdomain must be lowercase alphanumeric with optional hyphens, cannot start or end with a hyphen"
      },
      {
        "path": ["port"],
        "message": "Port must be at least 1024"
      }
    ]
  }
}

Operational Errors (4xx)

Business logic errors return appropriate HTTP status codes with descriptive messages:

json
{
  "error": "Cannot delete the last user"
}

Common status codes:

CodeMeaningExample
400Bad request / validation failedInvalid subdomain format
4012FA session requiredValid mTLS cert but no 2FA session cookie
403mTLS certificate missing or invalidNo client cert presented
404Resource not foundTunnel ID does not exist
409ConflictUsername already exists
410GoneOnboarding endpoint called after completion
503Service unavailableManagement endpoint called before onboarding

Internal Errors (500)

Unexpected errors return a generic message in production to avoid leaking internal details:

json
{
  "error": "Internal server error"
}

In development mode, the response includes a details object with the error message and stack trace.

Onboarding Guard

The API is split into two groups with mutual exclusion enforced by middleware:

Route GroupPrefixAvailable WhenOtherwise Returns
Onboarding/api/onboarding/*status != COMPLETED410 Gone
Management/api/* (except health, onboarding, invite, and enroll)status == COMPLETED503 Service Unavailable
Public/api/invite/*, /api/enrollAlways (no mTLS)N/A

The GET /api/onboarding/status endpoint is always accessible regardless of onboarding state. The GET /api/health endpoint is also always accessible — it is registered outside both guards. The /api/invite/* and /api/enroll routes are registered in a separate public context with no mTLS middleware and no onboarding guard.

410 response (onboarding complete):

json
{
  "error": "Onboarding already completed"
}

503 response (onboarding incomplete):

json
{
  "error": "Onboarding not complete",
  "onboardingStatus": "FRESH"
}

Onboarding States

The onboarding progresses through a linear state machine:

FRESH → DOMAIN_SET → DNS_READY → PROVISIONING → COMPLETED

Each state transition is triggered by a specific API call and validated server-side. You cannot skip states.

CORS Policy

Cross-Origin Resource Sharing is configured to accept requests from the panel UI origins:

  • Before domain setup: https://<droplet-ip>:9292
  • After domain setup: both https://<droplet-ip>:9292 and https://panel.<domain>

Requests from other origins are rejected by the CORS policy.

WebSocket Connections

Two endpoints use WebSocket for real-time streaming:

EndpointPurposeProtocol
WS /api/onboarding/provision/streamProvisioning progressJSON messages
WS /api/services/:name/logsLive service log tailingJSON messages

WebSocket connections follow the standard upgrade handshake over the same HTTPS connection. The wss:// protocol is used in production since all traffic goes through nginx with TLS.

WebSocket messages are always JSON objects. There is no binary framing.

Common Response Patterns

Success with Data

Most GET endpoints return the requested resource directly:

json
{
  "tunnels": [{ "id": "...", "subdomain": "app", "port": 8080 }]
}

Success with Confirmation

Mutating endpoints typically return an ok field:

json
{
  "ok": true,
  "tunnel": { "id": "...", "subdomain": "app", "port": 8080 }
}

Success with Warning

Some operations succeed but produce a non-fatal warning (for example, nginx reload failure after certificate renewal):

json
{
  "ok": true,
  "domain": "app.example.com",
  "newExpiry": "2026-06-11T00:00:00.000Z",
  "warning": "Certificate renewed but nginx reload failed"
}

File Upload Limits

The server accepts multipart file uploads up to 50 MB per file, used by the static sites file management endpoints.

Rate Limiting

There is no general application-level rate limiting. The mTLS requirement means only authenticated administrators can reach the API, and the expected number of concurrent users is one or two. If you are self-hosting and want rate limits, configure them in nginx.

There are two exceptions:

  • 2FA endpoints (/confirm, /verify, /disable) enforce per-IP rate limiting: 5 attempts per 2-minute window, with a 5-minute ban once the limit is exceeded. This protects against brute-force TOTP guessing even though the caller already holds a valid mTLS certificate.
  • Ticket requests (POST /api/tickets) enforce per-agent rate limiting: 10 tickets per minute per agent. This protects against resource exhaustion from automated ticket generation.

Quick Reference

ItemValue
Base URL (IP)https://<ip>:9292/api
Base URL (domain)https://panel.<domain>/api
AuthenticationmTLS client certificate
Content-Typeapplication/json (default)
ValidationZod schemas at route level
Error format{ "error": "...", "details": {...} }
WebSocket protocolwss:// with JSON messages
Max upload size50 MB per file
Internal listen address127.0.0.1:3100

Endpoint Summary

MethodPathGroupDescription
GET/api/healthAlwaysHealth check
GET/api/onboarding/statusAlwaysOnboarding state
POST/api/onboarding/domainOnboardingSet domain and email
POST/api/onboarding/verify-dnsOnboardingVerify DNS records
POST/api/onboarding/provisionOnboardingStart provisioning
WS/api/onboarding/provision/streamOnboardingProvisioning progress
GET/api/invite/:tokenPublicGet invitation details
POST/api/invite/:token/acceptPublicAccept invitation
POST/api/enrollPublicEnroll agent with token (hardware-bound)
GET/api/system/statsManagementSystem statistics
GET/api/tunnels/agent-configManagementGet agent tunnel configuration
GET/api/tunnelsManagementList tunnels
POST/api/tunnelsManagementCreate tunnel
PATCH/api/tunnels/:idManagementToggle tunnel enabled/disabled
DELETE/api/tunnels/:idManagementDelete tunnel
GET/api/tunnels/mac-plistManagementDownload Mac plist
GET/api/sitesManagementList static sites
POST/api/sitesManagementCreate static site
DELETE/api/sites/:idManagementDelete static site
PATCH/api/sites/:idManagementUpdate site settings
POST/api/sites/:id/verify-dnsManagementVerify site DNS
GET/api/sites/:id/filesManagementList site files
POST/api/sites/:id/filesManagementUpload site files
DELETE/api/sites/:id/filesManagementDelete site file
GET/api/invitationsManagementList invitations
POST/api/invitationsManagementCreate invitation
DELETE/api/invitations/:idManagementRevoke invitation
GET/api/usersManagementList users
POST/api/usersManagementCreate user
PUT/api/users/:usernameManagementUpdate user
DELETE/api/users/:usernameManagementDelete user
POST/api/users/:username/reset-totpManagementReset TOTP secret
GET/api/certsManagementList certificates
GET/api/certs/auto-renew-statusManagementAuto-renew timer status
POST/api/certs/:domain/renewManagementForce-renew certificate
POST/api/certs/mtls/rotateManagementRotate mTLS cert
GET/api/certs/mtls/downloadManagementDownload client.p12
POST/api/certs/agentManagementGenerate agent certificate
GET/api/certs/agentManagementList agent certificates
GET/api/certs/agent/:label/downloadManagementDownload agent .p12
PATCH/api/certs/agent/:label/capabilitiesManagementUpdate agent capabilities
PATCH/api/certs/agent/:label/allowed-sitesManagementUpdate agent site access
DELETE/api/certs/agent/:labelManagementRevoke agent certificate
POST/api/certs/agent/enrollManagementGenerate enrollment token
POST/api/certs/admin/upgrade-to-hardware-boundManagementUpgrade admin to hardware-bound
GET/api/certs/admin/auth-modeManagementGet admin auth mode
GET/api/settings/2faManagementGet 2FA status
POST/api/settings/2fa/setupManagementGenerate TOTP secret
POST/api/settings/2fa/confirmManagementConfirm initial code, enable 2FA
POST/api/settings/2fa/verifyManagementVerify code, issue session cookie
POST/api/settings/2fa/disableManagementDisable 2FA
GET/api/servicesManagementList service statuses
POST/api/services/:name/:actionManagementControl a service (start/stop/restart)
WS/api/services/:name/logsManagementStream service logs
GET/api/pluginsManagementList installed plugins
GET/api/plugins/:nameManagementGet plugin details
POST/api/plugins/installManagementInstall a plugin
POST/api/plugins/:name/enableManagementEnable a plugin
POST/api/plugins/:name/disableManagementDisable a plugin
DELETE/api/plugins/:nameManagementUninstall a plugin
GET/api/plugins/push-install/configManagementGet push install configuration
PATCH/api/plugins/push-install/configManagementUpdate push install configuration
GET/api/plugins/push-install/policiesManagementList push install policies
POST/api/plugins/push-install/policiesManagementCreate push install policy
PATCH/api/plugins/push-install/policies/:idManagementUpdate push install policy
DELETE/api/plugins/push-install/policies/:idManagementDelete push install policy
POST/api/plugins/push-install/enable/:labelManagementEnable push install for agent
DELETE/api/plugins/push-install/enable/:labelManagementDisable push install for agent
GET/api/plugins/push-install/agent-statusManagementAgent checks own push install status
POST/api/plugins/push-install/:labelManagementSend push install command to agent
GET/api/plugins/push-install/sessionsManagementList push install audit log
POST/api/tickets/scopesManagementRegister ticket scope
GET/api/tickets/scopesManagementList scopes, instances, assignments
DELETE/api/tickets/scopes/:nameManagementDelete ticket scope
POST/api/tickets/instancesManagementRegister instance
DELETE/api/tickets/instances/:instanceIdManagementDeregister instance
POST/api/tickets/instances/:instanceId/heartbeatManagementInstance heartbeat
POST/api/tickets/assignmentsManagementAssign agent to instance
DELETE/api/tickets/assignments/:agentLabel/:instanceScopeManagementRemove assignment
GET/api/tickets/assignmentsManagementList assignments
POST/api/ticketsManagementRequest ticket
GET/api/tickets/inboxManagementCheck ticket inbox
POST/api/tickets/validateManagementValidate and consume ticket
GET/api/ticketsManagementList all tickets (admin)
DELETE/api/tickets/:ticketIdManagementRevoke ticket
POST/api/tickets/sessionsManagementCreate session from ticket
POST/api/tickets/sessions/:sessionId/heartbeatManagementSession heartbeat
PATCH/api/tickets/sessions/:sessionIdManagementUpdate session status
DELETE/api/tickets/sessions/:sessionIdManagementKill session
GET/api/tickets/sessionsManagementList sessions

Agent Capabilities

Agent certificates use capability-based access control. Base capabilities are always available; plugins can declare additional capabilities in their manifest.

Base capabilities:

CapabilityDescription
tunnels:readList tunnels, download Mac plist (always-on, cannot be removed)
tunnels:writeCreate and delete tunnels
services:readView service status
services:writeStart, stop, and restart services
system:readView system stats (CPU, RAM, disk)
sites:readList sites and browse files
sites:writeUpload and delete files on assigned sites

Plugin-declared capabilities: Plugins can declare additional capabilities in their portlama-plugin.json manifest using either a flat array ("capabilities": ["scope:action"]) or a nested object ("capabilities": { "agent": ["scope:action"] }). Both formats are normalized to a flat array internally. These are merged with base capabilities and available for assignment to agent certificates. Capabilities are validated dynamically via getValidCapabilities().

Ticket scope capabilities: Ticket scopes (registered via POST /api/tickets/scopes) declare capabilities that are dynamically merged with base and plugin capabilities. For example, a scope named shell declaring scopes: [{ name: "shell:connect" }] makes shell:connect available for assignment to agent certificates. See the Tickets API for details.

Released under the PolyForm Noncommercial License 1.0.0