Skip to main content
Version: 2.0.1

OpenPanel API

OpenPanel exposes a full REST API that mirrors all web UI functionality. Every feature available in the panel can be accessed programmatically.

A machine-readable OpenAPI 3.0 spec covering every endpoint below is also available — import it into Postman/Insomnia, generate a client SDK, or load it into Swagger UI/Redoc.

Authentication​

All API requests require a Bearer token obtained via the login endpoint.

Step 1 — Get a token:

curl -s -X POST https://panel.example.com/api/login \
-H "Content-Type: application/json" \
-d '{"username": "stefan", "password": "yourpassword"}'

Response:

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 86400
}

Step 2 — Use it on every request:

curl https://panel.example.com/api/account \
-H "Authorization: Bearer eyJhbGciOi..."

Tokens expire after 24 hours. Both the blanket api feature flag AND the specific feature flag must be enabled in the user's plan.


MySQL​

All MySQL endpoints are flat — there is no /api/mysql/<db_name>/users nesting. Databases live under /api/mysql/databases, users under /api/mysql/users, and the two are tied together with a separate /api/mysql/grants resource.

List databases​

GET /api/mysql/databases
Authorization: Bearer <token>
{
"databases": [
{"name": "mydb", "assigned_users": "myuser"}
],
"total": 1
}

Create a database​

POST /api/mysql/databases
Content-Type: application/json

{"name": "newdb"}

Drop a database​

DELETE /api/mysql/databases/newdb

List tables in a database​

GET /api/mysql/databases/mydb/tables
{
"database": "mydb",
"tables": [
{"table": "wp_posts", "rows": 120, "data_length": 98304, "index_length": 32768, "data_free": 0}
]
}

Optimize / repair a database​

Runs OPTIMIZE TABLE or REPAIR TABLE against every table in the database.

POST /api/mysql/databases/mydb/optimize
POST /api/mysql/databases/mydb/repair
{
"database": "mydb",
"action": "optimize",
"results": [
{"table": "wp_posts", "status": "ok", "details": [{"op": "optimize", "msg_type": "status", "msg_text": "OK"}]}
]
}

Export a database​

Streams a .sql or .sql.gz file download.

GET /api/mysql/databases/mydb/export?format=sql

Import a database​

Multipart upload — field name must be file. Accepts .sql or .sql.gz.

POST /api/mysql/databases/mydb/import
Content-Type: multipart/form-data

Get per-database disk usage​

Mirrors the web UI's database-size view — distinct from /api/mysql/info, which has no size data, and /api/mysql/databases/<db>/tables, which is per-table for a single database.

GET /api/mysql/size?unit=mb&show_all=false
{"sizes": [{"database": "mydb", "size": 12.4}], "total": 1, "unit": "MB"}

Set the MySQL root password​

Write-only — the current password can't be retrieved. Updates both the % and localhost hosts and restarts the service.

PUT /api/mysql/root-password
Content-Type: application/json

{"password": "newRootPass123"}

List database users​

GET /api/mysql/users
{"users": ["myuser", "shopuser"], "total": 2}

Create a database user​

POST /api/mysql/users
Content-Type: application/json

{"username": "dbuser", "password": "securepass", "host": "%"}

host defaults to % if omitted.

Delete a database user​

DELETE /api/mysql/users/dbuser?host=%

Change a database user's password​

PATCH /api/mysql/users/dbuser/password
Content-Type: application/json

{"password": "newpass", "host": "%"}

Get a user's privileges on a database​

GET /api/mysql/users/dbuser/privileges/mydb?host=%
{"username": "dbuser", "host": "%", "database": "mydb", "privileges": ["ALL PRIVILEGES"]}

Grant privileges​

POST /api/mysql/grants
Content-Type: application/json

{"username": "dbuser", "database": "mydb", "privileges": ["ALL PRIVILEGES"], "host": "%"}

Revoke privileges​

DELETE /api/mysql/grants
Content-Type: application/json

{"username": "dbuser", "database": "mydb", "host": "%"}

Get databases/users/assignments summary​

GET /api/mysql/info
{
"databases": ["mydb", "shop"],
"users": ["dbuser", "shopuser"],
"assigned_databases": [{"database": "mydb", "users": "dbuser"}]
}

Show full processlist​

GET /api/mysql/processlist
{
"processlist": [
{"id": 42, "user": "dbuser", "host": "localhost", "db": "mydb", "command": "Query", "time": 0, "state": "", "info": "SELECT 1"}
],
"total": 1
}

Remote access status​

GET /api/mysql/remote-access
{
"enabled": false,
"server_ip": "203.0.113.10",
"port": "3306",
"user_access": [{"username": "dbuser", "hosts": ["%"]}]
}

Enable / disable remote access​

POST /api/mysql/remote-access
Content-Type: application/json

{"action": "enable"}

Grant a remote-access entry​

Creates a new user@host (cloning grants from an existing host for that user, if any).

POST /api/mysql/remote-access/entries
Content-Type: application/json

{"username": "dbuser", "host": "1.2.3.4", "password": "securepass"}

Change a remote-access entry's host​

PATCH /api/mysql/remote-access/entries
Content-Type: application/json

{"username": "dbuser", "old_host": "1.2.3.4", "new_host": "5.6.7.8"}

Remove a remote-access entry​

DELETE /api/mysql/remote-access/entries
Content-Type: application/json

{"username": "dbuser", "host": "1.2.3.4"}

Get server configuration​

GET /api/mysql/configuration
{
"configuration": {"max_connections": "150"},
"available_keys": ["max_connections", "max_allowed_packet", "innodb_buffer_pool_size", "..."]
}

Update server configuration​

Only keys present in available_keys are accepted. Restarts MySQL/MariaDB.

PUT /api/mysql/configuration
Content-Type: application/json

{"max_connections": "200"}
{"updated": true, "restarted": true, "configuration": {"max_connections": "200"}}

PostgreSQL​

Same flat structure as MySQL: databases, users, and grants are separate top-level resources.

List databases​

GET /api/postgresql/databases
{
"databases": [{"name": "pgdb", "assigned_users": "pguser"}],
"total": 1
}

Create a database​

POST /api/postgresql/databases
Content-Type: application/json

{"name": "pgdb"}

Drop a database​

DELETE /api/postgresql/databases/pgdb

Export a database​

Streams a .sql dump.

GET /api/postgresql/databases/pgdb/export

Import a database​

Multipart upload — field name must be file.

POST /api/postgresql/databases/pgdb/import
Content-Type: multipart/form-data

{"database": "pgdb", "file": "dump.sql", "imported": true}

List users​

GET /api/postgresql/users
{"users": ["pguser"], "total": 1}

