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...",
"user_id": 1,
"expires_in": 3600
}
If the account has two-factor authentication enabled and no twofa_code was sent, the response is instead:
{"twofa_required": true, "user_id": 1}
Retry the request with twofa_code included in the body. Login attempts are rate-limited per IP; exceeding the limit returns 429 Too Many Requests.
Step 2 — Use it on every request:
curl https://panel.example.com/api/account \
-H "Authorization: Bearer eyJhbGciOi..."
Tokens expire after 1 hour (expires_in is in seconds). 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
}
Kill a running query​
Runs KILL QUERY. Only entries whose command is Query can be killed, system threads are refused (403). Returns 404 if the process is gone or no longer running a query.
POST /api/mysql/processlist/42/kill
{"id": "42", "status": "killed"}
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"}}
Configuration recommendations​
Checks the live server (memory limit, data per storage engine, status counters, connections and error log) and suggests values for the configurable keys, with the reason for each and the facts they are based on. Returns 503 if the database service can't be reached.
GET /api/mysql/configuration/recommendations
{
"recommendations": [
{"key": "max_connections", "label": "Max Connections", "current": "151", "recommended": "100", "reason": "..."}
],
"facts": {"memory_limit": "1 GB", "databases": "3"},
"notes": []
}
phpMyAdmin autologin​
Returns a one-click phpMyAdmin link that logs straight into the user's MySQL server — the API twin of the phpMyAdmin button in the panel. Requires the phpmyadmin feature. Pass ?db=<name> to have it included in the link.
The link stays valid until a new one is generated (by this endpoint or the panel button), so open it right away and don't share it. login_url is the regular phpMyAdmin login form for this user, for logging in with a database user's own credentials. Returns 503 if phpMyAdmin isn't running on the server.
GET /api/phpmyadmin
Authorization: Bearer <token>
{
"url": "https://panel.example.com:2053/pma.php?user=stefan&token=3f9c...",
"login_url": "https://panel.example.com:2053/index.php?manual=stefan"
}
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
}
Cancel a running query​
Runs pg_cancel_backend, the connection stays open. Only active client backend queries can be cancelled (403 otherwise). Returns 404 if the process is gone or no longer running a query.
POST /api/postgresql/processlist/123/kill
{"pid": "123", "status": "killed"}
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"}
Configuration recommendations​
Checks the live server (memory and CPU limits, data size, statistics, connections and log) and suggests values for the configurable keys, same response shape as MySQL. Returns 503 if the database service can't be reached.
GET /api/postgresql/configuration/recommendations
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": [
]
}
Create mailbox​
domain, username, and password are all required. Optional gb + format set a custom quota, and send_welcome: true drops a mail client setup guide into the new mailbox.
POST /api/emails
Content-Type: application/json
{"domain": "example.com", "username": "info", "password": "mailpass123"}
Get mailbox details​
GET /api/emails/[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": [
]
}
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]
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]
{
"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": "..."}
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"}
Get / set cron time zone​
The time zone the cron service runs schedules in, from its TZ, the server zone when /etc/localtime is mounted, or UTC. Setting it writes TZ on the cron service in docker-compose.yml and recreates the service when a job is enabled.
GET /api/crons/timezone
{"timezone": "Europe/Belgrade"}
PUT /api/crons/timezone
Content-Type: application/json
{"timezone": "Europe/Belgrade"}
{"message": "Cron time zone set to Europe/Belgrade", "timezone": "Europe/Belgrade"}
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/PIDs limits​
PATCH /api/containers/myapp/resources
Content-Type: application/json
{"cpu": "1", "ram": "512m", "pids": "512"}
All three fields are optional — send only the ones you want to change.
{"message": "Resources updated", "results": {"cpu": "...", "ram": "...", "pids": "Max PIDs for container myapp set to 512"}}
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": []}
Set WAF mode​
status is On, Off or DetectionOnly (monitor only, logs but never blocks).
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"}
PHP options recommendations​
Checks the PHP service (memory limit, workers, log), the domains and WordPress sites using this version and the current options, and suggests values with the reason for each, same response shape as the MySQL recommendations. Returns 400 for an invalid version and 404 if it isn't installed.
GET /api/php/8.2/options/recommendations
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"}
PHP App Manager​
Installs and manages a Composer-based PHP project in an existing domain's docroot, run inside whichever shared php-fpm-<version> container that domain's PHP version already points to. Unlike the Node.js/Python app installer below, this never creates a dedicated container — the domain's existing vhost already routes to the right php-fpm container.
Install a PHP app​
Form-encoded body; response streams newline-delimited JSON progress events.
POST /api/php/install
Re-run composer install / update​
POST /api/php/apps/composer-install/example.com
POST /api/php/apps/composer-update/example.com
Content-Type: application/json
{"optimize_autoloader": "true"}
Get Composer run log​
GET /api/php/apps/logs/example.com
Response is plain text ("No Composer runs recorded yet." if none).
Remove a PHP app​
Removes the app's tracking entry and .env settings — docroot files and the database, if any, are left untouched.
DELETE /api/php/apps/example.com
Account​
Get account info​
GET /api/account
{
"username": "stefan",
"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"}
]
}
Get activity log​
Paginated, newest first. search filters by substring (forces show_all); show_all=true returns every matching line on one page.
GET /api/account/activity?page=1&search=&show_all=false
{
"rows": [
{"Timestamp": "2026-01-15 12:00:00", "IP": "203.0.113.10", "User": "myuser", "Action": "logged in via user API"}
],
"page": 1, "per_page": 100, "total_pages": 3, "total_lines": 214,
"show_all": false, "search": ""
}
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"}
Combined server info​
The same three payloads above (info/plan/ports), in one call — what the /server/info panel page itself fetches, bundled.
GET /api/server/info
{
"info": {"system": "Linux", "node": "openpanel", "release": "5.14.0-...", "ip": "203.0.113.10", "uptime": "11 days", "load_avg": "2.37, 2.72, 2.75"},
"plan": {"context": "myuser", "plan_webserver": "nginx", "plan_mysql": "mariadb", "plan_cpu_limit": "4", "plan_ram_limit": "6g"},
"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}
List past scans​
Scans from the panel, the API and the automatic (cron) scans, newest first. The last 50 are kept.
GET /api/malware-scanner/logs
{
"logs": [
{
"started_at": "2026-10-01 03:00:00",
"finished_at": "2026-10-01 03:02:10",
"source": "cron",
"path": "/var/www/html/",
"scanned": 1200,
"infected": 1,
"errors": 0,
"status": "completed",
"findings": [
{"path": "/var/www/html/example.com/x.php", "result": "Malware detected: Eicar-Test-Signature (quarantined)"}
]
}
],
"count": 1
}
source is panel, api or cron. status is completed, aborted (the page was closed during a panel scan) or failed, with the reason in message.
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
}
The scan is also saved in the scan logs.
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
Backups​
The configurable backup destination flow (S3, WebDAV, SSH, Azure, Dropbox) — separate from the one-click Backup Wizard above. values holds the active destination's own credential keys (varies per destination); settings holds the shared schedule/retention/notification keys.
Get status summary​
GET /api/backups
{"target": "s3", "service_active": true, "values": {"AWS_ACCESS_KEY_ID": "..."}, "settings": {"BACKUP_RETENTION_DAYS": "7"}}
Get / update settings​
GET /api/backups/settings
PUT /api/backups/settings
Content-Type: application/json
{"values": {"AWS_ACCESS_KEY_ID": "AKIA..."}, "settings": {"BACKUP_RETENTION_DAYS": "7"}}
Get / switch the active destination​
GET /api/backups/destination
{"active": "s3", "targets": ["s3", "webdav", "ssh", "azure", "dropbox"]}
PUT /api/backups/destination
Content-Type: application/json
{"target": "s3"}
List backups at the destination​
Served from a cached index. Run a reindex (below) to pick up backups created since the last one.
GET /api/backups/list
[{"backup_file": "myuser-2026-01-15T12-00-00.tar.gz", "types": ["html", "mysql", "crons"], "databases": ["wp_db"], "has_files": true, "has_crons": true}]
Add ?status=1 to get reindex progress instead of the list:
GET /api/backups/list?status=1
{"reindexing": false, "error": "", "count": 12}
Reindex the destination​
Rescans the configured destination in the background, same as the Reindex Destination button in the panel. Returns 202 when started, or 409 if a reindex is already running. Poll GET /api/backups/list?status=1 until reindexing is false, then fetch the list.
POST /api/backups/reindex
{"message": "Reindex started."}
Restore from a backup​
restore_target is all (files + databases), database (a single database — set database to its name), or files (files only).
POST /api/backups/restore
Content-Type: application/json
{"backup_file": "myuser-2026-01-15T12-00-00.tar.gz", "restore_target": "all", "database": ""}
{"success": true, "message": "Full restore completed."}
Download a backup​
Streams the archive from the configured destination.
POST /api/backups/download
Content-Type: application/json
{"backup_file": "myuser-2026-01-15T12-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​
Omit the path segment (GET /api/files) to list the docroot root.
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​
Fix permissions​
Sets you as the owner of your website files, sets files to 664 and folders to 775, and fixes ownership of FTP account folders, same as the Fix Permissions page. Emails, databases and file contents are not changed.
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"}
{"message": "Permissions fixed."}
Returns 400 with {"error": "Invalid directory"} for a path outside /var/www/html/, and 500 with {"error": "Failed to fix permissions."} if the fix fails.
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