Klargør og administrer cloudinfrastruktur programmatisk — bestil VPS’er, styr Docker-containere og databaser, og byg forhandlerløsninger oven på Suble.
# 1 · Create a VPS — returns a job to poll
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances" \
-H "Authorization: Bearer $SUBLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"web-01","plan":"bxs-2-4","source":{"app":"docker"}}'
# 2 · Poll the job until the instance is running
curl "https://api.v3.suble.io/projects/$PROJECT/jobs/job_…" \
-H "Authorization: Bearer $SUBLE_API_KEY"
Authentication
Every request carries a bearer token in the Authorizationheader. The token's shape selects the mode — a token starting with sk_proj_ is treated as a project API key, anything else is verified as a JWT.
Authorization header
Authorization: Bearer sk_proj_<48-hex> # project API key — for servers, scripts, CI
Authorization: Bearer <oauth-access-jwt> # third-party apps + the remote MCP endpoint
Authorization: Bearer <session-jwt> # the Suble dashboard only
Project API key
Recommended for developers. Create it in the dashboard under Console → your project → API keys. The secret (sk_proj_…) is shown once and is scoped to that project, capped by the permissions you grant.
OAuth 2.1 — apps & MCP
Authorization Code + PKCE issues short-lived (1h) bearer tokens on behalf of a user. Powers third-party apps and the remote MCP endpoint at POST /mcp. Scopes: mcp:read and mcp:manage.
Session JWT
Minted by the dashboard login. Validated like any other JWT, but it's meant for the first-party dashboard — integrations should use a project API key.
API-key permissions
A key carries a permission bitmask (it defaults to all permissions). Each route checks the route's required permission against the key's bitmask — there is no owner-bypass, so a key can never do more than its grant. Project-scoped endpoints note their required permission as a chip.
Permission
Grants
ORDER_PRODUCT
Order/provision new instances
READ_VPS
View instances
VPS_CONTROL
Power actions (start/stop/reboot)
VPS_DELETE
Delete instances
CONSOLE_VNC
Access the VNC console
READ_NETWORK / WRITE_NETWORK
View / change instance networking
READ_FIREWALL / WRITE_FIREWALL
View / modify firewall rules
READ_BACKUP / WRITE_BACKUP
View / create / restore backups
READ_NETWORKS / WRITE_NETWORKS
View / manage private networks
READ_SSHKEYS / WRITE_SSHKEYS
View / add SSH keys
READ_BILLING / WRITE_BILLING
View / manage billing & payment methods
READ_MEMBERS / WRITE_MEMBERS
View / manage project members
READ_API / WRITE_API
List / create & revoke API keys
READ_SETTINGS / WRITE_SETTINGS
View / change project settings
Conventions
These apply to every endpoint. Read once; the per-endpoint docs assume them.
Base URL
All requests go to https://api.v3.suble.io — the API is served at the root, no version prefix. Liveness: GET /health → { "ok": true }.
Content type
JSON in and out — send Content-Type: application/json on any request with a body. Exceptions: invoice PDFs return application/pdf, and some mutations reply 204 No Content.
Money
All amounts are integer øre (1/100 DKK), excluding VAT — fields end in Ore. Divide by 100 for DKK. Danish VAT is 25% and is added only at invoice time. Hourly price = ceil(monthlyOre / 720).
Timestamps & IDs
Timestamps are ISO 8601 in UTC. Resource ids are opaque {prefix}_{12 chars} — match the prefix to know the type (ins_, job_, net_, inv_…).
Async jobs
Long-running mutations (create, delete, resize, rebuild, power, app/Docker/DB actions) return 202 Accepted with a job handle instead of completing synchronously. Poll the job — or read the current_job embedded in the instance payload — until it reaches a terminal state. Only one job runs per instance; a second enqueue returns 409 operation_in_progress.
Not implemented (by design): there is no generic pagination (page/offset are unsupported; only instance events are cursor-paginated, the rest return capped newest-first sets), no client Idempotency-Key header (idempotency is enforced server-side via the one-job rule), and no rate limiting or 429 — still self-throttle polling to ~2–3s.
Errors
Every non-2xx response uses one envelope with a stable, machine-readable code. Branch on error.code, never on message.
details is always present ({}unless it's a validation error, which fills details.issues[]). request_id is on every response — quote it in support requests.
Returns every active compute plan (the Suble VPS catalog) ordered by display sort. Each plan describes its hardware resources and pricing in integer øre excluding VAT.
Auth via middlewareV2() with no permission and requireProject=false: any valid bearer credential passes — a user JWT (Authorization: Bearer <jwt>) or a project API key (Authorization: Bearer sk_proj_...). The route declares no :project param, so no project-scoping or permission check runs; a valid token always reaches
Returns every active OS image available for provisioning instances, grouped by family and ordered by version descending. Each image carries its default user, connection protocol, minimum disk requirement, and guest-agent support flag.
Auth via middlewareV2() — accepts a user JWT or a project API key (Bearer sk_proj_...); project-free, no permission required. Only os_images rows with active = 1 are returned, ORDER BY family, version DESC.
Returns the catalog of one-click apps, deduplicated to the latest active version per slug (highest id wins). Each entry includes a derived category and the minimum resource/image requirements for installation.
Auth via middlewareV2() — accepts a user JWT or a project API key (Bearer sk_proj_...); project-free, no permission required. The query INNER JOINs apps to a derived MAX(id) per slug (active = 1 only), so only the newest active row per app slug appears, ORDER BY slug.
Returns the latest active version of a single one-click app identified by its slug. Responds 404 when no active app matches the slug.
Path parameters
slug
—
App slug to look up (e.g. "coolify", "wordpress", "postgresql"). Matched against apps.slug where active = 1; ORDER BY id DESC LIMIT 1 returns the highest-id (newest) active row.
Auth via middlewareV2() — accepts a user JWT or a project API key (Bearer sk_proj_...); project-free, no permission required. Returns a single app wrapped under an "app" key (not "apps").
Errors
404app_not_found— No active app exists with the given slug
Returns every project the caller owns or is a member of. Staff callers see all projects in the system.
Auth via middlewareV2 (no requireProject): accepts either a user JWT (Authorization: Bearer <jwt>, the dashboard path) or a project-scoped API key (Authorization: Bearer sk_proj_…). Auth-layer errors return { error: <message> } with NO machine code.
Creates a new project owned by the authenticated caller and returns the created project entity.
Body
namerequired
string
Project display name; coerced to string and trimmed, must be non-empty.
category
string
Accepted per the router docstring ({ name, category? }) but ignored by the handler; the response category is always "business".
Auth via middlewareV2 (JWT or sk_proj_ API key). Side effect: inserts a row into projects with the caller as ownerID and uid uid('prj') (prj_ + 12 lowercase a-z0-9).
Errors
400invalid_name— `name` is missing, empty, or whitespace after trimming
Fetches a single project by its uid, with live instance and member counts.
Path parameters
project
—
Project uid (prj_ prefix).
middlewareV2({ requireProject: true }) runs first: it requires req.params.project and verifies the caller is the project owner, a projectMembers member, or staff before the handler runs (else 400 "Project not found." or 403). The handler then re-reads the row; a missing row yields 404 not_found.
Errors
404not_found— Project passes middleware but the handler read-back returns nothing (rare)
Renames a project. Only the name field is mutable; returns the updated project.
Path parameters
project
—
Project uid (prj_ prefix).
Body
name
string
New project name; if non-null it is coerced to string and trimmed before update. If null/omitted the project is returned unchanged.
middlewareV2({ requireProject: true, permission: 'WRITE_SETTINGS' }). On the JWT path, the project owner and staff bypass the permission check; ordinary members must have the WRITE_SETTINGS bit.
Errors
404not_found— Project row read-back returns nothing after the update
Lists the project owner plus all invited members with their role and acceptance status.
Path parameters
project
—
Project uid (prj_ prefix).
middlewareV2({ requireProject: true }). Built from a UNION: the owner row (role 'owner') plus each projectMembers row joined to users (role 'member', excluding the owner).
Accepts a member role change. In the sandbox role is advisory (members always carry full permissions), so this is a no-op.
Path parameters
project
—
Project uid (prj_ prefix).
email
—
Member email address (URL-encoded path segment).
middlewareV2({ requireProject: true, permission: 'WRITE_MEMBERS' }). Always returns 204 and changes nothing — the handler ignores the request body and the :email param; member roles are not persisted in the sandbox (every member carries the full-access bitmask).
Returns all non-deleted instances in the project with their plan, primary IP, image/app, and current job progress. Joins plans, public_ips, os_images, apps, and the active job for each instance.
Path parameters
project
—
Project uid (e.g. prj_xxxxxxxxxxxx)
Auth is via middlewareV2 with permission READ_VPS and requireProject: accepts a project API key (Authorization: Bearer sk_proj_...) or a user JWT. The image_name column comes from os_images.display_name.
Provisions a new instance from an OS image, a project template, or an app. Validates the plan against image/app minimums, enqueues an async instance.create job, and returns 202 with the queued instance and job.
Path parameters
project
—
Project uid
Body
namerequired
string
Hostname: lowercase RFC1123 label, regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$. Must be unique among active instances in the project.
planrequired
string
Plan code (e.g. bxs-2-4); must reference an active plan (active = 1).
sourcerequired
object
Exactly one of {image: <os_image uid>}, {template: <template uid>}, or {app: <app slug>} (zod union).
ssh_keys
string[]
SSH public keys snapshotted onto the instance (stored in instances.ssh_keys_snapshot as JSON). Not passed in the job payload.
networks
string[]
Private network uids to attach; passed through to the create job payload (defaults to []).
backup_tier
string
One of none|basic|extended. Defaults to none.
password
string
Root/admin password, 8-72 chars. If omitted a random password is generated and returned once via the instance detail endpoint.
Async: returns a job (instance.create, priority 5, serializeOnInstance true). Poll the job to track provisioning.
Returns the full instance detail (same fields as list) plus the one-time root_password, which is shown decrypted until the caller acknowledges it.
Path parameters
project
—
Project uid
instance
—
Instance uid (ins_...)
Detail is read via the shared instanceListSelect filtered by uid; root_password is decrypted from instances.root_password_enc separately. root_password is non-null only until POST /:instance/password/acknowledge nulls the column (or if decryption throws, in which case it is null).
Errors
404instance_not_found— No active instance with this uid in the project
Enqueues an async instance.delete job that destroys the VM and frees its resources, optionally taking a final backup first.
Path parameters
project
—
Project uid
instance
—
Instance uid
Query parameters
final_backup
boolean
Pass final_backup=true (exact string) to take a final backup before destroying the VM. Any other value is treated as false.
Async: returns a job (instance.delete, priority 4, serializeOnInstance true) to poll. The job payload is {final_backup: req.query.final_backup === 'true'}.
Errors
404instance_not_found— No active instance with this uid in the project
Enqueues a power action (start, stop, shutdown, reboot, reset) after checking the instance is in an allowed state for that action.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
typerequired
string
One of start|stop|shutdown|reboot|reset. Preconditions: start requires status 'stopped'; stop/shutdown/reboot/reset require status 'running' or 'installing'.
Async: returns a job (instance.power, priority 2, serializeOnInstance true) to poll. The action is passed to the job as payload.action.
Errors
400invalid_request— type is not one of the allowed power actions (zod enum)
404instance_not_found— No active instance with this uid in the project
409invalid_state— Instance status does not satisfy the action's precondition (POWER_PRECONDITIONS)
Adds an SSH public key to the default user's authorized_keys on the running VM via the guest agent (idempotent). Powers the `suble vm ssh` CLI flow.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
publicKeyrequired
string
A single-line OpenSSH public key (ssh-ed25519/ssh-rsa/ssh-dss/ecdsa-sha2-*). Trimmed and validated against /^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( \S.*)?$/ before use.
Synchronous guest-agent exec via pve.agentRun (20s timeout), not a job. Idempotent: re-adding an existing key returns added:false, alreadyPresent:true (script echoes EXISTS/ADDED).
Errors
400invalid_key— publicKey is missing or not a well-formed single-line OpenSSH public key
404instance_not_found— No active instance with this uid in the project
409invalid_state— Instance has no running VM yet (vmTarget: missing vmid or node_name)
409ssh_key_failed— The guest-agent script returned a non-zero exit code
Enqueues a mutating app action (Docker container create/start/stop/restart/remove, managed-DB create database/user/upgrade, or ingress set/remove). Validates and builds the shell script up-front so bad args fail fast with 400.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
actionrequired
string
One of docker.start|docker.stop|docker.restart|docker.remove|docker.create|db.create_database|db.create_user|db.upgrade|ingress.set|ingress.remove (zod enum).
args
object
Action arguments (zod record, defaults to {}). docker control: {id}. docker.create: {image, name?, ports?, env?, volumes?, restart?}. db.create_database: {name}. db.create_user: {username, password, database?}. db.upgrade: {}. ingress.set: {hostname, target, port}. ingress.remove: {hostname}. Shape is enforced by buildActionScript, not by zod.
Async: returns a job (app.action, priority 2, serializeOnInstance true) to poll. The DB engine for db.* actions is derived from the instance's installed app slug (instance_apps JOIN apps); docker.* ignore it.
Errors
400invalid_request— Body fails zod validation (unknown action enum value)
400invalid_action— buildActionScript throws an ActionError (e.g.
404instance_not_found— No active instance with this uid in the project
Switches the instance to a new plan (cores/RAM/disk) and enqueues an instance.resize job. Disk is grow-only — the target plan's disk must be greater than or equal to the current.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
planrequired
string
Target plan code (active plan). Must have disk_gb >= the current plan's disk_gb.
Async: returns a flat job object {uid,type,status:'queued',createdAt} — NOT wrapped in {job:{...}}. Order: the busy precondition is checked first, then current/target plan lookup and disk comparison.
Errors
400invalid_request— Body fails zod validation (missing plan)
400disk_shrink_unsupported— Target plan disk_gb is smaller than the current plan disk_gb
404instance_not_found— No active instance with this uid in the project
404plan_not_found— Target plan code not found / inactive
409operation_in_progress— Instance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)
Wipes and reinstalls the instance from a (possibly new) image or template while keeping its IP and vmid. Takes a pre-rebuild safety backup unless explicitly disabled.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
sourcerequired
object
Exactly one of {image: <os_image uid>} or {template: <template uid>} to rebuild from (zod union).
sshKeys
string[]
Replacement SSH public keys snapshot (written to instances.ssh_keys_snapshot only if provided).
backup
boolean
Whether to take a pre-rebuild safety backup. Defaults to true; the job payload backup is set to (body.backup !== false), so only backup:false disables it.
Async: returns a flat job object {uid,type,status:'queued',createdAt} (same shape as resize). DESTRUCTIVE — all data on the instance is wiped.
Errors
400invalid_request— Body fails zod validation (missing/invalid source)
400disk_too_small— source.image: plan disk_gb below the image min_disk_gb
404instance_not_found— No active instance with this uid in the project
404image_not_found— source.image uid not found / inactive
404template_not_found— source.template uid not found in this project
409operation_in_progress— Instance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)
409template_not_ready— source.template status is not 'ready'
Sets a new root/admin password (supplied or generated), stores it encrypted, enqueues an instance.reset_password job, and returns the new password once.
Path parameters
project
—
Project uid
instance
—
Instance uid
Body
password
string
New password, 8-72 chars. If omitted a random password is generated.
Async: returns a flat job object plus the new password once (echoed only in this response — store it). Side effect: instances.root_password_enc is updated immediately (encrypted) before enqueue; the job applies it via cloud-init and the live agent.
Returns the instance's lifecycle event feed (newest first) from the shared actions log, with opaque base64 cursor pagination.
Path parameters
project
—
Project uid
instance
—
Instance uid
Query parameters
limit
number
Max events to return, capped at 200 (Math.min(limit,200)). Defaults to 50.
cursor
string
Opaque base64 cursor from a previous response's next_cursor; decoded to a numeric id and returns events older than it.
Cursor pagination: next_cursor is non-null only when a full page was returned (events.length === limit) and the last event has an id. The internal numeric id is stripped (set to undefined) from each event before serialization.
Errors
404instance_not_found— No active instance with this uid in the project
Returns the install status, progress, and manifest display info for the app installed on the instance, decrypting any secret display values for the authorized caller.
Path parameters
project
—
Project uid
instance
—
Instance uid
Returned columns: ia.status, current_step, total_steps, step_name, exit_code, info, retry_count, health_checked_at, started_at, finished_at, plus app.slug/version/name/logo_url and display (JSON_EXTRACT of manifest $.info.display). Secret display values stored encrypted (enc:true, base64 AES-256-GCM) are decrypted in-p
Errors
404app_not_found— No instance_apps row exists for this instance
404instance_not_found— No active instance with this uid in the project
Runs the installed app manifest's state probes on the VM via the guest agent and returns a live per-app overview. Returns running:false when the VM is not up.
Path parameters
project
—
Project uid
instance
—
Instance uid
Synchronous guest-agent exec via runAppState driven by the app manifest's state probes. When instance.vmid is null, OR status is not 'running'/'installing', it returns 200 with {running:false, state:null} (early return, no error).
Errors
404instance_not_found— No active instance with this uid in the project
404app_not_found— Instance has a vmid and is running/installing but no installed app (instance_apps JOIN) was found
For SQL database apps, lists tables (name + on-disk size) in a given database via a least-privilege read-only system user, run live on the VM via the guest agent.
Path parameters
project
—
Project uid
instance
—
Instance uid
Query parameters
databaserequired
string
Database name to enumerate. Validated as a SQL identifier inside dbListTablesScript. Defaults to empty string when omitted, which fails validation (but only after the VM-up/engine checks).
Synchronous guest-agent exec via pve.agentRun (30s timeout). When instance.vmid is null OR status is not 'running'/'installing', returns 200 with {tables:[]} (early return, before the app/engine lookup).
Errors
400unsupported_engine— Installed app slug/engine is not mysql, mariadb, or postgresql (SQL_TABLE_ENGINES)
400invalid_database— dbListTablesScript throws for an invalid database identifier
404instance_not_found— No active instance with this uid in the project
404app_not_found— VM is up and running/installing but no installed app (instance_apps JOIN) found
502db_query_failed— The guest-agent table-listing script returned non-zero
Returns all non-deleted backups (manual snapshots and automatic-tier backups) for the given instance, newest first (ORDER BY id DESC). Each item reflects current snapshot status and size.
Path parameters
project
—
Project uid the instance belongs to (e.g. proj_...). Resolved to an internal id; must exist.
instance
—
Instance uid (e.g. ins_...). Must belong to the project and not be deleted.
Read-only. SQL filters status != 'deleted' and orders by id DESC (newest first).
Errors
401unauthorized— No Authorization header / bearer token, or invalid JWT / API key.
403forbidden— API key lacks READ_VPS, member lacks READ_VPS, or caller is not authorized for the project.
400project_lookup_failed— JWT path only: middleware resolves the project membership before the handler and returns 400 { error: "Project not found." } when the :project uid does not resolve.
404project_not_found— Handler-level getProjectId() finds no project for the :project uid.
404instance_not_found— The :instance uid does not exist in the project or has status 'deleted'.
Inserts a pending manual backup row immediately, then enqueues a backup.create job to perform the snapshot via PBS. Returns the queued job to poll, not the finished backup.
Path parameters
project
—
Project uid (e.g. proj_...).
instance
—
Instance uid (e.g. ins_...) to snapshot.
Body
note
string
Optional free-text label for the backup, max 255 characters (zod: z.string().max(255).optional()).
Async. The backup row is INSERTed first with type 'manual', status 'pending' and a generated uid bak_...
Errors
400invalid_request— Body fails the zod schema (e.g.
401unauthorized— Missing/invalid auth token
403forbidden— Caller lacks VPS_CONTROL for the project
404project_not_found— Handler getProjectId() finds no project for the :project uid
404instance_not_found— Instance uid not found in project or status 'deleted'
Enqueues an instance.restore job that rolls the instance back to a successful backup, claims the instance so no other operation can run concurrently, and flips the instance to status 'restoring'.
Path parameters
project
—
Project uid (e.g. proj_...).
instance
—
Instance uid (e.g. ins_...) to restore.
backup
—
Backup uid (e.g. bak_...) to restore from; must belong to the instance and have status 'success'.
Destructive and async. Order of checks: resolve project, resolve instance, then 409 if instance not idle (running/stopped), then 404 if no matching 'success' backup.
Errors
401unauthorized— Missing/invalid auth token
403forbidden— Caller lacks VPS_CONTROL for the project
404project_not_found— Project uid not found
404instance_not_found— Instance uid not found in project or deleted
409operation_in_progress— Instance status is not 'running' or 'stopped' (busy).
404backup_not_found— No backup with the given uid for this instance whose status is 'success'
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backups/$BACKUP/restore" \
-H "Authorization: Bearer $SUBLE_API_KEY"
Creates a reusable image template from a successful backup by inserting a template row (status 'creating') and enqueuing a template.create job. The template captures the backup's PBS snapshot and size plus the source instance's disk, default user, and protocol.
Path parameters
project
—
Project uid (e.g. proj_...).
instance
—
Instance uid (e.g. ins_...) that owns the backup; its plan disk_gb (as min_disk_gb), os image default_user, and protocol seed the template.
backup
—
Backup uid (e.g. bak_...) to base the template on; must belong to the instance and have status 'success'.
Body
namerequired
string
Display name for the new template, 1 to 64 characters (zod: z.string().min(1).max(64)).
Async. Inserts a templates row (uid tpl_..., status 'creating') copying pbs_snapshot and size_gb from the backup, and min_disk_gb from the instance's plan disk_gb, default_user from the os image, and protocol from the os image (defaults to 'ssh' when null); source_instance_id and source_backup_id reference the original
Errors
400invalid_request— Body fails zod validation (name missing, empty, or longer than 64 chars).
401unauthorized— Missing/invalid auth token
403forbidden— Caller lacks VPS_CONTROL for the project
404project_not_found— Project uid not found
404instance_not_found— Instance uid not found in project or deleted
404backup_not_found— No backup with this uid for the instance whose status is 'success'
Updates the instance's automatic backup tier, which controls scheduled backup frequency/retention and associated billing. Applies synchronously to the instance row.
Path parameters
project
—
Project uid (e.g. proj_...).
instance
—
Instance uid (e.g. ins_...) whose backup tier is being set.
Body
tierrequired
string (enum: none | basic | extended)
The automatic backup tier to set. One of 'none', 'basic', 'extended' (zod: z.enum(['none','basic','extended'])).
Synchronous: directly UPDATEs instances.backup_tier and returns 204 No Content with an empty body (no job enqueued; res.status(204).end()). Changing the tier has billing side effects (basic/extended are paid automatic-backup plans); 'none' disables automatic backups.
Errors
400invalid_request— Body fails zod validation (tier missing or not one of none|basic|extended).
401unauthorized— Missing/invalid auth token
403forbidden— Caller lacks VPS_CONTROL for the project
404project_not_found— Project uid not found
404instance_not_found— Instance uid not found in project or deleted
Returns all firewall rules for an instance, ordered by ascending position. The DB is the source of truth; this read reflects the persisted rule set, not necessarily what is live on Proxmox if a firewall.sync is still pending.
Path parameters
project
—
Project UID (e.g. proj_…). With an API key it must equal the key's bound project (mismatch is rejected 403 before the route runs).
instance
—
Instance UID (ins_…). Must belong to the project and have status != 'deleted'.
Response is a bare JSON array of rule objects (res.json(rows.map(serialize))) — no envelope, no pagination. Fields proto/source/dest/sport/dport/comment are omitted entirely when NULL (serialize maps null -> undefined, which JSON drops).
Errors
401Invalid API key.— API key not found / secret hash mismatch (or for JWT callers: missing token -> 'Authentication missing1', bad token -> 'Invalid token.').
403API key is not valid for this project.— The :project param does not match the API key's bound project (checked in apiKeyAuth before the route).
403API key lacks the required permission.— API key does not hold the READ_VPS permission.
404project_not_found— The :project UID does not resolve to a project (getProjectId).
404instance_not_found— No instance with the :instance UID exists in the project with status != 'deleted'.
Appends a new firewall rule to the instance, auto-assigning the next position (COALESCE(MAX(position),0)+1). Persists to the DB and best-effort enqueues a firewall.sync job to push the full rule set to Proxmox.
Path parameters
project
—
Project UID. With an API key it must equal the key's bound project (mismatch -> 403).
instance
—
Instance UID (ins_…) to attach the rule to. Must belong to the project and have status != 'deleted'.
Body
directionrequired
string enum: "in" | "out"
Traffic direction the rule matches.
actionrequired
string enum: "ACCEPT" | "DROP" | "REJECT"
What to do with matching traffic.
proto
string (max 16)
Protocol, e.g. tcp, udp, icmp. Omit for any (stored NULL).
source
string (max 512)
Source address/CIDR/alias to match. Omit for any (stored NULL).
dest
string (max 512)
Destination address/CIDR/alias to match. Omit for any (stored NULL).
sport
string (max 64)
Source port or port range/list (Proxmox syntax). Stored NULL if omitted.
dport
string (max 64)
Destination port or port range/list (Proxmox syntax). Stored NULL if omitted.
comment
string (max 128)
Free-text label for the rule. Stored NULL if omitted.
enabled
boolean
Whether the rule is active. Defaults to true (zod .default(true)).
Returns the created rule (the new fwr_ uid and the server-assigned `position`); the client cannot choose position on create — it is always MAX(position)+1 for the instance. The response body is built in-memory from the inserted values (serialize of the row), not re-read from the DB.
Errors
400invalid_request— Body fails zod validation (bad enum value, field over max length, wrong type).
401Invalid API key.— Missing/invalid API key or JWT (flat {error:"..."} body).
403API key is not valid for this project.— The :project param does not match the API key's bound project.
403API key lacks the required permission.— API key does not hold VPS_CONTROL.
404project_not_found— The :project UID does not resolve (getProjectId).
404instance_not_found— No instance with the :instance UID exists in the project with status != 'deleted'.
Partially updates an existing firewall rule by UID. Only provided fields are changed; position can be reassigned to reorder the rule. Persists changes and best-effort enqueues a firewall.sync job — but only when at least one field actually changes.
Path parameters
project
—
Project UID. With an API key it must equal the key's bound project (mismatch -> 403).
instance
—
Instance UID (ins_…). Must belong to the project and have status != 'deleted'.
rule
—
Firewall rule UID (fwr_…) to update; must belong to the instance (matched on uid AND instance_id).
Body
direction
string enum: "in" | "out"
New traffic direction.
action
string enum: "ACCEPT" | "DROP" | "REJECT"
New action.
proto
string (max 16)
New protocol; an empty string (falsy) clears it to NULL (any).
source
string (max 512)
New source; empty string clears to NULL.
dest
string (max 512)
New destination; empty string clears to NULL.
sport
string (max 64)
New source port spec; empty string clears to NULL.
dport
string (max 64)
New destination port spec; empty string clears to NULL.
comment
string (max 128)
New comment; empty string clears to NULL.
enabled
boolean
Enable/disable the rule (stored as 1/0).
position
integer (positive)
New evaluation position; reorders the rule. Validated as z.number().int().positive().
All body fields optional (partial update; patchSchema = ruleSchema.partial().extend({position})). NOTE: because ruleSchema gives `enabled` a default of true, .partial() makes it optional but if `enabled` is omitted it is left undefined (no default re-applied in the SET builder), so omitting it does NOT force enabled=tr
Errors
400invalid_request— Body fails zod validation (bad enum, field over max length, non-positive/non-integer position).
401Invalid API key.— Missing/invalid API key or JWT (flat {error:"..."} body).
403API key is not valid for this project.— The :project param does not match the API key's bound project.
403API key lacks the required permission.— API key does not hold VPS_CONTROL.
404project_not_found— The :project UID does not resolve (getProjectId).
404instance_not_found— No instance with the :instance UID exists in the project with status != 'deleted'.
404rule_not_found— No firewall rule with this UID exists for this instance (uid + instance_id lookup).
Permanently removes a firewall rule from the instance by UID, then best-effort enqueues a firewall.sync job to update Proxmox.
Path parameters
project
—
Project UID. With an API key it must equal the key's bound project (mismatch -> 403).
instance
—
Instance UID (ins_…). Must belong to the project and have status != 'deleted'.
rule
—
Firewall rule UID (fwr_…) to delete; must belong to the instance (matched on uid AND instance_id).
Returns 204 No Content with an empty body on success (res.status(204).end()). Hard delete (no soft-delete); the row is removed and remaining rules keep their existing positions (no auto-renumber).
Errors
401Invalid API key.— Missing/invalid API key or JWT (flat {error:"..."} body).
403API key is not valid for this project.— The :project param does not match the API key's bound project.
403API key lacks the required permission.— API key does not hold VPS_CONTROL.
404project_not_found— The :project UID does not resolve (getProjectId).
404instance_not_found— No instance with the :instance UID exists in the project with status != 'deleted'.
404rule_not_found— DELETE affected 0 rows (no rule with this UID for the instance).
Lists all non-deleted private networks in the project, newest first. Each entry includes the deterministic /24 CIDR and a live count of attached instances.
Path parameters
project
—
Project uid (prj_…) that owns the networks.
Mounted at /v3/projects/:project/networks (router has mergeParams). Auth via middlewareV2({permission:'READ_VPS', requireProject:true}): accepts a project API key (Authorization: Bearer sk_proj_…) whose permissions bitmask includes READ_VPS and that belongs to the path project, or a user JWT (owner/member with READ_VPS
Errors
404project_not_found— getProjectId finds no project with the given uid (route-layer error envelope with code).
Creates a VLAN-tagged L2 private network. The server claims the lowest free VLAN tag in 2000–3999 and derives a deterministic /24 CIDR. The network is active immediately and billing starts at creation.
Path parameters
project
—
Project uid (prj_…) to create the network in.
Body
namerequired
string
Display name. Zod: z.string().min(1).max(32) — 1 to 32 characters.
Requires the ORDER_PRODUCT permission (not READ_VPS) because creation provisions a billable resource. Validated with createSchema = z.object({name: z.string().min(1).max(32)}); only `name` is read from req.body.
Errors
400invalid_request— createSchema validation fails — name missing, empty, or longer than 32 chars.
404project_not_found— No project exists with the given uid.
Attaches an instance to the private network by allocating a private IP and hot-plugging a NIC. The work runs asynchronously: a network.attach job is enqueued and a job descriptor is returned to poll.
Path parameters
project
—
Project uid (prj_…) that owns the network.
network
—
Network uid (net_…) to attach the instance to.
Body
instanceUidrequired
string
Uid (ins_…) of the instance to attach. Zod: z.string().min(1). Must resolve to a non-deleted instance in the same project.
ip
string (IPv4)
Optional static private IPv4 to assign. Zod: z.string().ip({version:'v4'}).optional(). If omitted, the next free host from .10 to .249 in the network's /24 is chosen.
Requires VPS_CONTROL. Asynchronous: returns 202 with a job descriptor {uid (job_ prefix), type:'network.attach', status:'queued', createdAt: new Date()} — status is hardcoded 'queued', createdAt is request time, not job.createdAt.
Errors
400invalid_request— attachSchema validation fails — instanceUid empty/missing, or ip present but not a valid IPv4 address (ZodError → details.issues).
403unauthenticated— getUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it.
404project_not_found— No project exists with the given uid.
404network_not_found— findNetwork finds no matching non-deleted network in this project.
404instance_not_found— findInstanceId: instanceUid does not resolve to a non-deleted instance in this project.
409already_attached— A network_members row already exists for this network+instance.
409nic_limit— The instance already has the maximum of 3 private NICs (MAX_PRIVATE_NICS).
409subnet_full— No free address remains in the /24 (.10–.249 all taken, or the loop yields no IP).
409operation_in_progress— enqueueJob with serializeOnInstance:true fails to claim the instance because another job is already running on it.
Detaches an instance from the private network. Marks the member as detaching and enqueues an async network.detach job that removes the NIC; returns a job descriptor to poll.
Path parameters
project
—
Project uid (prj_…) that owns the network.
network
—
Network uid (net_…).
instance
—
Instance uid (ins_…) to detach from the network.
Requires VPS_CONTROL. Asynchronous: returns 202 with a job descriptor {uid (job_ prefix), type:'network.detach', status:'queued' (hardcoded), createdAt: new Date()}; the NIC removal runs in the network.detach job handler (priority 3, serializeOnInstance:true).
Errors
403unauthenticated— getUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it (route-layer ApiError, code 'unauthenticated').
404project_not_found— No project exists with the given uid.
404network_not_found— findNetwork finds no matching non-deleted network in this project.
404instance_not_found— findInstanceId: the :instance uid does not resolve to a non-deleted instance in this project.
404member_not_found— No network_members row exists for this network+instance.
409operation_in_progress— enqueueJob with serializeOnInstance:true cannot claim the instance because another job is already running on it.
Returns all non-deleted disk-image templates belonging to the project, newest first (ordered by internal id descending). Templates are reusable disk images created from instance backups via the backups router.
Path parameters
project
—
Project uid (e.g. proj_...) the templates belong to.
Auth is middlewareV2 — accepts EITHER a logged-in user JWT (Bearer) OR a project API key (Bearer sk_proj_...); the same READ_VPS permission bitmask is enforced for both. Project owners bypass the permission check.
Errors
404project_not_found— Route-level: project passes the auth middleware but getProjectId finds no projects row for the uid.
Updates the display name of a single template and returns the full updated, serialized template object. Only the name can be changed via this endpoint.
Path parameters
project
—
Project uid that owns the template.
template
—
Template uid (e.g. tpl_...) to rename.
Body
namerequired
string
New template name. Validated by zod: 1-64 characters (z.string().min(1).max(64)).
Synchronous write — UPDATE templates SET name = ? committed immediately; the returned object is serialize({ ...template, name }) reflecting the new name with monthlyPriceOre recomputed from sizeGb.
Errors
400invalid_request— Body fails the zod schema (name missing, empty, or longer than 64 chars).
404template_not_found— No template with that uid exists in the project, or its status is 'deleted'.
404project_not_found— Route-level: getProjectId finds no projects row for the uid (after auth middleware passes).
Marks the template row as 'deleting' and enqueues an asynchronous template.delete job that removes the underlying PBS snapshot. Returns the queued job descriptor to poll.
Path parameters
project
—
Project uid that owns the template.
template
—
Template uid (e.g. tpl_...) to delete.
Asynchronous. The template row is immediately set to status 'deleting' (UPDATE templates SET status = 'deleting'), then a template.delete job with priority 6 and payload { templateId } is enqueued; PBS snapshot removal happens in the background, so poll the returned job uid.
Errors
404template_not_found— No template with that uid exists in the project, or its status is already 'deleted'/'deleting' filtered out (findTemplate filters status != 'deleted').
404project_not_found— Route-level: getProjectId finds no projects row for the uid (after auth middleware passes).
Returns the 50 most recent jobs in the project, newest first. Optionally filter to a single instance via the `instance` query parameter. The dashboard polls this while a job is active.
Path parameters
project
—
Project UID the jobs belong to. With an API key, must match the key's scoped project (mismatch returns 403). With a user JWT, the caller must be the project owner or an authorized member.
Query parameters
instance
string
Instance UID (ins_...) to filter jobs by. When present, the query adds `AND ins.uid = ?` so only jobs whose joined instance UID matches are returned. Coerced to a string via String(); a falsy value is treated as absent.
Auth via middlewareV2({ requireProject: true }): accepts either a project API key (Authorization: Bearer sk_proj_...) or a user JWT bearer token. No `permission` is passed, so no specific scope/permission is enforced by this route (beyond JWT callers needing project owner/member access).
Errors
401Invalid API key.— Auth middleware (bare { error: "..." } string shape, not the v3 envelope).
403API key is not valid for this project.— API-key path only: the :project path param does not equal the project the API key is scoped to.
404project_not_found— In-handler: getProjectId finds no projects row for the :project UID.
Fetches one job by its UID within the project, including live progress and error detail. Used to poll an async operation (e.g. instance create, resize, backup) to completion.
Path parameters
project
—
Project UID the job belongs to. With an API key, must match the key's scoped project; with a user JWT, the caller must own or be an authorized member of the project.
job
—
Job UID (job_...) to retrieve. Scoped to the project via `WHERE job.project_id = ? AND job.uid = ?`, so a job from another project is treated as not found.
Auth via middlewareV2({ requireProject: true }): accepts a project API key (Bearer sk_proj_...) or a user JWT; no permission/scope check on this route. Poll while status is pending/running until finished_at is set; terminal states populate either progress (typically on success) or error { code, message } (on failure).
Errors
401Invalid API key.— Auth middleware (bare string shape).
403API key is not valid for this project.— API-key path: :project does not match the key's scoped project.
404project_not_found— getProjectId finds no projects row for :project.
404job_not_found— No job with the given :job UID exists within the project (queryOne returns null → notFound("job_not_found", "Job")).
Returns all non-revoked API keys for the project, ordered newest first. Secrets are never returned here; each key is shown only by its masked 20-character prefix.
Path parameters
project
—
Project uid (prj_...) the keys belong to.
Mounted at /v3/projects/:project/api-keys (router uses mergeParams; /v3 added at app.use("/v3", v3.router), :project comes from the parent mount). Authenticated via Authorization: Bearer — accepts either a project API key (sk_proj_...; verified in modules/auth.ts apiKeyAuth before JWT) or a user session JWT; project ow
Errors
401—— Auth layer (middlewareV2): no Authorization header/token
403—— Auth layer: Bearer-JWT principal is a non-owner member without READ_API
Generates a new project-scoped API key (sk_proj_...) for automation and returns the full secret exactly once. Only a sha256 hash and a 20-char prefix are persisted server-side.
Path parameters
project
—
Project uid (prj_...) the key is scoped to.
Body
namerequired
string
Human label for the key. 1-64 characters (zod min(1).max(64)).
permissions
integer
PermissionTypes bitmask granted to the key (non-negative integer; zod number().int().nonnegative().optional()). Omit to grant the default full-access mask 2147483647 (0x7fffffff).
Order of execution in the handler: createSchema.parse(req.body) → getProjectId(req.params.project) → getUserId(res). Secret is `sk_proj_` + randomBytes(24).toString("hex") = sk_proj_ followed by 48 hex chars; prefix = secret.slice(0,20); hash = sha256 hex.
Errors
400invalid_request— Body fails the zod createSchema (missing/empty name, name longer than 64 chars, or permissions negative/non-integer).
Revokes (soft-deletes) a single API key by setting its revoked_at timestamp. The key immediately stops authenticating requests. Revoking an already-revoked or unknown key returns 404.
Path parameters
project
—
Project uid (prj_...) that owns the key.
key
—
API key uid (apk_...) to revoke.
Handler runs getProjectId(req.params.project) first, then UPDATE api_keys SET revoked_at = NOW() WHERE uid = ? AND project_id = ?
Errors
404api_key_not_found— UPDATE matched no rows (affectedRows = 0): no active (revoked_at IS NULL) key with that uid exists in the project — also returned if the key was already revoked or belongs to a different project.
Returns the project's DDoS settings, the list of always-on-protected instance IPs and owned IP ranges with their current protection status, and active plus recent (last 30 days) attack alerts grouped by alertid. Edge scrubbing is always on; this endpoint surfaces GlobalConnect events (legacy ddos_events table, ingested via /v2/webhook/ddos) that hit the project's instance IPs or owned IP ranges.
Path parameters
project
—
Project UID (e.g. proj_...). The :project path segment is resolved to an internal numeric id via getProjectId (SELECT id FROM projects WHERE uid = ?).
Mounted at /v3/projects/:project/ddos (router.use in v3 routes/index.ts; /v3 prefix applied where v3.router is mounted in src/index). Accepts a project API key (Authorization: Bearer sk_proj_...) requiring READ_VPS, or a user JWT (project owner and staff bypass permission checks).
Errors
404project_not_found— API-key auth only: the auth middleware does not resolve the project for API keys, so getProjectId throws the v3 envelope when the :project UID does not resolve.
Toggles the paid 'DDoS Business' addon for the project. Enabling starts billing and stamps business_started_at; disabling stops billing (business_stopped_at) and also disables the dependent alert webhook addon.
Path parameters
project
—
Project UID. Resolved to an internal numeric id via getProjectId.
Body
enabledrequired
boolean
true to enable the DDoS Business addon, false to disable it. Validated by zod (z.boolean()).
Requires a project API key with ORDER_PRODUCT (or a project owner/staff/member JWT with that permission). NOTE: calls getUserId, which throws 403 unauthenticated for any principal without a resolvable users.id — in practice a project API key will hit this (no userUID on res.locals), so this endpoint is effectively JWT-
Errors
400invalid_request— Body fails zod validation (enabled missing or not a boolean) — thrown by .parse via asyncHandler.
403unauthenticated— v3 envelope: the authenticated principal cannot be resolved to a numeric user id (getUserId — e.g.
404project_not_found— API-key auth only: getProjectId fails to resolve the :project UID.
Sets or clears the single HTTPS alert webhook that receives DDoS event notifications. Requires DDoS Business to be enabled before a non-null URL can be set; passing null clears the webhook.
Path parameters
project
—
Project UID. Resolved to an internal numeric id via getProjectId.
Body
urlrequired
string | null
Webhook URL. zod requires a valid URL of at most 512 chars, or null. Field must be present (it is nullable, not optional). A non-null URL must additionally be https:// (checked after zod). Pass null to remove the webhook.
Requires ORDER_PRODUCT. Also calls getUserId (used on first-time INSERT), so like /business it effectively requires a JWT user, not a bare project API key.
Errors
400invalid_request— Body fails zod validation (url is not a valid URL, exceeds 512 chars, or the field is missing — it is required, though nullable).
409business_required— A non-null url is provided while DDoS Business is not enabled.
400invalid_webhook— A non-null url passes zod but is not https:// (only checked after the business_required gate).
403unauthenticated— v3 envelope: getUserId cannot resolve the principal to a numeric user id (e.g.
404project_not_found— API-key auth only: getProjectId fails to resolve the :project UID.
Adds an email address to the DDoS alert notification recipients for the project. Requires DDoS Business; the first recipient is included, each additional one is billed.
Path parameters
project
—
Project UID. Resolved to an internal numeric id via getProjectId.
Body
emailrequired
string
Recipient email address. zod requires a valid email of at most 255 chars. Stored lowercased; duplicates are ignored (INSERT IGNORE).
Requires ORDER_PRODUCT and DDoS Business enabled. Does NOT call getUserId, so unlike /business and /webhook it works with a bare project API key.
Errors
400invalid_request— Body fails zod validation (email missing, not a valid email, or over 255 chars).
409business_required— DDoS Business is not enabled for the project (loadSettings business_enabled is falsy).
409too_many_emails— The project already has 20 recipients (MAX_EMAILS), checked before insert.
404project_not_found— API-key auth only: getProjectId fails to resolve the :project UID.
Removes an email address from the project's DDoS alert recipients. The address to remove is supplied in the JSON request body (lowercased before matching).
Path parameters
project
—
Project UID. Resolved to an internal numeric id via getProjectId.
Body
emailrequired
string
Recipient email to remove. zod requires a valid email of at most 255 chars. Lowercased before matching the DELETE.
Requires ORDER_PRODUCT. Does NOT call getUserId, so it works with a bare project API key.
Errors
400invalid_request— Body fails zod validation (email missing, not a valid email, or over 255 chars).
404project_not_found— API-key auth only: getProjectId fails to resolve the :project UID.
Returns the project's colocation product including rack location, power commitment, and its smart plugs with each plug's latest power reading. Returns null when the project has no colocation product (or the legacy colocation tables do not exist on a v3-only database).
Path parameters
project
—
Project UID the colocation belongs to (e.g. prj_...). Resolved to a numeric project id via getProjectId.
Read-only; no request body, no zod schema (so the shared invalid_request/400 validation path never fires here). Dual auth via middlewareV2({ permission: READ_COLOCATION, requireProject: true }): accepts a project-scoped API key (Authorization: Bearer sk_proj_...) where READ_COLOCATION is checked against the key's own p
Errors
200null_body— Project resolves but has no colocation product, or the legacy products/coloPlugs tables are absent (ER_NO_SUCH_TABLE on a v3-only DB) — body is literally null at HTTP 200.
401unauthorized— No Authorization header / no token → { error: "Authentication missing1" }.
Returns the hourly power-usage history (watts) for a single smart plug within the project's colocation, over a selected lookback window. Returns an empty array when the project has no colocation or no matching history rows.
Path parameters
project
—
Project UID the colocation belongs to (e.g. prj_...). Resolved to a numeric project id via getProjectId.
Query parameters
plugrequired
string
Smart plug UID (coloPlugs.uid, e.g. plug_a1b2c3) to fetch usage for. Coerced via String(req.query.plug ?? ""), so if omitted it defaults to an empty string that matches no plug and yields [].
range
string
Lookback window: one of 1h, 6h, 24h, 7d (mapped to 1/6/24/168 hours). Unknown or omitted values fall back to 24h. Not validated or echoed in the response.
Read-only; no request body, no zod schema. Same dual auth as the parent route (middlewareV2 with READ_COLOCATION + requireProject), with the same API-key-vs-user-bearer divergence on a missing project (404 v3 envelope vs 400 auth envelope).
Errors
200empty_array— Project has no colocation product (or legacy tables absent → ER_NO_SUCH_TABLE), or no plugHourly rows match the given plug UID + range (including a wrong/foreign/omitted plug) — body is [] at HTTP 200
403forbidden— API-key path: lacks READ_COLOCATION → { error: "API key lacks the required permission." } or key not valid for project → { error: "API key is not valid for this project." }.
400bad_request— User-bearer path only: no :project with requireProject → { error: "Project ID required." };
404project_not_found— API-key path: getProjectId cannot resolve the :project UID.
Returns the single IP transit configuration for the project, including the linked private network (VLAN), Suble's ASN, port speed, prefix limits, and the configured BGP sessions with their MD5 passwords. Transit is staff-provisioned in the DB (transits + transit_bgp_sessions tables); this endpoint is read-only.
Path parameters
project
—
Project UID (e.g. proj_…). For project API keys the key's own project is enforced by the auth layer; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.
Handler: router.get('/'). Auth via middlewareV2({permission:'READ_COLOCATION', requireProject:true}) — accepts a project API key (Authorization: Bearer sk_proj_…, must carry the READ_COLOCATION permission bit) OR a user JWT bearer (project owner, staff, or member with READ_COLOCATION).
Errors
200null— No transit row is provisioned for the project — the body is the JSON literal null (not an error envelope).
404project_not_found— The :project UID does not resolve to a project (getProjectId throws → v3 error envelope).
Returns current in/out throughput, 30-day 95th-percentile in/out, and current in/out packet rates for the project's transit, computed live from Prometheus counters on the VyOS border router (device eth4.<vlan>) for the transit's VLAN.
Path parameters
project
—
Project UID. For project API keys the key's own project is enforced; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.
Handler: router.get('/stats'). Same middlewareV2({permission:'READ_COLOCATION', requireProject:true}) auth as the index route.
Errors
200null— No transit is provisioned, OR the transit has no linked active private network (VLAN unknown) — the body is the JSON literal null.
404project_not_found— The :project UID does not resolve to a project (getProjectId).
Returns a time series of in/out throughput (Mbit/s) for the project's transit over the requested range, sampled from Prometheus query_range against the VyOS counters (device eth4.<vlan>) for the transit's VLAN.
Path parameters
project
—
Project UID. For project API keys the key's own project is enforced; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.
Query parameters
range
string
Time window. One of 1h, 6h, 24h, 7d. Any other/missing value falls back to 24h. Controls both span (1h=3600s, 6h=21600s, 24h=86400s, 7d=604800s) and step (1h→60s, 6h→300s, 24h→900s, 7d→3600s).
Handler: router.get('/usage'). Same middlewareV2({permission:'READ_COLOCATION', requireProject:true}) auth.
Errors
200[]— No transit is provisioned, OR the transit has no linked active private network — the body is an empty array [].
404project_not_found— The :project UID does not resolve to a project (getProjectId).
Returns the IP prefixes (transit / colocation space) that a project owns, ordered by address family then internal id. Ranges are read-only here — they are allocated by Suble staff directly in the ip_ranges table after ownership verification — and are consumed by the DDoS attribution logic so any attack on an IP inside a range maps to this project.
Path parameters
project
—
Project UID (uid column of projects), e.g. proj_a1b2c3. Resolved to the internal numeric project id via getProjectId; an unknown uid yields 404 project_not_found (on the API-key path the auth layer first checks that the key's own project matches :project).
Single read-only route. Router({ mergeParams: true }); :project comes from the parent mount at /v3/projects/:project/ip-ranges.
Errors
401Authentication missing1— No Authorization header / no Bearer token present.
401Invalid API key.— API-key path (sk_proj_…): no matching non-revoked api_keys row for the secret prefix, or the stored sha256 hash is not 64 chars / fails the timing-safe comparison.
403API key lacks the required permission.— The project API key (sk_proj_…) is valid but its permissions bitmask does not include READ_COLOCATION (the key is the cap — no owner-bypass).
403API key is not valid for this project.— API-key path: the key authenticates but its project_id/projectUid does not match the :project param in the path.
403Not authorized to access this resource. 1— User-JWT caller is a project member (or has a projectMembers row) but lacks the READ_COLOCATION permission and is not the owner or staff.
403Not authorized to access this resource. 5— User-JWT caller has no projectMembers permissions row (project.permissions undefined) and is neither the project owner nor staff.
403Authentication missing4— Any exception thrown inside middlewareV2 (e.g.
404project_not_found— The :project uid does not resolve to a project row (thrown by getProjectId via the v3 error envelope { error: { code, message, details, request_id } }).
400Project ID required.— User-JWT path: requireProject is true but the request has no :project param.
400Project not found.— User-JWT path: the project lookup join (member/owner/project by uid) returns no row for the given :project + caller.
400Authentication missing2— User-JWT path: the verified token payload has no userUID.
Returns all SSH public keys belonging to the authenticated user, newest first (ordered by descending row id). v3 SSH keys are user-scoped (not project-scoped).
Auth via middlewareV2() with no permission/scope check: accepts a v2 JWT session bearer token or a project API key (sk_proj_), where an API key resolves to the key owner's account. middlewareV2 places userUID on res.locals.user; getUserId converts it to the numeric users.id.
Errors
401unauthenticated— Missing or invalid Authorization bearer token (middlewareV2 rejects before the handler).
403unauthenticated— getUserId throws 403 when res.locals.user.userUID is absent ('Authentication required') or when the UID does not resolve to a user row ('User not found').
Adds a new OpenSSH public key to the user's account. The SHA256 fingerprint is computed server-side from the base64 key body and used to reject duplicates.
Body
namerequired
string (1-64 chars)
Human label for the key.
public_keyrequired
string
OpenSSH public key. Must match ^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp(256|384|521))\s+<base64-body>(\s+comment)?$.
Idempotency is enforced by fingerprint: re-posting an identical key returns 409 key_exists rather than creating a duplicate. The response omits public_key and created_at, returning only uid, name, fingerprint.
Errors
400invalid_request— Body fails the zod schema (name empty or >64 chars, or public_key does not match the OpenSSH key regex — the regex issue carries the message 'invalid OpenSSH public key').
409key_exists— A key with the same computed SHA256 fingerprint already exists on this account (message 'This SSH key is already added').
Partially updates the authenticated user's profile (full name and/or UI language) and returns the full current profile DTO.
Body
fullname
string (1-128 chars)
User's display name. Omit to leave unchanged.
language
string enum: "da" | "en"
Preferred UI language. Omit to leave unchanged.
Both fields are optional; an empty body is valid and performs no UPDATE but still returns the current profile. Only the provided fields are written (dynamic SET clause).
Errors
400invalid_request— Body fails the zod schema (fullname empty/>128 chars, or language not 'da'/'en').
404user_not_found— The user row cannot be re-read after the update.
Starts two-factor (TOTP) enrollment: generates a secret stored encrypted but not yet enabled, and returns the base32 secret plus an otpauth:// URI for the authenticator QR code.
No request body. Side effect: overwrites mfa_secret with encrypt(secret) and sets mfa_enabled=0, replacing any prior unconfirmed secret.
Errors
404user_not_found— The authenticated user id does not resolve to a user row.
409mfa_already_enabled— Two-factor is already enabled (users.mfa_enabled is truthy);
Returns each account quota with its live usage and effective limit (per-account overrides fall back to platform defaults). Covers instances, total memory, template storage, and monthly restores.
Returns a JSON array (not an object), one entry per quota in the fixed order instances, memoryMb, templateStorageGb, restoresPerMonth. Default limits: instances=10, memoryMb=65536, templateStorageGb=100, restoresPerMonth=3, overridable per user via the account_limits table (quota_key/limit_value).
Returns the authenticated user's quota-increase requests, newest first (descending row id), with their current review status.
Returns a JSON array serialized via serializeRequest: DB columns are mapped to camelCase (quota_key->quotaKey, use_case->useCase, created_at->createdAt); requested is coerced via Number(). status is a free string set by staff review (e.g.
Inserts into limit_requests with uid=uid('req') and an implicit status of 'pending' (the response hardcodes status:'pending'); approval is a separate staff/admin action (not in this file). The createdAt in the response is the server's new Date() at insert time rather than the DB-stored value, so it may differ by sub-se
Errors
400invalid_request— Body fails zod: quotaKey not in the allowed enum, requested not a positive integer, or useCase shorter than 10 / longer than 512 chars.
403unauthenticated— getUserId cannot resolve the authenticated user.
Request · cURL
curl -X POST "https://api.v3.suble.io/account/limit-requests" \
-H "Authorization: Bearer $SUBLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"quotaKey": "instances",
"requested": 25,
"useCase": "Scaling out a CI fleet for nightly integration tests"
}'
Request body
{
"quotaKey": "instances",
"requested": 25,
"useCase": "Scaling out a CI fleet for nightly integration tests"
}
Response · 201
{
"uid": "req_5a1f2b3c4d",
"quotaKey": "instances",
"requested": 25,
"useCase": "Scaling out a CI fleet for nightly integration tests",
"status": "pending",
"createdAt": "2026-06-15T11:42:09.123Z"
}
Current user
GET/me
Bearer token→ 200
Returns the profile of the user that owns the credential used for the request. With a project API key (sk_proj_...) it resolves to the key owner; with a user JWT it resolves to the token subject. Reads the shared (v2-owned) users table.
Single route: router.get("/", ...) mounted at /v3/me. Auth accepts either a project API key (Authorization: Bearer sk_proj_...) or a user JWT (Authorization: Bearer <jwt>); no scope/permission is required since middlewareV2() is called with no options.
Errors
403unauthenticated— Auth passed but no userUID could be resolved from res.locals.user (message: "Authentication required").
404user_not_found— No row exists in the users table for the resolved userUID (message: "User not found").
Returns the authenticated account's notifications (newest first), populated by other subsystems such as billing and background jobs. Capped at the 100 most recent.
Mounted via router.get('/') under the /v3/notifications prefix. Guarded by middlewareV2() with no permission and requireProject=false, so no scope/permission is required and it is not project-scoped.
Errors
403unauthenticated— Token is valid but carries no userUID, or the resolved user no longer exists in the users table (thrown by getUserId).
Marks the given notifications (by uid) as read for the authenticated account by setting their read_at timestamp to NOW(). Only currently-unread notifications owned by the caller are affected.
Body
uidsrequired
string[]
Notification uids to mark as read. Validated by zod as z.array(z.string()).min(1), so it must be a non-empty array of strings. Uids not owned by the caller, already-read, or non-existent are silently ignored.
Mounted via router.post('/read') under the /v3/notifications prefix. Guarded by middlewareV2() (no permission, not project-scoped).
Errors
400invalid_request— Body fails zod validation (ZodError), e.g.
403unauthenticated— Authenticated principal has no resolvable user (no userUID, or user row not found).
Marks every currently-unread notification for the authenticated account as read by setting read_at to NOW(). Takes no request body.
Mounted via router.post('/read-all') under the /v3/notifications prefix. Guarded by middlewareV2() (no permission, not project-scoped).
Errors
403unauthenticated— Authenticated principal has no resolvable user (no userUID, or user row not found).
Request · cURL
curl -X POST "https://api.v3.suble.io/notifications/read-all" \
-H "Authorization: Bearer $SUBLE_API_KEY"
Billing & credits
GET/billing/overview
Bearer token→ 200
Returns the account's billing rail, prepaid credit balance, current burn rate, accrued usage for the month, default payment method, upcoming credit expiry, and a provisioning-readiness block that drives the instance wizard gate.
Money fields are integer øre excluding VAT. burnRatePerDayOre = SUM(plan.price_hourly_ore) x 24 across all non-deleted/non-error instances in projects the user is a member of (joined via projectMembers; stopped instances still bill).
Errors
403unauthenticated— Token resolves but has no userUID, or the user row is not found (v3 error envelope: {error:{code,message,details,request_id}}).
Returns per-resource usage records for the current UTC month, grouped by resource, type, SKU and project, ordered by spend descending. Optionally filtered to a single project.
Query parameters
projectUid
string
Restrict usage to a single project (matches projects.uid, e.g. prj_...). Ignored unless it is a string.
Bare JSON array (not wrapped). Aggregates usage_records since the 1st of the current UTC month, grouped by resource_uid, resource_type, sku, project uid and resource_name, ordered by amount_ore DESC.
Errors
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Returns the invoice as a PDF. For the DK (Dinero) rail it streams Dinero's own PDF; for the Stripe rail it 302-redirects to Stripe's hosted invoice URL; otherwise it renders a self-generated PDF from the stored lines and customer details.
Path parameters
uid
—
Invoice uid (inv_...). Must belong to the authenticated user.
Read-only; creates nothing. The invoice is loaded by uid AND user_id.
Errors
404invoice_not_found— No invoice with that uid owned by the user.
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Returns the account's non-revoked payment methods. As a side effect it reconciles any pending MobilePay agreements against Vipps, activating or failing them so the dashboard can poll this route while a method is being approved.
Bare JSON array from payment_methods, ordered by id DESC (newest first), excluding status='revoked'. Side effect: pending mobilepay methods are checked against Vipps (when Vipps is configured) and either activated (also capturing the MobilePay profile onto billing_customers, set as default) or, when STOPPED/EXPIRED, ma
Errors
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Returns the account's saved invoice address and company details (name, email, address, country, VAT/EAN), or null if no billing_customers row exists yet.
Returns JSON null (200) when no billing_customers row exists. A row seeded at signup may have empty address fields (only country + account type known); name/email/street/zipCode/city fall back to empty strings and country defaults to 'DK'.
Errors
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Upserts the account's invoice address and company details and re-derives the billing rail from the country (DK -> MobilePay+Dinero, otherwise Stripe), unless a manual rail override is locked.
Body
isCompanyrequired
boolean
Whether this is a business account.
namerequired
string
Legal/billing name, 1-128 chars.
emailrequired
string
Billing email (validated as an email).
streetrequired
string
Street address, 1-160 chars.
zipCoderequired
string
Postal code, 1-16 chars.
cityrequired
string
City, 1-96 chars.
countryrequired
string
ISO 3166-1 alpha-2 country code, exactly 2 chars (uppercased server-side); drives rail routing.
phone
string
Phone number, max 32 chars.
vatNumber
string
VAT registration number, max 32 chars.
eanNumber
string
EAN/GLN number for public-sector invoicing, max 32 chars.
Returns 204 No Content. Upsert into billing_customers keyed by user_id.
Errors
400invalid_request— Body fails the zod schema (e.g.
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Initiates a one-time prepaid credit top-up. On the Stripe rail it creates a Checkout payment session; on the MobilePay rail it records a pending payment attempt and creates a Vipps ePayment. Returns a redirect URL the customer must complete.
Body
amountOrerequired
integer
Top-up amount in øre, integer, min 5000 (50 DKK), max 10000000 (100,000 DKK).
Provider-gated. Rail comes from billing_customers.rail (defaults to 'mobilepay' when no row/null).
Errors
400invalid_request— amountOre missing, non-integer, below 5000, or above 10000000.
503provider_not_configured— Stripe rail with no STRIPE_SECRET_KEY, or MobilePay rail with ePayment not configured.
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Creates a recurring MobilePay (Vipps) agreement and records a pending payment method. Returns the approval URL the customer must open plus the new method uid.
No request body. Provider-gated on Vipps configuration (VippsProvider.configured).
Errors
503provider_not_configured— Vipps/MobilePay is not configured.
502mobilepay_agreement_failed— Creating the agreement at Vipps threw;
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X POST "https://api.v3.suble.io/billing/payment-methods/mobilepay" \
-H "Authorization: Bearer $SUBLE_API_KEY"
Atomically redeems a promo code, granting the configured credit to the account's ledger. Enforces one redemption per customer and the code's usage cap, active flag, and expiry.
Body
coderequired
string
Promo code, 1-64 chars. Trimmed and uppercased before lookup.
Runs in a DB transaction with the promo_codes row locked FOR UPDATE; a UNIQUE(promo_code_id, user_id) on promo_redemptions enforces one redemption per customer (duplicate insert -> 409). Grants credit via applyCredit (ledger type 'promo'), optionally with a lot expiry computed from the code's credit_expiry_days, then i
Errors
404promo_not_found— Code does not exist.
400promo_inactive— Code is not active.
400promo_expired— Code's expires_at has passed.
400promo_exhausted— Code has reached max_uses.
409promo_already_used— This user already redeemed the code (duplicate insert into promo_redemptions).
400invalid_request— code missing or fails length validation (1-64).
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Returns the account's configured low-balance alert thresholds and whether each is currently armed, highest threshold first.
Bare JSON array from billing_alert_thresholds, ordered by threshold_ore DESC. thresholdOre is integer øre; armed is a boolean derived from the armed column (a fired threshold re-arms when the balance recovers above it).
Errors
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Upserts the account's auto-recharge configuration and optionally records a standing suspend acknowledgment. Enabling auto-recharge requires both a trigger and an amount.
Body
autorechargeEnabledrequired
boolean
Whether auto-recharge is enabled.
autorechargeTriggerOre
integer|null
Balance at/below which auto-recharge fires, in øre; 5000-10000000 or null. Required (non-null) when enabling.
autorechargeAmountOre
integer|null
Amount to recharge in øre; 5000-10000000 or null. Required (non-null) when enabling.
suspendAcknowledged
boolean
Set true to record a standing acknowledgment of suspend-on-empty-balance. Write-once: never cleared once set.
Returns 204 No Content. Upsert into billing_settings keyed by user_id.
Errors
400autorecharge_incomplete— autorechargeEnabled is true but trigger and/or amount is missing/null.
400invalid_request— Body fails the zod schema (e.g.
403unauthenticated— Token resolves but has no userUID, or the user row is not found.
Verifies email/password and returns RS256 JWT access and refresh tokens. If the account has 2FA enabled, the first call without a code returns { mfa_required: true } and the client must re-submit with a 6-digit TOTP code.
6-digit TOTP code, supplied on the second step only when 2FA is enabled on the account.
access_token TTL is 1 day, refresh_token TTL is 30 days. Tokens are RS256 JWTs carrying { userUID, role, type: 'user', tokenType } where role defaults to 'user' when the user row has none.
Errors
200mfa_required— 2FA is enabled and no `code` was supplied;
401invalid_credentials— Email not found, the user has no password set, or the password does not match.
401mfa_code_invalid— 2FA is enabled and the supplied TOTP `code` fails verification against the decrypted secret.
400invalid_request— Body fails zod validation (missing/invalid email or empty password);
Creates a new user (role 'user', emailVerified=0), seeds a default project ('My Project'), seeds a billing_customers row with the payment rail derived from country (DK -> MobilePay, else Stripe), sends a verification email, and returns access/refresh tokens.
Body
emailrequired
string (email)
New account email; must be unique, stored lowercased.
passwordrequired
string
Password, minimum 8 characters; hashed with bcrypt (rounds 10).
fullnamerequired
string
Display name, 1-120 characters.
country
string (ISO 3166-1 alpha-2)
2-letter country code; defaults to 'DK', uppercased server-side. Determines the billing rail and purchase-gate.
isCompany
boolean
Whether the account is a business; stored as is_company (0/1) on the billing_customers row (defaults to false).
language
string ('da' | 'en')
UI language; defaults to 'da'.
Side effects: inserts a users row (role 'user', emailVerified 0), a default projects row ('My Project', ownerID = new user id), and a billing_customers row (rail = railForCountry(country), is_company flag, country) via INSERT ... ON DUPLICATE KEY UPDATE.
Errors
409email_taken— An account with the given email already exists.
400invalid_request— Body fails zod validation (invalid email, password < 8 chars, empty/oversized fullname >120, country not exactly 2 chars, or language not 'da'/'en');
Validates a refresh-type JWT and issues a fresh access/refresh token pair for the same user. The refresh token itself is the credential; no other auth header is required.
Body
refresh_tokenrequired
string
A previously issued refresh JWT (tokenType 'refresh'); min length 1.
Issues a rolling pair: a new 1-day access_token and a new 30-day refresh_token. Tokens are not revoked or rotated via a denylist, so the old refresh token remains valid until natural expiry.
Errors
401invalid_refresh— Token fails signature/expiry verification, is not tokenType 'refresh', or carries no userUID.
400invalid_request— Body is missing refresh_token or it is an empty string;
If the email belongs to an account, generates a one-hour reset token and emails a reset link. Always returns 204 regardless of whether the email exists, to avoid revealing account existence.
Body
emailrequired
string (email)
Email to send the reset link to; matched case-insensitively.
Always responds 204 No Content with an empty body, whether or not the email is registered (enumeration-safe). On a match it inserts a password_resets row with a 64-hex-char token expiring in 1 hour and sends an email (best-effort; send failure is logged, response is unchanged).
Errors
400invalid_request— Body is missing email or contains an invalid email;
Consumes a valid, unused, unexpired reset token, updates the user's password (bcrypt rounds 10), and marks the token used. The token is the credential; no session is required.
Body
tokenrequired
string (64-char hex)
Reset token from the email link; must be exactly 64 characters.
passwordrequired
string
New password, minimum 8 characters.
Single-use: the matching password_resets row is marked used_at=NOW() after the password update. Responds 204 No Content with no body.
Errors
400invalid_token— Token does not exist, was already used (used_at set), or has expired (expires_at <= NOW()).
400invalid_request— Body fails zod validation (token not exactly 64 chars, or password < 8 chars);