Infrastructure-as-code: see TERRAFORM.md for the Terraform provider resource mapping. The machine-readable spec is served at
GET /api-docs/openapi.json.
https://your-panel.example.com/api/v1
Most /api/v1 endpoints require a Bearer API key.
Authorization: Bearer hpg_live_xxxxxxxxxxxx
Admin keys - issued at Admin → API Keys.
Client keys - issued at App → API Keys.
Role enforcement is per-endpoint. Keys that belong to a disabled or demoted user are rejected on every request (roles are re-checked live, not cached in the token).
An admin key's reach is derived from the owning user, not the token:
| Owner | Data reach | Global infra (nodes / pools / plans) | Client provisioning |
|---|---|---|---|
super_admin / unrestricted admin / api |
all clients | allowed | allowed |
Reseller-admin (users.reseller_id set, reseller active) |
only that reseller's clients | denied (403 global admin scope required) |
allowed (client owned by its reseller) |
Suspended-reseller admin (resellers.status != 'active') |
none - hard fail-closed | denied | denied |
Client-scoped admin (users.is_restricted=1 + admin_client_scope rows) |
only assigned clients | denied | denied |
client |
only its own client | n/a | n/a |
List endpoints return only in-scope rows; single-resource reads and mutations
return 403 for out-of-scope ids. A reseller-admin key may create and delete
clients owned by its reseller, but never touches shared node infrastructure.
Plan management via API key is platform-admin only (a reseller-admin manages its
own plans through the panel UI, not the API); global plans stay platform-only.
All responses use Content-Type: application/json.
Success - status varies by operation (200, 201, 204).
Error
{ "error": "human-readable message" }Authenticated /api/v1 endpoints share a per-key requests-per-minute cap stored on the key record. Exceeding the cap returns:
HTTP 429 Too Many Requests
Retry-After: 60
{"error":"rate_limit_exceeded","cap_rpm":120}
Unauthenticated endpoints (WireGuard bootstrap, node join) are rate-limited per source IP. Those limits are documented per-endpoint below.
| Role | Access |
|---|---|
admin / super_admin |
All write operations |
client |
Read own services and routes; create/delete own routes |
Returns API version and liveness status. No authentication required.
Response 200
{
"status": "ok",
"version": "0.1.174"
}A service links a client to a backend IP address and an allowed port range (e.g. 30000-30019). Routes are then mapped from domains to ports within that range.
Create a service.
Auth: Bearer token - admin only.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
client_id |
integer | yes | ID of the client to assign the service to |
name |
string | yes | Human-readable label |
backend_ip |
string (IPv4) | yes | Backend IP of the customer VPS |
allowed_port_start |
integer | yes | First port in allowed range (1-65534) |
allowed_port_end |
integer | yes | Last port in allowed range (2-65535) |
plan_id |
integer | yes | Plan ID; determines node group and domain limits |
external_reference |
string | no | Billing system reference (e.g. fossbilling-service-99) |
{
"client_id": 7,
"name": "Acme VPS #1",
"backend_ip": "10.0.1.55",
"allowed_port_start": 30000,
"allowed_port_end": 30019,
"plan_id": 3,
"external_reference": "fossbilling-service-99"
}Response 201
{ "id": 42 }Errors
| Code | Meaning |
|---|---|
| 400 | Missing required fields, invalid IP, or invalid port range |
| 401 | Missing or invalid bearer token |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
| 500 | Database error |
Get a single service.
Auth: Bearer token. Admins can fetch any service; clients may only fetch their own.
Response 200
{
"id": 42,
"client_id": 7,
"name": "Acme VPS #1",
"backend_ip": "10.0.1.55",
"allowed_port_start": 30000,
"allowed_port_end": 30019,
"plan_id": 3,
"status": "active",
"external_reference": "fossbilling-service-99",
"created_at": "2026-01-15T08:30:00Z"
}status values: active, suspended, terminated.
Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Not your service (client accessing another client's service) |
| 404 | Service not found |
| 429 | Rate limit exceeded |
Update a service.
Auth: Bearer token - admin only.
All fields are optional; omit to leave unchanged.
Request body
| Field | Type | Notes |
|---|---|---|
status |
string | active, suspended, or terminated |
external_reference |
string | Billing system reference |
notes |
string | Internal admin notes |
{
"status": "suspended",
"external_reference": "new-ref-123"
}Response 200
{ "id": 42, "updated": true }Errors
| Code | Meaning |
|---|---|
| 400 | Invalid status value |
| 401 | Auth required |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
| 500 | Database error |
Replace the allowed port range for a service.
Auth: Bearer token - admin only.
Existing routes that fall outside the new range must be deleted before calling this endpoint.
Request body
{
"allowed_port_start": 31000,
"allowed_port_end": 31019
}Response 200
{ "id": 42 }Errors
| Code | Meaning |
|---|---|
| 400 | Invalid port range |
| 401 | Auth required |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
| 500 | Database error |
List routes attached to a service.
Auth: Bearer token. Clients may only query their own services.
Use the status field to track SSL provisioning progress.
Response 200
{
"routes": [
{
"id": 201,
"domain": "app.example.com",
"path_prefix": "/api",
"upstream_port": 30005,
"status": "active"
}
]
}status values: active, pending_ssl, error, inactive.
Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Not your service |
| 404 | Service not found |
| 429 | Rate limit exceeded |
A route maps a domain (and optional path prefix) to a port on a service's backend. Caddy configuration is pushed asynchronously. SSL provisioning may take up to a few minutes after DNS propagates.
Create a route.
Auth: Bearer token. Admins can create routes for any service. Clients can only create routes for services they own.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
service_id |
integer | yes | Service to attach the route to |
upstream_port |
integer | yes | Must be within the service's allowed port range |
domain |
string | yes | Fully-qualified domain name |
path_prefix |
string | no | Optional sub-path routing (e.g. /api) |
ssl |
boolean | no | Default true - enables automatic HTTPS |
websocket |
boolean | no | Default false |
force_https |
boolean | no | Default true - redirect HTTP to HTTPS |
{
"service_id": 42,
"upstream_port": 30005,
"domain": "app.example.com",
"path_prefix": "/api",
"ssl": true,
"websocket": false,
"force_https": true
}Response 201
{ "id": 201 }Caddy config is pushed in the background. Poll GET /api/v1/services/{id}/routes and watch status for SSL provisioning progress.
Errors
| Code | Meaning |
|---|---|
| 400 | Invalid domain or port outside allowed range |
| 401 | Auth required |
| 403 | Service not yours |
| 409 | Domain already mapped, no node available, or plan domain limit reached |
| 429 | Rate limit exceeded |
| 500 | Internal error |
Delete a route.
Auth: Bearer token. Clients may only delete routes on services they own.
Caddy config is updated asynchronously.
Response 200
{ "id": 201, "deleted": true }Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Not your route |
| 429 | Rate limit exceeded |
| 500 | Internal error |
Queue a DNS verification check.
Auth: Bearer token. Clients may only verify routes on services they own.
Caddy issues a TLS certificate once DNS resolves to the node's IP. This call enqueues a background job.
Response 200
{ "id": 201, "queued": true }Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Not your route |
| 429 | Rate limit exceeded |
| 500 | Internal error |
Retry SSL certificate provisioning.
Auth: Bearer token. Clients may only retry routes on services they own.
Alias of verify-dns. Triggers a fresh DNS check and certificate retry on Caddy.
Response 200
{ "id": 201, "queued": true }Errors - same as verify-dns.
Caddy nodes are the reverse-proxy servers that serve customer routes.
List all Caddy nodes, ordered by priority descending.
Auth: Bearer token - admin only.
Response 200
{
"nodes": [
{
"id": 1,
"name": "ams-node-01",
"api_url": "http://10.8.0.2:2019",
"public_hostname": "ams01.proxy.example.com",
"public_ip": "185.10.20.30",
"node_group_id": 1,
"max_routes": 500,
"current_routes": 127,
"enabled": true,
"health": "healthy"
}
]
}health values: healthy, degraded, unknown.
Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
Register a Caddy node manually.
Auth: Bearer token - admin only.
Alternative to the automatic node-join flow. The node starts enabled with health: unknown.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Unique node label |
api_url |
string | yes | Internal Caddy Admin API URL (e.g. http://10.8.0.2:2019) |
public_hostname |
string | no | Public DNS hostname |
public_ip |
string (IPv4) | no | Public IP address |
node_group_id |
integer | yes | Node group this node belongs to |
max_routes |
integer | yes | Maximum number of routes this node can serve |
priority |
integer | no | Higher value = preferred for new route placement (default 0) |
{
"name": "ams-node-01",
"api_url": "http://10.8.0.2:2019",
"public_hostname": "ams01.proxy.example.com",
"public_ip": "185.10.20.30",
"node_group_id": 1,
"max_routes": 500,
"priority": 10
}Response 201
{ "id": 1 }Errors
| Code | Meaning |
|---|---|
| 400 | Missing required fields |
| 401 | Auth required |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
| 500 | Database error |
Rebuild and push the full Caddy route config from the database to a node.
Auth: Bearer token - admin only.
Use after manual DB edits or to recover from config drift. The operation is synchronous with a 15-second timeout.
Response 200
{ "id": 1, "resynced": true }Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required |
| 429 | Rate limit exceeded |
| 500 | Resync failed (message includes reason) |
Register a Caddy node via one-shot join token.
Auth: The hpg_join_ token in the request body is the credential. No Bearer API key is needed. Rate-limited per IP.
This endpoint is called by the node bootstrap script (/install/node.sh). Do not call it manually unless you are building a custom provisioning tool.
Request body
{
"token": "hpg_join_xxxxxxxxxxxxxxxxxxxxxxxx",
"public_hostname": "ams01.proxy.example.com",
"public_ip": "185.10.20.30"
}Response 200
{
"node_id": 1,
"node_name": "ams-node-01",
"fingerprint": "sha256:...",
"wireguard": { }
}Errors
| Code | Meaning |
|---|---|
| 400 | Empty body or invalid JSON |
| 401 | Token invalid, already used, or malformed |
| 429 | Rate limit exceeded |
A reseller is a white-label tenant that owns a set of clients (and optionally its own plans + branding). Reseller management is platform-admin only - a reseller-admin API key passes the role check but is denied by the global-scope gate (403 global admin scope required); it manages its own tenants through the scoped /clients, /services, and /routes endpoints instead.
List all resellers.
Auth: Bearer token - platform-admin only.
Response 200
{
"resellers": [
{
"id": 3,
"name": "Acme Hosting",
"slug": "acme-hosting",
"status": "active",
"brand_name": "Acme Cloud",
"support_email": "support@acme.example",
"reseller_plan_id": 2,
"overselling_allowed": false,
"can_create_plans": true,
"owner_user_id": 41
}
]
}Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin (reseller-admin key) |
| 429 | Rate limit exceeded |
| 500 | Database error |
Get a single reseller, including current package usage.
Auth: Bearer token - platform-admin only.
Response 200
{
"id": 3,
"name": "Acme Hosting",
"slug": "acme-hosting",
"status": "active",
"brand_name": "Acme Cloud",
"support_email": "support@acme.example",
"reseller_plan_id": 2,
"overselling_allowed": false,
"can_create_plans": true,
"owner_user_id": 41,
"usage": {
"Clients": 4,
"MaxClients": 10,
"Services": 6,
"MaxServices": 25,
"Domains": 18,
"MaxDomains": 50,
"Overselling": false,
"Limited": true
}
}usage.Limited is false when the reseller has no package subscribed (unlimited); the Max* fields are then 0 and not enforced.
Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 404 | Reseller not found |
| 429 | Rate limit exceeded |
| 500 | Database error |
Create a reseller and its owner login in one atomic operation.
Auth: Bearer token - platform-admin only.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Display name |
slug |
string | yes | Lowercase alphanumeric/dashes; unique |
owner_email |
string | yes | Login for the reseller-admin owner account |
owner_password |
string | yes | >= 12 characters |
brand_name |
string | no | White-label brand name |
support_email |
string | no | Support contact shown to the reseller's own clients |
{
"name": "Acme Hosting",
"slug": "acme-hosting",
"owner_email": "owner@acme.example",
"owner_password": "correct-horse-battery-staple",
"brand_name": "Acme Cloud",
"support_email": "support@acme.example"
}The owner account is created with role=reseller and users.reseller_id set to the new reseller, and is subscribed to the default "Unlimited" package.
Response 201
{ "id": 3, "owner_user_id": 41 }Errors
| Code | Meaning |
|---|---|
| 400 | Missing/invalid name, slug, owner_email, or owner_password too short |
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 409 | slug or owner_email already exists |
| 429 | Rate limit exceeded |
| 500 | Hash or database error |
Update a reseller's identity, branding, status, and package policy.
Auth: Bearer token - platform-admin only.
All fields are optional; omit to leave unchanged.
Request body
| Field | Type | Notes |
|---|---|---|
name |
string | |
status |
string | active or suspended |
brand_name |
string | |
support_email |
string | |
reseller_plan_id |
integer | Subscribed package (see Reseller plans below) |
overselling_allowed |
boolean | Policy flag |
can_create_plans |
boolean | Policy flag |
{ "status": "suspended" }Setting status: "suspended" revokes every active session for that reseller's users (fail-closed - a reseller-admin cannot keep working through an already-open session).
Response 200 - same shape as GET /api/v1/resellers/{id} (without usage).
Errors
| Code | Meaning |
|---|---|
| 400 | Invalid JSON, or status not active/suspended |
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 404 | Reseller not found |
| 429 | Rate limit exceeded |
| 500 | Database error |
Delete a reseller.
Auth: Bearer token - platform-admin only.
Owned clients, plans, and users have reseller_id reset to NULL (they become platform-direct) - deleting a reseller never cascades a delete of customer data. Sessions of the freed users are revoked.
Response 200
{ "id": 3, "deleted": true }Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 404 | Reseller not found |
| 429 | Rate limit exceeded |
| 500 | Database error |
A reseller plan (package) is the aggregate quota + resource grant tier a reseller subscribes to - analogous to a Plesk "reseller plan" or WHM "account limits". It is distinct from a Plan (plans table), which is the quota a client's service uses; a reseller plan caps the reseller's totals across all of its clients/services/domains and grants which node pools and route features the reseller may use when authoring its own plans.
List all reseller packages.
Auth: Bearer token - platform-admin only.
Response 200
{
"reseller_plans": [
{
"id": 2,
"name": "Growth",
"max_clients": 10,
"max_services_total": 25,
"max_domains_total": 50,
"rate_limit_rpm_cap": 600,
"node_group_ids": [1, 2],
"features": ["ssl", "wildcard", "websocket"]
}
]
}0 on any max_*/rate_limit_rpm_cap field means unlimited/uncapped.
Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 429 | Rate limit exceeded |
| 500 | Database error |
Create a reseller package.
Auth: Bearer token - platform-admin only.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Unique package name |
max_clients |
integer | no | 0 = unlimited |
max_services_total |
integer | no | 0 = unlimited |
max_domains_total |
integer | no | 0 = unlimited |
rate_limit_rpm_cap |
integer | no | 0 = uncapped |
node_group_ids |
array of integer | no | Node pools this package may place services on |
features |
array of string | no | Grantable set: ssl, wildcard, websocket, path, external, waf, geo, l4, cache, rate_limit, dns01, weighted_lb |
{
"name": "Growth",
"max_clients": 10,
"max_services_total": 25,
"max_domains_total": 50,
"rate_limit_rpm_cap": 600,
"node_group_ids": [1, 2],
"features": ["ssl", "wildcard", "websocket"]
}Response 201
{ "id": 2 }Errors
| Code | Meaning |
|---|---|
| 400 | name missing |
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 409 | Package name already exists |
| 429 | Rate limit exceeded |
| 500 | Database error |
Replace a reseller package's limits and grant sets.
Auth: Bearer token - platform-admin only.
This is a full replace, not a partial patch: node_group_ids and features are wholesale-replaced with the arrays supplied, so omitting them clears the grant. Request body is the same shape as create.
Response 200
{ "id": 2, "updated": true }Errors
| Code | Meaning |
|---|---|
| 400 | name missing |
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 409 | Package name already exists |
| 429 | Rate limit exceeded |
| 500 | Database error |
Delete a reseller package.
Auth: Bearer token - platform-admin only.
Response 200
{ "id": 2, "deleted": true }Errors
| Code | Meaning |
|---|---|
| 401 | Auth required |
| 403 | Admin role required, or caller is not a global-scope admin |
| 404 | Package not found |
| 409 | Package still in use - reassign resellers to a different package first |
| 429 | Rate limit exceeded |
| 500 | Database error |
POST /api/v1/clients and POST /api/v1/services enforce the caller's reseller package limits (internal/quota) when the target client/service belongs to a reseller. Exceeding a limit returns 403 with the standard error envelope:
{ "error": "reseller quota: client limit reached" }{ "error": "reseller quota: service limit reached" }{ "error": "reseller quota: domain limit reached" }These are business limits, not authorization failures - they only trigger for resellers that are actually subscribed to a package (a reseller with no package, or a platform-direct client, is never limited). Domain (route) capacity is enforced either as an allocation check at service-create time (sum of attached plans' max_domains against the package total) or, when overselling_allowed is on, as a real route count at route-create time.
These endpoints serve customer-side WireGuard tunnel setup. They are not under /api/v1 and use a different authentication scheme.
Authentication is via a single-shot 192-hex-char bootstrap token passed as ?token=. Tokens have a 24-hour TTL and are invalidated on first use. All endpoints are rate-limited to 10 requests per minute per source IP.
Download the WireGuard .conf file for a customer tunnel.
The token is consumed on first call. Subsequent calls with the same token return 403. Use the installer script endpoint instead of calling this directly.
Response 200 - text/plain WireGuard configuration file
[Interface]
PrivateKey = ...
Address = 100.96.5.42/32
[Peer]
PublicKey = ...
Endpoint = ams01.proxy.example.com:51820
Response header: Content-Disposition: attachment; filename="hostyt-tunnel-<id>.conf"
Errors
| Code | Meaning |
|---|---|
| 403 | Token invalid, expired, or already consumed |
| 429 | Rate limit exceeded |
| 500 | Internal error |
Download the bash installer script.
The script installs wireguard-tools, fetches /api/wg/bootstrap, and creates a systemd unit.
# Install
curl 'https://your-panel.example.com/api/wg/install.sh?token=<token>' | sudo bash
# Remove
curl 'https://your-panel.example.com/api/wg/install.sh?token=<token>' | sudo bash -s -- removeThe bootstrap token is embedded in the script and consumed when the script calls /api/wg/bootstrap. Running the script a second time with the same URL is safe - it detects the existing config and validates/repairs the tunnel instead of re-downloading.
Response 200 - text/x-shellscript
Errors
| Code | Meaning |
|---|---|
| 400 | Missing or malformed token (must be exactly 192 chars) |
| 429 | Rate limit exceeded |
| 503 | Panel URL not configured or tunnel transport not ready |
Poll whether the customer WireGuard peer has established a handshake.
Response 200
{
"status": "active",
"last_handshake": "2026-06-24T10:00:00Z"
}status values: pending, active.
last_handshake is null when no handshake has occurred.
Errors
| Code | Meaning |
|---|---|
| 400 | Missing or malformed token |
| 404 | Unknown token |
| 429 | Rate limit exceeded |
These endpoints are called by hpg-node-agent on each Caddy node. They are not under /api/v1.
Authentication: Authorization: Bearer <agent_token> or ?node_token=<agent_token>.
The agent token is the per-node token stored as a SHA-256 hash in caddy_nodes.agent_token_hash. It is issued during node join or rotated via Admin → Nodes → Rotate.
Pull the list of WireGuard peers this node should configure.
Called by the agent every ~30 seconds.
Response 200
{
"peers": [
{
"pubkey": "abc123...==",
"allowed_ip": "100.96.5.42/32",
"status": "active"
}
]
}status values: active, pending, revoked.
Errors
| Code | Meaning |
|---|---|
| 401 | Missing token |
| 403 | Invalid token |
| 503 | Database not ready |
Report WireGuard peer stats parsed from wg show <iface> dump.
Supersedes /api/node/wg/handshakes. The panel stores reset-safe byte deltas and updates wstunnel_healthy, triggering a Caddy route resync on any health transition.
Request body
{
"stats": [
{
"pubkey": "abc123...==",
"last_handshake": 1719216000,
"rx_bytes": 1048576,
"tx_bytes": 524288,
"endpoint": "203.0.113.5:51820"
}
],
"node": {
"ip_forward_enabled": true,
"forward_policy_drop_detected": false,
"docker_rules_installed": true,
"firewall_backend": "nftables",
"mtu": 1420,
"listen_port": "51821",
"last_setup_error": "",
"wstunnel_healthy": true
}
}The node object is optional - older agents that omit it leave diagnostic fields as NULL in the database rather than overwriting with false negatives.
Response 204 No Content
Errors
| Code | Meaning |
|---|---|
| 400 | Malformed body |
| 401 | Missing token |
| 403 | Invalid token |
| 503 | Database not ready |
Report WireGuard handshake timestamps.
Superseded by /api/node/wg/stats. Kept live for rolling agent upgrades.
Request body (JSON or form-encoded)
{
"reports": [
{
"pubkey": "abc123...==",
"last_handshake": "2026-06-24T10:00:00Z"
}
]
}Response 204 No Content
Errors
| Code | Meaning |
|---|---|
| 401 | Missing token |
| 403 | Invalid token |
Caddy configuration changes (route create, route delete, node resync) are pushed to each node in a background goroutine. The API call returns as soon as the database row is committed.
SSL certificate provisioning is also asynchronous. After creating a route:
- Point the domain's DNS A/AAAA record to the node's public IP.
- Call
POST /api/v1/routes/{id}/verify-dnsto trigger a DNS check. - Poll
GET /api/v1/services/{id}/routesand watchstatus- it moves frompending_ssltoactiveonce Caddy obtains the certificate.
The panel ships a built-in Swagger UI at /api-docs (no login required when enabled by the operator). The OpenAPI 3.1 spec is available at /api-docs/openapi.json.