Create a user​

POST /api/postgresql/users
Content-Type: application/json

{"username": "pguser", "password": "securepass"}

Drop a user​

Revokes access from every database before dropping the role.

DELETE /api/postgresql/users/pguser

Change a user's password​

PATCH /api/postgresql/users/pguser/password
Content-Type: application/json

{"password": "newpass"}

Grant all privileges on a database​

POST /api/postgresql/grants
Content-Type: application/json

{"username": "pguser", "database": "pgdb"}

Revoke privileges​

DELETE /api/postgresql/grants
Content-Type: application/json

{"username": "pguser", "database": "pgdb"}

Get databases/users/assignments summary​

GET /api/postgresql/info

Show active connections​

GET /api/postgresql/processlist
{
"processlist": [
{"pid": 123, "user": "pguser", "application": "psql", "client_addr": "127.0.0.1",
"state": "active", "wait_event_type": "", "wait_event": "", "query": "SELECT 1",
"backend_type": "client backend", "duration": "0:00:00.001"}
],
"total": 1
}

Remote access status​

GET /api/postgresql/remote-access
{"enabled": false, "server_ip": "203.0.113.10", "port": "5432", "postgres_port": 5432}

Enable / disable remote access​

POST /api/postgresql/remote-access
Content-Type: application/json

{"action": "enable"}

Get / update server configuration​

Same shape as MySQL configuration — restarts PostgreSQL on save.

GET /api/postgresql/configuration
PUT /api/postgresql/configuration
Content-Type: application/json

{"max_connections": "200"}

Domains​

List all domains​

GET /api/domains
{
"domains": [
{
"domain": "example.com", "docroot": "/var/www/html", "php_version": "8.2",
"site_count": 1, "redirect_url": null, "ssl": true, "status": "active",
"suspend_comment": null, "has_dns_zone": true
}
],
"total": 1
}

Add a domain​

docroot defaults to /var/www/html/. .onion domains also require hs_ed25519_public_key and hs_ed25519_secret_key.

POST /api/domains
Content-Type: application/json

{"domain": "newdomain.com"}

Remove a domain​

DELETE /api/domains/olddomain.com

Get domain status​

SSL / suspend / redirect status in one call.

GET /api/domains/example.com/status
{"domain": "example.com", "ssl": true, "status": "active", "suspend_comment": null, "redirect_url": null}

Suspend / unsuspend a domain​

POST /api/domains/example.com/suspend
POST /api/domains/example.com/unsuspend

Get / change the document root​

docroot must resolve inside /var/www/html/.

GET /api/domains/example.com/docroot

PUT /api/domains/example.com/docroot
Content-Type: application/json

{"docroot": "/var/www/html/example.com/public"}

Get / set / remove a redirect​

GET /api/domains/example.com/redirect

PUT /api/domains/example.com/redirect
Content-Type: application/json

{"redirect_url": "https://newsite.com"}

DELETE /api/domains/example.com/redirect

Get SSL status​

GET /api/domains/example.com/ssl
{"domain": "example.com", "ssl_mode": "auto", "keys": ""}

Issue / renew SSL​

action is required — one of autossl, generate, switch_and_generate, or custom (which also requires public_path and private_path, both inside /var/www/html/).

POST /api/domains/example.com/ssl
Content-Type: application/json

{"action": "autossl"}

Custom certificate:

POST /api/domains/example.com/ssl
Content-Type: application/json

{
"action": "custom",
"public_path": "/var/www/html/certs/example.com.crt",
"private_path": "/var/www/html/certs/example.com.key"
}

Get / save the VHost config​

GET /api/domains/example.com/vhost

PUT /api/domains/example.com/vhost
Content-Type: application/json

{"vhost": "...vhost content..."}

Get / set a display-case override​

Cosmetic only — e.g. showing "MyBrand.com" instead of "mybrand.com" in the UI.

GET /api/domains/example.com/capitalize
{"domain": "example.com", "capitalized_domain": "example.com"}
PUT /api/domains/example.com/capitalize
Content-Type: application/json

{"capitalized_domain": "Example.com"}

Get the TLSA record hash​

For DANE/TLSA DNS records, computed from the domain's active SSL certificate (Let's Encrypt or custom).

GET /api/domains/example.com/tlsa-hash
{"hash_311": "a30dc1ad...", "hash_301": "05754994...", "cert_path": "/etc/openpanel/caddy/ssl/.../example.com.crt", "type": "letsencrypt"}

DNS zone (nested under domains)​

This mirrors the standalone DNS Zone Editor below but is scoped under /api/domains/<domain>/.... Note the field name difference: record creation here uses value, while the standalone /api/dns endpoints use record.

GET /api/domains/example.com/dns
{
"domain": "example.com",
"serial": "2025011501",
"records": [
{"line_number": 12, "end_line_number": 12, "record": "www 3600 IN A 1.2.3.4"}
]
}

Save the whole zone file at once:

PUT /api/domains/example.com/dns
Content-Type: application/json

{"zone_content": "$ORIGIN example.com.\n..."}

Add a record:

POST /api/domains/example.com/dns/records
Content-Type: application/json

{"name": "www", "ttl": "3600", "type": "A", "value": "1.2.3.4"}

Update / delete a record by line number (end_row_id covers multi-line records like DKIM):

PUT /api/domains/example.com/dns/records/12
Content-Type: application/json

{"content": "www 3600 IN A 5.6.7.8", "end_row_id": 12, "serial": "2025011501"}

DELETE /api/domains/example.com/dns/records/12
Content-Type: application/json

{"end_row_id": 12}

Reset the zone to the default template, or export it as a file:

POST /api/domains/example.com/dns/restart
GET /api/domains/example.com/dns/export

Access logs​

GET /api/domains/example.com/logs?page=1&show_all=false
{
"domain": "example.com",
"logs": [{"ts": "2025-01-15T12:00:00Z", "status": 200, "path": "/"}],
"total": 1,
"page": 1,
"total_pages": 1,
"items_per_page": 1000
}

Email​

List mailboxes​

GET /api/emails
{
"emails": [
{"address": "[email protected]", "quota_limit": "1024", "quota_used": "12"}
]
}

Create mailbox​

domain, username, and password are all required. Optional gb + format set a custom quota.

POST /api/emails
Content-Type: application/json

{"domain": "example.com", "username": "info", "password": "mailpass123"}

Get mailbox details​

GET /api/emails/[email protected]
{
"address": "[email protected]",
"quota_limit": "1024",
"quota_used": "12",
"incoming": "ACCEPT",
"outgoing": "ACCEPT",
"server_ip": "203.0.113.10",
"mail_host": "mail.example.com"
}

Update mailbox​

At least one of gb+format, incoming (suspend/allow), outgoing (suspend/allow), or password is required.

PATCH /api/emails/[email protected]
Content-Type: application/json

{"password": "newpass456"}

Delete mailbox​

DELETE /api/emails/[email protected]

Download mail client configuration​

config_type is one of thunderbird, outlook, apple.

GET /api/emails/configuration/thunderbird/[email protected]?ssl=true

List aliases​

GET /api/emails/aliases
{
"aliases": [
{"source": "[email protected]", "targets": ["[email protected]"]}
]
}

Create alias​

POST /api/emails/aliases
Content-Type: application/json

{"username": "alias", "domain": "example.com", "target": "[email protected]"}

Get targets for one alias​

GET /api/emails/aliases/[email protected]
{"source": "[email protected]", "targets": ["[email protected]"]}

Add a target to an existing alias​

POST /api/emails/aliases/[email protected]
Content-Type: application/json

{"target": "[email protected]"}

Delete an alias target (or the whole alias)​

DELETE /api/emails/aliases/[email protected]
Content-Type: application/json

{"target": "[email protected]"}

Delete every target for the alias:

DELETE /api/emails/aliases/[email protected]
Content-Type: application/json

{"delete_all": true}

Get catch-all address​

GET /api/emails/default/example.com
{"domain": "example.com", "destination": null}

Set / remove catch-all address​

Omit or empty destination to remove it.

PUT /api/emails/default/example.com
Content-Type: application/json

{"destination": "[email protected]"}

Check deliverability (all domains)​

GET /api/emails/deliverability

Check deliverability for a domain​

Compares live DNS against what the mail server expects for DKIM, SPF, and DMARC.

GET /api/emails/deliverability/example.com
{
"domain": "example.com",
"server_ip": "203.0.113.10",
"ok": true,
"dkim": {"status": "ok", "current": "v=DKIM1; ...", "expected": "v=DKIM1; ..."},
"spf": {"status": "ok", "current": "v=spf1 ip4:203.0.113.10 ~all", "expected": "v=spf1 ip4:203.0.113.10 ~all"},
"dmarc": {"status": "ok", "current": "v=DMARC1; p=none", "expected": "v=DMARC1; p=none"}
}

Get Sieve mail filters​

GET /api/emails/filters/[email protected]
{
"email": "[email protected]",
"raw": "require [\"fileinto\"];\nif header :contains \"Subject\" \"SPAM\" { fileinto \"Junk\"; }",
"parsed": [{"condition": "header :contains \"Subject\" \"SPAM\"", "action": "fileinto \"Junk\""}]
}

Update Sieve filters​

PUT /api/emails/filters/[email protected]
Content-Type: application/json

{"content": "require [\"fileinto\"];\nif header :contains \"Subject\" \"SPAM\" { fileinto \"Junk\"; }"}

Export mailboxes​

Returns a CSV download (email,password,quota — password is always blank, since it's never stored in reversible form).

GET /api/emails/export

Import mailboxes​

Two-step: the first call validates a CSV upload and returns an import_token; the second confirms it and creates the mailboxes.

POST /api/emails/import
Content-Type: multipart/form-data

{"import_token": "...", "valid_users": [{"email": "[email protected]", "..."}], "invalid_users": []}
POST /api/emails/import/confirm
Content-Type: application/json

{"import_token": "..."}
{"message": "...", "created": 3, "results": [{"email": "[email protected]", "status": "created"}]}

FTP​

List FTP accounts​

GET /api/ftp
{
"accounts": [{"username": "[email protected]", "path": "/var/www/html", "uid": "1000", "gid": "1000"}],
"server_ip": "203.0.113.10",
"ftp_host": "203.0.113.10"
}

Create FTP account​

domain is required — the account is created as username@domain, and path must start with /var/www/html/.

POST /api/ftp
Content-Type: application/json

{"username": "ftpuser", "password": "ftppass", "domain": "example.com", "path": "/var/www/html/"}

Delete FTP account​

DELETE /api/ftp/[email protected]

Change FTP password​

PATCH /api/ftp/[email protected]/password
Content-Type: application/json

{"password": "newftppass"}

Change FTP home path​

PATCH /api/ftp/[email protected]/path
Content-Type: application/json

{"path": "/var/www/html/subdir"}

List active FTP connections​

GET /api/ftp/connections

Download FTP client configuration​

config_type is one of cyberduck, filezilla.

GET /api/ftp/configuration/filezilla/[email protected]

Cron Jobs​

List all cron jobs​

GET /api/crons
{
"jobs": [
{"comment": "my job", "schedule": "0 * * * *", "container": "php-fpm-8.1", "command": "/usr/bin/php /var/www/html/cron.php"}
],
"containers": ["php-fpm-8.1", "nginx"],
"schedule_issues": []
}

Create a cron job​

schedule, command, and container are required. comment defaults to the container name.

POST /api/crons
Content-Type: application/json

{"schedule": "*/5 * * * *", "command": "/usr/bin/php /var/www/html/run.php", "container": "php-fpm-8.1", "comment": "my job"}

Edit a cron job​

The job is located by matching original_schedule, original_command, original_container, and original_comment exactly against the existing entry.

PATCH /api/crons
Content-Type: application/json

{
"original_schedule": "*/5 * * * *",
"original_command": "/usr/bin/php /var/www/html/run.php",
"original_container": "php-fpm-8.1",
"original_comment": "my job",
"schedule": "0 */2 * * *",
"command": "/usr/bin/php /var/www/html/run.php",
"container": "php-fpm-8.1",
"comment": "my job"
}

Delete a cron job​

schedule, command, container, and comment must all match the existing job exactly.

DELETE /api/crons
Content-Type: application/json

{"schedule": "*/5 * * * *", "command": "/usr/bin/php /var/www/html/run.php", "container": "php-fpm-8.1", "comment": "my job"}

Get / save raw crontab​

GET /api/crons/raw

PUT /api/crons/raw
Content-Type: application/json

{"content": "0 * * * * /usr/bin/php /var/www/html/cron.php\n"}

Tail cron log​

GET /api/crons/log?lines=50&job=cron.php

Cache Services​

Redis, Valkey, Memcached, Elasticsearch, and OpenSearch all follow the same two-endpoint pattern: a status GET and an action POST that takes {"action": "enable" | "disable" | "restart"}.

Redis / Valkey / Memcached / Elasticsearch / OpenSearch​

GET /api/cache/redis
POST /api/cache/redis
Content-Type: application/json

{"action": "enable"}
{
"service": "redis", "port": 6379,
"description": "In-memory key-value store used primarily as an application cache.",
"container_state": "running", "health_status": "healthy",
"actions": ["disable"]
}

Same pattern for the rest, just replace redis with the service name:

GET /api/cache/valkey
POST /api/cache/valkey {"action": "disable"}

GET /api/cache/memcached
POST /api/cache/memcached {"action": "restart"}

GET /api/cache/elasticsearch
POST /api/cache/elasticsearch {"action": "enable"}

GET /api/cache/opensearch
POST /api/cache/opensearch {"action": "enable"}

Varnish​

Varnish adds stats and per-domain toggles on top of the standard status/action pair.

# Status + domain list
GET /api/cache/varnish
{
"service": "varnish", "container_state": "stopped", "health_status": "unknown",
"actions": ["enable", "restart"],
"domain_statuses": {"example.com": "Off"}
}
# Enable/disable/restart
POST /api/cache/varnish
Content-Type: application/json

{"action": "enable"}

# Hit ratio and traffic stats
GET /api/cache/varnish/stats

# Per-domain Varnish status
GET /api/cache/varnish/domains

# Toggle Varnish for a specific domain
POST /api/cache/varnish/domains/example.com
Content-Type: application/json

{"status": "On"}
{
"status": "running",
"cache": {"hit_ratio": 0.92, "hits": 9200, "misses": 800, "pass": 0},
"traffic": {"requests_total": 10000, "connections": 500},
"backend": {"requests": 800, "failures": 0, "retries": 0, "health": "healthy"},
"memory": {"objects": 120, "evictions": 0},
"performance": {"efficiency_score": 92, "error_rate": 0.0}
}

Docker / Containers​

List compose services​

GET /api/containers

List running containers​

GET /api/containers/status
{"running_containers": ["nginx", "mysql", "php-fpm-8.2"]}

Get single container state​

GET /api/containers/nginx/status
{"service": "nginx", "state": "running", "health": "healthy"}

Start / stop / restart​

POST /api/containers/nginx/start
POST /api/containers/nginx/stop
POST /api/containers/nginx/restart

Start with an image pull first:

POST /api/containers/nginx/start
Content-Type: application/json

{"pull": true}

Update CPU/RAM limits​

PATCH /api/containers/myapp/resources
Content-Type: application/json

{"cpu": "1", "ram": "512m"}

Tail container logs​

GET /api/containers/nginx/logs?lines=200
{"service": "nginx", "lines": 200, "entries": [{"log": "2025/01/15 12:00:00 [notice] start worker process"}]}

Add / edit / remove a compose service​

Not to be confused with PATCH /api/containers/<service>/resources (CPU/RAM limits on an existing service, documented above) — these manage the service's presence and definition in docker-compose.yml itself. volumes is a list of {"name": "...", "mount": "...", "readonly": false}.

POST /api/containers
Content-Type: application/json

{
"service_name": "phpmyadmin",
"image": "phpmyadmin:latest",
"cpu": "1",
"ram": "512m",
"network": "db",
"environment": "PMA_HOST=mariadb",
"add_socket": false,
"volumes": []
}

PATCH /api/containers/myservice
Content-Type: application/json

{"cpu": "1", "ram": "512m", "environment": "..."}

DELETE /api/containers/myservice

Switch the MySQL/MariaDB variant​

Wipes the old data volume.

POST /api/containers/mysql
Content-Type: application/json

{"new_sql": "mysql:8.0"}

Switch the active webserver​

POST /api/containers/webserver
Content-Type: application/json

{"new_ws": "nginx"}

Change a service's image​

PATCH /api/containers/myservice/image
Content-Type: application/json

{"new_tag": "myimage:tag"}

WAF (Web Application Firewall)​

Status for all domains​

GET /api/waf
{"domains": {"example.com": "On"}}

Status for a domain​

GET /api/waf/example.com
{"domain": "example.com", "status": "On", "removed_rules": [], "removed_tags": []}

Toggle WAF on/off​

POST /api/waf/example.com
Content-Type: application/json

{"status": "On"}

Update excluded rules​

The body fields are removed_rules (numeric rule IDs) and removed_tags — not remove_by_id/remove_by_tag.

PUT /api/waf/example.com/rules
Content-Type: application/json

{
"removed_rules": ["920170", "941100"],
"removed_tags": ["attack-sqli"]
}

Paginated WAF log​

GET /api/waf/log/example.com?page=1&per_page=25

WAF stats​

GET /api/waf/stats/example.com?seconds=3600
{"domain": "example.com", "seconds": 3600, "checks": 120, "blocks": 4}

List rule IDs / tags​

Both calls hit the same parametrized route, /api/waf/ids/<id_type>, which only accepts ids or tags as the path segment:

GET /api/waf/ids/ids
GET /api/waf/ids/tags
{"ids": ["920170", "941100"]}

IP Blocker​

List blocked IPs​

GET /api/ip-blocker
{"blocked_ips": ["1.2.3.4", "10.0.0.0/8"]}

Block IPs​

Accepts single IPs and CIDR ranges.

POST /api/ip-blocker
Content-Type: application/json

{"ips": ["1.2.3.4", "10.0.0.0/8"]}
{"blocked": ["1.2.3.4", "10.0.0.0/8"], "invalid": [], "message": "2 IP(s) added to blocklist"}

Unblock all IPs​

DELETE /api/ip-blocker

PHP​

List installed PHP versions​

GET /api/php/versions

Get php.ini content​

GET /api/php/8.2/ini

Save php.ini​

PUT /api/php/8.2/ini
Content-Type: application/json

{"content": "memory_limit = 256M\nupload_max_filesize = 64M\n..."}

Get PHP option keys and values​

GET /api/php/8.2/options

Update specific PHP options​

PUT /api/php/8.2/options
Content-Type: application/json

{"memory_limit": "256M", "upload_max_filesize": "64M", "max_execution_time": "300"}

List extensions with state​

GET /api/php/8.2/extensions
{
"version": "8.2",
"service": "php-fpm-8.2",
"extensions": [
{"name": "imagick", "state": "active"},
{"name": "redis", "state": "disabled"},
{"name": "swoole", "state": "not_installed"}
],
"history": []
}

Toggle an extension​

enable is a boolean, not an action string. PHP-FPM is restarted afterwards.

POST /api/php/8.2/extensions
Content-Type: application/json

{"extension": "imagick", "enable": true}

List all supported extensions​

GET /api/php/8.2/extensions/available

Install extensions​

extensions is a list (not a single extension string) — installation runs asynchronously.

POST /api/php/8.2/extensions/install
Content-Type: application/json

{"extensions": ["swoole"]}
{"install_id": "b3f1...-uuid", "extensions": ["swoole"], "message": "Install started"}

Poll install status​

GET /api/php/8.2/extensions/install/status?install_id=b3f1...-uuid

Get / set the server-wide default PHP version​

Used for new sites/domains without an explicit override. Not available on LiteSpeed the same way — see the per-domain note below.

GET /api/php/default
{"version": "7.1", "service": "php-fpm-7.1", "is_litespeed": false, "installed_versions": ["8.5", "8.4", "..."]}
PUT /api/php/default
Content-Type: application/json

{"version": "8.2"}

List / change per-domain PHP version assignments​

PUT takes the domain in the request body, not the URL path. Not available on LiteSpeed, which has no per-domain PHP — use PUT /api/php/default instead.

GET /api/php/domains
{
"domains": [{"DomainID": 52, "DomainURL": "example.com", "PHPVersion": "8.5", "Level": "good"}],
"counts": [{"Version": "8.5", "Count": 7, "Label": "Supported (Latest)", "Level": "good"}],
"outdated_domains": [],
"available_php_versions": ["8.5", "8.4", "..."],
"php_default_version": "7.1",
"is_litespeed": false
}
PUT /api/php/domains
Content-Type: application/json

{"domain": "example.com", "version": "8.2"}

Account​

Get account info​

GET /api/account
{
"username": "stefan",
"email": "[email protected]",
"plan_id": 1,
"context": "stefan",
"permit_username_change": false
}

Update account​

PATCH /api/account
Content-Type: application/json

{"email": "[email protected]"}
# or
{"password": "newpass"}
# or (only if allowed on this server)
{"username": "newname"}

List active sessions​

GET /api/account/sessions

Terminate a session​

DELETE /api/account/sessions/<token>

List / add / remove favorites​

GET /api/account/favorites
{"favorites": [{"link": "dashboard", "title": "Dashboard"}]}
POST /api/account/favorites
Content-Type: application/json

{"link": "dashboard", "title": "Dashboard"}

DELETE /api/account/favorites
Content-Type: application/json

{"link": "dashboard"}

Get / set preferred UI language​

GET /api/account/language
{"locales": ["en", "de", "fr", "..."], "current": "en"}
PUT /api/account/language
Content-Type: application/json

{"locale": "de"}

Get login history​

GET /api/account/login-history
{
"entries": [
{"ip": "203.0.113.10", "country_code": "US", "login_time": "2026-01-15 12:00:00"}
]
}

Two-factor authentication​

Setup is two calls: POST .../setup generates a pending secret (2FA still off), then POST .../confirm validates a code against it and turns 2FA on. Enrolling a passkey isn't available over the API — use the panel UI at /account/passkeys to add one. Listing and revoking existing passkeys is available below.

GET /api/account/2fa
{"enabled": false}
POST /api/account/2fa/setup
{"secret": "AHFB5DLV55SIZASCJEKFMSUPQ2GW6ZGV", "otpauth_url": "otpauth://totp/username?secret=...&issuer=OpenPanel"}
POST /api/account/2fa/confirm
Content-Type: application/json

{"otp_code": "123456"}
DELETE /api/account/2fa

List / revoke passkeys​

GET /api/account/passkeys
{"passkeys": [{"id": 1, "name": "Passkey", "created_at": "2026-01-15 12:00:00", "last_used_at": ""}]}
DELETE /api/account/passkeys/1

List / create / revoke MCP tokens​

The raw token is only ever returned once, at creation.

GET /api/account/mcp
{"tokens": [{"id": 1, "name": "my token", "token_prefix": "op_mcp_abc123", "read_only": true, "created_at": "2026-01-15 12:00:00"}]}
POST /api/account/mcp
Content-Type: application/json

{"name": "my token", "read_only": true, "expires_in_days": 30}
{"token": "op_mcp_...", "name": "my token", "read_only": true, "expires_in_days": 30}
DELETE /api/account/mcp/1

Get / update notification preferences​

PUT only changes the keys present in the body — keys you omit are left as-is.

GET /api/account/notifications
{"preferences": [{"key": "notify_account_login", "value": true, "label": " account login"}]}
PUT /api/account/notifications
Content-Type: application/json

{"preferences": {"notify_account_login": false}}

Resource Usage​

Latest snapshot​

GET /api/usage
{
"cpu": {"usage": {"pct": 12.5}},
"memory": {"usage_pct": 45, "used": {"human": "900MB"}, "total": {"human": "2GB"}},
"bandwidth": {"usage_pct": 5, "limit": {"human": "100GB"}}
}

Paginated history​

GET /api/usage/history?page=1&per_page=25

Hosting Info​

Read-only informational endpoints, always available.

General hosting info​

GET /api/hosting/info
{"system": "Linux", "node": "openpanel", "release": "5.14.0-...", "version": "...", "machine": "x86_64", "processor": "unknown", "ip": "203.0.113.10", "uptime": "11 days", "load_avg": "2.37, 2.72, 2.75"}

Hosting plan details​

GET /api/hosting/plan
{
"context": "myuser", "plan_webserver": "nginx", "plan_mysql": "mariadb",
"plan_cpu_limit": "4", "plan_ram_limit": "6g", "plan_disk_limit": "50 GB",
"plan_bandwidth": "500", "plan_domains_limit": "10", "plan_websites_limit": "10",
"plan_db_limit": "20", "plan_email_limit": "500", "plan_ftp_limit": "100",
"plan_inodes_limit": "1000000", "plan_max_email_quota": "2G",
"plan_description": "A professional plan", "ns1": "", "ns2": "", "ns3": "", "ns4": ""
}

Allocated remote-access ports​

GET /api/hosting/ports
{"remote_mysql_port": "32770", "remote_postgres_port": "32771"}

Disk Usage & Inodes​

Disk usage (root)​

GET /api/disk-usage

Disk usage for a path​

GET /api/disk-usage/public_html
{
"directory": "public_html",
"entries": [
{"size": "120M", "path": "wp-content"},
{"size": "3.2M", "path": "wp-includes"}
]
}

Inode count (root)​

GET /api/inodes

Inode count for a path​

GET /api/inodes/public_html
{
"directory": "public_html",
"entries": [{"folder": "wp-content", "inode_count": 4200}]
}

Malware Scanner​

List quarantined files​

GET /api/malware-scanner/quarantine
{"quarantine_files": [], "count": 0}

Run a scan​

POST /api/malware-scanner/scan
Content-Type: application/json

{"directory": "/var/www/html"}
{
"directory": "/var/www/html",
"infected_files": [],
"infected_count": 0,
"summary": ["Scanned files: 1234", "Infected files: 0"],
"return_code": 0
}

Backup Wizard​

The single-click "back up my whole account" flow — creates a .tar.gz of the account's docroot volume in the background.

Get backup status and existing backups​

GET /api/backup-wizard/status
{
"in_progress": false,
"in_progress_started": "2026-01-15 12:00:00",
"in_progress_size": "45.2 MB",
"backups": [
{"name": "myuser_backup_2026-01-15_12-00-00.tar.gz", "size_raw": 47448064, "size": "45.2 MB", "mtime": "2026-01-15 12:00:00", "in_progress": false}
]
}

in_progress_started and in_progress_size are only present while a backup is running.

Start a backup​

Fire-and-forget — returns immediately, poll GET /api/backup-wizard/status for progress. Returns 409 if a backup is already running.

POST /api/backup-wizard/create
{"message": "Backup started. Poll GET /api/backup-wizard/status for progress."}

Download a backup​

Streams the .tar.gz. Returns 409 if the requested file is still the one currently being written.

GET /api/backup-wizard/download/myuser_backup_2026-01-15_12-00-00.tar.gz

File Manager​

All paths are relative to /var/www/html/. A path that would escape the account's docroot is always rejected, regardless of how it's phrased.

List a directory​

GET /api/files/subdir?hidden_files=true&page=1
{
"files_info": [{"Permissions": "-rw-r--r--", "Links": "1", "Owner": "1001", "Group": "1001", "Size": "0", "Date": "Aug 10 20:45", "Name": "hello.txt", "LinkTarget": "", "Type": "file"}],
"pagination": {"current_page": 1, "total_pages": 1, "per_page": 500, "total_files": 1},
"limits": {"edit_size_mb": 5, "view_size_mb": 5, "download_size_mb": 2000, "upload_size_mb": 2000},
"extensions": {"extensions": ".txt .md .php ...", "images": ".jpg .png ...", "archives": ".zip .tar.gz ..."}
}

List subfolders (for a copy/move destination picker)​

GET /api/folders/subdir
{"folders": [{"name": "images", "path": "subdir/images", "has_subfolders": false}]}

Create a file / directory​

POST /api/file-manager/new-file
Content-Type: application/json

{"path": "subdir", "filename": "notes.txt"}

POST /api/file-manager/new-directory
Content-Type: application/json

{"path": "subdir", "foldername": "images"}

Upload files​

Multipart — field name must be files (repeatable), plus a path_param field for the destination directory.

POST /api/file-manager/upload
Content-Type: multipart/form-data

path_param=subdir
{"uploaded": ["photo.jpg"], "errors": []}

Rename​

POST /api/file-manager/rename
Content-Type: application/json

{"path": "subdir", "old_name": "old.txt", "new_name": "new.txt"}

Delete​

mode is permanent (default) or trash.

DELETE /api/file-manager/delete?filename=old.txt&path_param=subdir&item_type=file&mode=trash

Change permissions​

Applies one octal mode to one or more files at once.

POST /api/file-manager/permissions
Content-Type: application/json

{"path": "subdir", "filenames": ["a.txt", "b.txt"], "permissions": "644"}
{"changed": ["a.txt", "b.txt"], "errored": []}

Copy / move​

POST /api/file-manager/copy?item_name=a.txt&item_type=file&path_param=subdir&destination_path=other/a.txt

POST /api/file-manager/move?item_name=a.txt&path_param=subdir&destination_path=other/a.txt

Get / save file content​

GET returns the raw content as a bare JSON string (not wrapped in an object). Both are limited to editable text file types; PUT also normalizes line endings for known text file types.

GET /api/file-manager/edit-file/subdir/notes.txt
"file content here"
PUT /api/file-manager/edit-file/subdir/notes.txt
Content-Type: application/json

{"content": "new file content"}

Download / view a file​

Download streams application/octet-stream with a Content-Disposition attachment header; view returns the raw content as text/plain (or serves the file directly for images). Both take path_param as a query parameter for the parent directory.

GET /api/file-manager/download-file/subdir/notes.txt
GET /api/file-manager/view-file/subdir/notes.txt

Extract / create an archive​

Supports zip, tar, tar.gz/tgz, and single-file gzip. extract_destination defaults to the archive's own directory; omit or use / for the docroot itself.

POST /api/file-manager/extract-archive
Content-Type: application/json

{"archive_name": "backup.zip", "path": "subdir", "extract_destination": "subdir/extracted"}
POST /api/file-manager/create-archive
Content-Type: application/json

{"archive_name": "backup", "extension": "zip", "path": "subdir", "selected_files": ["a.txt", "b.txt"]}

Download a file from a URL​

Fire-and-forget. Only public http/https URLs are allowed — loopback, private, and link-local addresses are rejected. Returns a download_id to poll.

POST /api/file-manager/wget
Content-Type: multipart/form-data

url=https://example.com/file.zip
path_param=subdir
GET /api/file-manager/wget/status/<download_id>
{"progress": 100, "status": "done", "message": "File downloaded from URL successfully to /var/www/html/subdir"}

Trash​

List trashed items​

GET /api/trash
[{"Permissions": "-rw-r--r--", "Name": "old.txt", "Type": "file", "DeletionDate": "2026-01-15T12:00:00", "OriginalPath": "/var/www/html/subdir/old.txt"}]

Restore / permanently delete one item​

POST /api/trash/restore?filename=old.txt

DELETE /api/trash/delete?filename=old.txt

Restore / delete everything​

POST /api/trash/restore-all
{"restored": ["old.txt"], "errors": []}
POST /api/trash/delete-all

Webserver Config​

Get config​

GET /api/webserver-conf
{
"web_server": "nginx",
"label": "Nginx",
"filename": "nginx.conf",
"service": "nginx",
"content": "server { ... }"
}

Save config​

Validated and restarted automatically; the previous content is restored if the syntax check fails.

PUT /api/webserver-conf
Content-Type: application/json

{"content": "server {\n listen 80;\n ...\n}"}

Process Manager​

List running processes​

GET /api/process-manager
{
"processes": [
{"container": "nginx", "uid": "0", "pid": "1234", "ppid": "1", "cpu": "0.0", "stime": "12:00", "tty": "?", "time": "00:00:01", "cmd": "nginx: master process"}
],
"count": 1
}

Kill a process​

DELETE /api/process-manager/1234

Fix Permissions​

List directories​

GET /api/fix-permissions
{"directories": ["/var/www/html/example.com", "/var/www/html/example.com/wp-content"]}

Fix permissions​

Omit directory (or pass /) to fix the whole account; otherwise it must resolve inside /var/www/html/.

POST /api/fix-permissions
Content-Type: application/json

{"directory": "/var/www/html/example.com"}

Sites & Websites​

List all websites​

GET /api/sites

Get website details​

GET /api/sites/example.com

Google Safe Browsing check​

GET /api/sites/example.com/safebrowsing
{"status": "safe", "url": "http://example.com", "threats": []}

Get / trigger PageSpeed data​

GET /api/sites/example.com/pagespeed
POST /api/sites/example.com/pagespeed

Get / run WP vulnerability scan​

GET /api/sites/example.com/wp-vulnerability
POST /api/sites/example.com/wp-vulnerability
GET /api/sites/example.com/temporary-link

Get recent visitors​

GET /api/sites/example.com/visitors?seconds=60
{"domain": "example.com", "seconds": 60, "count": 3, "ips": ["203.0.113.10", "..."]}

Get WordPress site info​

PHP/MySQL/WP versions and database credentials for a WordPress site. Supports subfolder sites (e.g. example.com/blog).

GET /api/sites/example.com/wp-info
{
"wp_version": "6.7.1",
"php_version": "8.2",
"mysql_version": "10.11.6",
"database_info": {"database_host": "mariadb", "database_name": "wp_db", "database_user": "wp_user", "database_password": "...", "database_table_prefix": "wp_"}
}

WordPress​

List all WordPress installations​

GET /api/wordpress

List backups​

GET /api/wordpress/example.com/backups
{
"domain": "example.com",
"backups": [
{"date": "2025-01-15_12-00-00", "hasDbBackup": true, "hasFilesBackup": true}
]
}

Create backup​

POST /api/wordpress/example.com/backups
Content-Type: application/json

{"backup_database": true, "backup_files": true}
{"message": "Backup completed successfully", "timestamp": "2025-01-15_12-00-00"}

Restore a backup​

POST /api/wordpress/example.com/restore
Content-Type: application/json

{"backup_date": "2025-01-15_12-00-00"}
{"message": "Backup restored: files and database", "restored": ["files", "database"]}

List available hardening rules​

GET /api/wordpress/secure
{"rules": ["wp_manager_xmlrpc", "wp_manager_user_enum", "wp_manager_wlwmanifest"]}

Get active hardening rules​

GET /api/wordpress/example.com/secure

Apply hardening rules​

PUT /api/wordpress/example.com/secure
Content-Type: application/json

{"rules": ["wp_manager_xmlrpc", "wp_manager_wlwmanifest"], "disable_all": false}

Disable all rules:

PUT /api/wordpress/example.com/secure
Content-Type: application/json

{"disable_all": true}

Uninstall WordPress​

Removes files and drops the database. Note the path is /api/wordpress/sites/<id> (the numeric sites.id, not the domain).

DELETE /api/wordpress/sites/42

Detach from WordPress Manager​

Removes from manager, keeps files.

POST /api/wordpress/sites/42/detach

Reload WP data from filesystem​

Rescans /var/www/html for wp-config.php files and refreshes site metadata.

POST /api/wordpress/reload

WP-CLI actions​

Available actions: core_update, core_update_check, plugin_update_all, theme_update_all, list_plugins, list_themes, cache_flush, cron_run.

POST /api/wp-cli/plugin_update_all
Content-Type: application/json

{"domain": "example.com"}
{
"action": "plugin_update_all",
"domain": "example.com",
"stdout": "Success: Updated 3 of 3 plugins.",
"stderr": "",
"returncode": 0
}

Node.js / Python Apps (PM2)​

Tail app logs​

GET /api/pm2/example.com/logs?lines=100

Start / stop / restart application​

Restart pulls the latest image first.

POST /api/pm2/example.com/start
POST /api/pm2/example.com/stop
POST /api/pm2/example.com/restart

Update app settings​

Any of version, requirements, startup_file, custom_cmd, workdir, cpu, ram may be sent.

PATCH /api/pm2/example.com
Content-Type: application/json

{
"version": "20",
"startup_file": "/var/www/html/index.js",
"cpu": "1",
"ram": "1"
}

Delete app​

Stops the container, reverts the webserver config, removes the service from docker-compose.yml, and deletes it from the database.

DELETE /api/pm2/example.com

Install a new app​

Creates the app (the endpoints above then manage it). The request body is form-encoded (not JSON), and the response streams newline-delimited JSON status/error events as the install progresses, rather than a single JSON object.

domain_id and service_name are required. Optional: startup_file, cpu_limit, mem_limit, port, subdirectory, version (defaults to latest), custom_cmd, requirements, git_repo_url.

POST /api/nodejs/install
Content-Type: application/x-www-form-urlencoded

domain_id=12&service_name=myapp&version=20&startup_file=index.js
POST /api/python/install
Content-Type: application/x-www-form-urlencoded

domain_id=12&service_name=myapp&version=3.12&startup_file=app.py
{"status": "Checking if existing installation processes are running.."}
{"status": "Validating provided data"}
{"status": "Service added successfully", "success": true}
{"status": "Starting docker container.."}

Helpers​

Small utility endpoints backing the Node.js/Python install forms.

Get available runtime versions​

type is nodejs or python. Proxies endoflife.date's release feed, cached 24h.

GET /api/docker/tags/nodejs

Check whether a file exists in the app's docroot​

Only .py and .js files are checked.

POST /api/helpers/check-file-exists
Content-Type: application/json

{"file": "app.py"}
{"file": "app.py", "exists": true}

Detect a Git repo's startup file​

Clones the repository to detect its entry point (package.json's main, or common filenames like index.js/app.py).

POST /api/helpers/detect-git-startup-file
Content-Type: application/json

{"git_repo_url": "https://github.com/user/repo.git", "app_type": "nodejs"}
{"startup_file": "index.js"}

Auto Installer​

List installed apps​

GET /api/autoinstaller
{
"sites": [{"site_name": "example.com", "type": "WordPress"}],
"counts": {"wordpress": 1, "drupal": 0, "sitebuilder": 0, "node": 0, "python": 0, "java": 0, "ruby": 0, "bun": 0, "mautic": 0, "flarum": 0, "fossbilling": 0},
"technologies": ["wordpress", "drupal", "sitebuilder", "node", "python", "java", "ruby", "bun", "mautic", "flarum", "fossbilling"],
"domain_count": 1
}

Dynamic DNS​

List all entries​

GET /api/dynamic-dns
{
"domains": {
"example.com": [
{"line_number": 14, "subdomain": "home", "ttl": "300", "type": "A", "record": "1.2.3.4", "token": "abc123xyz", "last_updated": "2025-01-15T12:00:00Z", "raw_line": "home 300 IN A 1.2.3.4 ; webcall=abc123xyz updated=2025-01-15T12:00:00Z"}
]
}
}

Create entry​

POST /api/dynamic-dns
Content-Type: application/json

{"domain": "example.com", "subdomain": "home", "ip": "0.0.0.0"}
{"message": "Dynamic DNS entry created", "entry": {"subdomain": "home", "record": "0.0.0.0", "type": "A", "token": "abc123xyz"}}

Update entry​

domain, line_number, subdomain, ip, and token are all required.

PUT /api/dynamic-dns
Content-Type: application/json

{
"domain": "example.com",
"line_number": 14,
"subdomain": "home",
"ip": "1.2.3.4",
"token": "abc123xyz"
}

Delete entry​

DELETE /api/dynamic-dns
Content-Type: application/json

{"domain": "example.com", "line_number": 14}

Auto-update IP (no auth required)​

Called by your router or DDNS client to update the record with the caller's IP:

GET /dynamic-dns/update?token=abc123xyz

DNS Zone Editor​

A standalone zone editor at /api/dns/..., separate from the domain-scoped DNS endpoints under /api/domains/<domain>/dns.... The two overlap in purpose but use different field names — this one's parsed records use line/multiline (not record), and new records use the field record (not value).

List domains with zone status​

GET /api/dns
{"domains": [{"domain_id": 1, "domain_url": "example.com", "zone_file_exists": true}]}

Get parsed DNS records​

GET /api/dns/example.com
{
"domain": "example.com",
"serial": "2025011501",
"records": [
{"line_number": 12, "end_line_number": 12, "line": "www 3600 IN A 1.2.3.4", "multiline": false}
],
"validation_error": null
}

Get raw zone file​

GET /api/dns/example.com/raw

Save raw zone file​

PUT /api/dns/example.com/raw
Content-Type: application/json

{"content": "$ORIGIN example.com.\n$TTL 3600\n..."}

Add a DNS record​

POST /api/dns/example.com/records
Content-Type: application/json

{"name": "www", "ttl": "3600", "type": "A", "record": "1.2.3.4"}

MX example:

{"name": "example.com.", "ttl": "3600", "type": "MX", "priority": "10", "record": "mail.example.com."}

TXT / SPF example:

{"name": "example.com.", "ttl": "3600", "type": "TXT", "record": "v=spf1 include:_spf.example.com ~all"}

Update a record by line number​

PATCH /api/dns/example.com/records/12
Content-Type: application/json

{"content": "www 3600 IN A 5.6.7.8", "serial": "2025011501"}

The serial field prevents concurrent-edit conflicts — pass the serial you read when fetching the zone.

Delete a record​

DELETE /api/dns/example.com/records/12

Multi-line record (e.g. DKIM):

DELETE /api/dns/example.com/records/15
Content-Type: application/json

{"end_row_id": 18}

Reset zone to default template​

POST /api/dns/example.com/reset

Export zone file​

Unlike the domain-scoped export (which streams a file download), this returns the zone content as JSON.

GET /api/dns/example.com/export
{"domain": "example.com", "content": "$ORIGIN example.com.\n...", "filename": "example.com.zone"}

Traffic Stats (GoAccess)​

List domains with stats availability​

GET /api/stats
{"domains": [{"domain_url": "example.com", "has_stats": true}]}

Get GoAccess report for a domain​

GET /api/stats/example.com
{
"domain": "example.com",
"available": true,
"html": "<!DOCTYPE html>..."
}

Stats are generated every 24 hours by the GoAccess daemon.


System Services​

List all services​

GET /api/services
{
"services": [
{"service": "nginx", "container_state": "running", "health_status": "healthy"},
{"service": "mysql", "container_state": "running", "health_status": "healthy"}
]
}

Get service status​

GET /api/services/nginx
{
"service": "nginx",
"container_state": "running",
"health_status": "healthy",
"available_actions": ["disable"]
}

Start / stop / restart a service​

POST /api/services/nginx
Content-Type: application/json

{"action": "enable"}
{"message": "Service nginx enabled successfully", "service": "nginx", "container_state": "running", "health_status": "healthy"}

Webmail​

Get webmail info​

GET /api/webmail
{"is_running": true, "webmail_url": "https://webmail.example.com/"}

Generate auto-login token​

Creates a single-use token for automatic Roundcube login.

POST /api/webmail/[email protected]
{
"email": "[email protected]",
"token": "abc123...",
"autologin_url": "https://webmail.example.com/autologin.php?token=abc123...",
"webmail_url": "https://webmail.example.com/"
}

Website Builder​

Get HTML and CSS content​

GET /api/website-builder/example.com
{
"domain": "example.com",
"docroot": "/var/www/html",
"html": "<!DOCTYPE html>...",
"css": "* { box-sizing: border-box; }"
}

Save content​

PUT /api/website-builder/example.com
Content-Type: application/json

{
"html": "<!DOCTYPE html><html>...</html>",
"css": "body { margin: 0; }"
}

Create a new site​

POST /api/website-builder
Content-Type: application/json

{"domain_id": 1, "subdirectory": ""}
{"message": "Website created successfully on example.com", "site_name": "example.com"}

Remove a site (deletes files)​

DELETE /api/website-builder/sites/7

Detach a site (keeps files)​

POST /api/website-builder/sites/7/detach

Dashboard​

Resource usage widget​

Same data the dashboard's live usage widget polls.

GET /api/dashboard/resource-usage
{
"cpu": {"usage": {"pct": 26, "human": "0.3 cores"}, "total": {"pct": 800, "human": "8.0 cores"}},
"memory": {"usage_pct": 11, "used": {"human": "723.5M"}, "total": {"human": "6.0G"}},
"bandwidth": {"usage_pct": 0, "total_sent": {"human": "0bit"}},
"tasks": {"current": 220, "limit": 900, "usage_pct": 24},
"uid": 1001, "user": "myuser", "timestamp": "2026-01-15T12:00:00Z"
}

Disk / inode usage widget​

GET /api/dashboard/disk-inodes
{"inodes_used": 87581, "inodes_soft": 1000000, "inodes_hard": 1000000, "disk_used": 2838644, "disk_soft": 51200000, "disk_hard": 51200000, "device": "/home/myuser/", "date": "2026-01-15T12:00:00Z"}

Plugins​

Only present if at least one plugin is installed on the server (GET /api/endpoints won't list it otherwise).

List installed plugins​

GET /api/plugins
{"plugins": [{"name": "example-plugin", "..."}]}

Error Responses​

All endpoints return standard HTTP status codes:

CodeMeaning
200OK
201Created
202Accepted (async operation started)
400Bad request / missing required field
401Unauthorized (missing or invalid token)
403Forbidden (feature not enabled or domain not owned)
404Resource not found
409Conflict (e.g. serial mismatch, resource already exists)
500Internal server error
503Service unavailable
504Timeout

Error responses always include an error field:

{"error": "You do not own this domain"}