Udviklere

Suble v3-API’et

Klargør og administrer cloudinfrastruktur programmatisk — bestil VPS’er, styr Docker-containere og databaser, og byg forhandlerløsninger oven på Suble.

Get an API key OpenAPI spechttps://api.v3.suble.io
Quickstart
# 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.

PermissionGrants
ORDER_PRODUCTOrder/provision new instances
READ_VPSView instances
VPS_CONTROLPower actions (start/stop/reboot)
VPS_DELETEDelete instances
CONSOLE_VNCAccess the VNC console
READ_NETWORK / WRITE_NETWORKView / change instance networking
READ_FIREWALL / WRITE_FIREWALLView / modify firewall rules
READ_BACKUP / WRITE_BACKUPView / create / restore backups
READ_NETWORKS / WRITE_NETWORKSView / manage private networks
READ_SSHKEYS / WRITE_SSHKEYSView / add SSH keys
READ_BILLING / WRITE_BILLINGView / manage billing & payment methods
READ_MEMBERS / WRITE_MEMBERSView / manage project members
READ_API / WRITE_APIList / create & revoke API keys
READ_SETTINGS / WRITE_SETTINGSView / 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.

202 → poll
POST /projects/{project}/instances              # → 202
{ "instance": { "uid": "ins_…" },
  "job": { "uid": "job_…", "type": "instance.create", "status": "queued" } }

GET /projects/{project}/jobs/job_…              # poll every ~2–3s
{ "job": { "status": "running",
           "progress": { "step": 2, "total": 5, "percent": 40, "message": "Cloning disk" } } }
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.

Error envelope
{
  "error": {
    "code": "invalid_request",
    "message": "Validation failed",
    "details": { "issues": [ { "path": "plan", "message": "Required" } ] },
    "request_id": "5f0e3a2b-9c41-4e7a-bb6e-1d2f3a4b5c6d"
  }
}

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.

Status codes

StatusMeaning
400Invalid input or failed validation
401Bad/missing credentials (login, refresh, MFA, MCP challenge)
402Billing gate — owner not yet billable
403Not authenticated, unverified email, or insufficient permission
404Resource not found
409State or uniqueness conflict
501Feature not implemented yet
503Payment provider not configured

Common codes

CodeStatusWhen
invalid_request400Body/params failed validation — details.issues[] lists each {path, message}
email_unverified403Email not verified when creating an instance
unauthenticated403No authenticated user in context
forbidden403Non-staff hitting an /admin route
billing_need_card402DK business owner has no active card
billing_need_card_or_credit402Non-DK owner: no card and < 50 DKK credit
billing_need_mobilepay_or_credit402DK private owner: no MobilePay agreement and < 50 DKK credit
instance_not_found404Instance uid missing or not in the project
app_not_found404App catalog entry or installed app missing
plan_not_found / image_not_found404Plan code or OS image unknown/inactive
invoice_not_found404Invoice uid does not resolve
name_taken409Instance name already used in the project
operation_in_progress409A job is already running for the resource — poll it, don't retry
invalid_state409Action not allowed in the resource's current state
nic_limit / subnet_full409Private-network attach hit a capacity limit
disk_too_small / disk_shrink_unsupported400Requested disk below minimum, or a resize tried to shrink
plan_below_app_minimum400Chosen plan is below the app's min vCPU/RAM/disk
not_implemented501Feature not built yet (e.g. instance rescue mode)
provider_not_configured503Payment provider (Stripe/MobilePay/Dinero) env not configured
internal_error500Catch-all for any uncaught server exception

API reference

Every endpoint, with request bodies, responses, and a runnable snippet. Search and switch languages below.

OpenAPI

Catalog

GET/plans
Bearer token200

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
Request · cURL
curl "https://api.v3.suble.io/plans" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "plans": [
    {
      "uid": "pln_bxs1",
      "code": "bxs-1",
      "name": "BXS 1",
      "family": "bxs",
      "vcpu": 1,
      "memoryMb": 2048,
      "diskGb": 40,
      "networkMbps": 10000,
      "priceMonthlyOre": 4000,
      "priceHourlyOre": 6
    }
  ]
}
GET/images
Bearer token200

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.
Request · cURL
curl "https://api.v3.suble.io/images" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "images": [
    {
      "uid": "img_ubuntu2404",
      "family": "ubuntu",
      "version": "24.04",
      "displayName": "Ubuntu 24.04 LTS",
      "protocol": "ssh",
      "defaultUser": "ubuntu",
      "minDiskGb": 10,
      "hasGuestAgent": true
    }
  ]
}
GET/apps
Bearer token200

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.
Request · cURL
curl "https://api.v3.suble.io/apps" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "apps": [
    {
      "uid": "app_coolify",
      "slug": "coolify",
      "version": "4.1.0",
      "name": "Coolify",
      "description": "Self-hostable PaaS to deploy apps and databases.",
      "logo": "https://cdn.suble.io/apps/coolify.svg",
      "category": "panel",
      "requirements": {
        "minVcpu": 2,
        "minMemoryMb": 4096,
        "minDiskGb": 40,
        "image": {
          "family": "ubuntu",
          "version": "24.04"
        }
      }
    }
  ]
}
GET/apps/:slug
Bearer token200

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

slugApp 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_foundNo active app exists with the given slug
Request · cURL
curl "https://api.v3.suble.io/apps/$SLUG" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "app": {
    "uid": "app_coolify",
    "slug": "coolify",
    "version": "4.1.0",
    "name": "Coolify",
    "description": "Self-hostable PaaS to deploy apps and databases.",
    "logo": "https://cdn.suble.io/apps/coolify.svg",
    "category": "panel",
    "requirements": {
      "minVcpu": 2,
      "minMemoryMb": 4096,
      "minDiskGb": 40,
      "image": {
        "family": "ubuntu",
        "version": "24.04"
      }
    }
  }
}

Projects & members

GET/projects
Bearer token200

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.
Request · cURL
curl "https://api.v3.suble.io/projects" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "prj_8kd2mq4zr1ab",
    "name": "Production",
    "category": "business",
    "role": "owner",
    "instanceCount": 3,
    "memberCount": 2,
    "createdAt": "2026-03-14T09:21:00.000Z"
  }
]
POST/projects
Bearer token201

Creates a new project owned by the authenticated caller and returns the created project entity.

Body

namerequiredstringProject display name; coerced to string and trimmed, must be non-empty.
categorystringAccepted 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
Request · cURL
curl -X POST "https://api.v3.suble.io/projects" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Production",
  "category": "business"
}'
Request body
{
  "name": "Production",
  "category": "business"
}
Response · 201
{
  "uid": "prj_8kd2mq4zr1ab",
  "name": "Production",
  "category": "business",
  "role": "owner",
  "instanceCount": 0,
  "memberCount": 1,
  "createdAt": "2026-06-20T12:00:00.000Z"
}
GET/projects/:project
Bearer token200

Fetches a single project by its uid, with live instance and member counts.

Path parameters

projectProject 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_foundProject passes middleware but the handler read-back returns nothing (rare)
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "uid": "prj_8kd2mq4zr1ab",
  "name": "Production",
  "category": "business",
  "role": "owner",
  "instanceCount": 3,
  "memberCount": 2,
  "createdAt": "2026-03-14T09:21:00.000Z"
}
PATCH/projects/:project
Bearer tokenWRITE_SETTINGS200

Renames a project. Only the name field is mutable; returns the updated project.

Path parameters

projectProject uid (prj_ prefix).

Body

namestringNew 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_foundProject row read-back returns nothing after the update
Request · cURL
curl -X PATCH "https://api.v3.suble.io/projects/$PROJECT" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Production EU"
}'
Request body
{
  "name": "Production EU"
}
Response · 200
{
  "uid": "prj_8kd2mq4zr1ab",
  "name": "Production EU",
  "category": "business",
  "role": "owner",
  "instanceCount": 3,
  "memberCount": 2,
  "createdAt": "2026-03-14T09:21:00.000Z"
}
DELETE/projects/:project
Bearer tokenWRITE_SETTINGS204

Deletes a project and its member rows. Refuses if the project still has non-deleted instances.

Path parameters

projectProject uid (prj_ prefix).
middlewareV2({ requireProject: true, permission: 'WRITE_SETTINGS' }). Returns no body (204).

Errors

  • 404not_foundProject uid does not resolve to a row in the handler
  • 409project_not_emptyThe project still has one or more instances whose status is not 'deleted'
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
GET/projects/:project/members
Bearer token200

Lists the project owner plus all invited members with their role and acceptance status.

Path parameters

projectProject 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).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/members" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "email": "owner@acme.com",
    "fullname": "Ada Owner",
    "role": "owner",
    "status": "accepted"
  },
  {
    "email": "dev@acme.com",
    "fullname": "Dev Member",
    "role": "member",
    "status": "accepted"
  }
]
POST/projects/:project/members
Bearer tokenWRITE_MEMBERS204

Adds an existing Suble user (by email) to the project as a member with full project permissions.

Path parameters

projectProject uid (prj_ prefix).

Body

emailrequiredstringEmail of an existing Suble user to add; coerced to string, trimmed, and lower-cased.
rolestringDocumented in the router header ({ email, role? }) but ignored by the handler; all members receive the same MEMBER_PERMS bitmask.
middlewareV2({ requireProject: true, permission: 'WRITE_MEMBERS' }). Returns no body (204).

Errors

  • 400invalid_email`email` is missing or empty after trimming
  • 404not_foundProject uid does not resolve in the handler
  • 404user_not_foundNo Suble user exists with the given email (v3 invite emails land with the prod-migration phase)
  • 409already_memberThat user is already a member of the project
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/members" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "dev@acme.com",
  "role": "member"
}'
Request body
{
  "email": "dev@acme.com",
  "role": "member"
}
PATCH/projects/:project/members/:email
Bearer tokenWRITE_MEMBERS204

Accepts a member role change. In the sandbox role is advisory (members always carry full permissions), so this is a no-op.

Path parameters

projectProject uid (prj_ prefix).
emailMember 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).
Request · cURL
curl -X PATCH "https://api.v3.suble.io/projects/$PROJECT/members/$EMAIL" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
DELETE/projects/:project/members/:email
Bearer tokenWRITE_MEMBERS204

Removes a member from the project, matched by the member's email address.

Path parameters

projectProject uid (prj_ prefix).
emailMember email address; the handler decodeURIComponent-decodes the path segment.
middlewareV2({ requireProject: true, permission: 'WRITE_MEMBERS' }). Returns no body (204).
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/members/$EMAIL" \
  -H "Authorization: Bearer $SUBLE_API_KEY"

Instances

GET/projects/:project/instances
Bearer tokenREAD_VPS200

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

projectProject 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.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "instances": [
    {
      "uid": "ins_a1b2c3d4e5f6",
      "name": "web-01",
      "status": "running",
      "power_state": "running",
      "backup_tier": "basic",
      "error_code": null,
      "error_message": null,
      "created_at": "2026-06-18T09:12:44.000Z",
      "plan_code": "bxs-2-4",
      "primary_ip": "185.234.12.7",
      "image_name": "Ubuntu 24.04 LTS",
      "app_slug": null,
      "app_name": null,
      "job_uid": null,
      "job_type": null,
      "progress_step": null,
      "progress_total": null,
      "progress_percent": null,
      "progress_message": null
    }
  ]
}
POST/projects/:project/instances
Bearer tokenORDER_PRODUCT202

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

projectProject uid

Body

namerequiredstringHostname: lowercase RFC1123 label, regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$. Must be unique among active instances in the project.
planrequiredstringPlan code (e.g. bxs-2-4); must reference an active plan (active = 1).
sourcerequiredobjectExactly one of {image: <os_image uid>}, {template: <template uid>}, or {app: <app slug>} (zod union).
ssh_keysstring[]SSH public keys snapshotted onto the instance (stored in instances.ssh_keys_snapshot as JSON). Not passed in the job payload.
networksstring[]Private network uids to attach; passed through to the create job payload (defaults to []).
backup_tierstringOne of none|basic|extended. Defaults to none.
passwordstringRoot/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.

Errors

  • 400invalid_requestBody fails zod validation (bad hostname, missing plan/source, password length, etc.)
  • 400plan_below_app_minimumsource.app: plan vcpu/memory_mb/disk_gb below the app's min_vcpu/min_memory_mb/min_disk_gb
  • 400agent_requiredsource.app: chosen base image has no guest agent (has_guest_agent falsy)
  • 400disk_too_smallsource.image/template: plan disk_gb below the image/template min_disk_gb
  • 402billing_<reason>Project owner is not billable (non-DK needs 50 DKK credit;
  • 403email_unverifiedCalling user's emailVerified flag is not set
  • 404plan_not_foundplan code not found / inactive
  • 404app_not_foundsource.app slug not found / inactive
  • 404image_not_foundsource.image uid (or app base image family/version) not found / inactive
  • 404template_not_foundsource.template uid not found in this project
  • 409name_takenAn active instance with this name already exists in the project
  • 409template_not_readysource.template status is not 'ready'
Request · cURL
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": {
    "image": "img_ubuntu2404lts"
  },
  "ssh_keys": [
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... me@host"
  ],
  "networks": [
    "net_9f8e7d6c5b4a"
  ],
  "backup_tier": "basic"
}'
Request body
{
  "name": "web-01",
  "plan": "bxs-2-4",
  "source": {
    "image": "img_ubuntu2404lts"
  },
  "ssh_keys": [
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... me@host"
  ],
  "networks": [
    "net_9f8e7d6c5b4a"
  ],
  "backup_tier": "basic"
}
Response · 202
{
  "instance": {
    "uid": "ins_a1b2c3d4e5f6",
    "name": "web-01",
    "status": "queued"
  },
  "job": {
    "uid": "job_112233445566",
    "type": "instance.create",
    "status": "queued"
  }
}
GET/projects/:project/instances/:instance
Bearer tokenREAD_VPS200

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

projectProject uid
instanceInstance 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_foundNo active instance with this uid in the project
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "instance": {
    "uid": "ins_a1b2c3d4e5f6",
    "name": "web-01",
    "status": "running",
    "power_state": "running",
    "backup_tier": "basic",
    "error_code": null,
    "error_message": null,
    "created_at": "2026-06-18T09:12:44.000Z",
    "plan_code": "bxs-2-4",
    "primary_ip": "185.234.12.7",
    "image_name": "Ubuntu 24.04 LTS",
    "app_slug": null,
    "app_name": null,
    "job_uid": null,
    "job_type": null,
    "progress_step": null,
    "progress_total": null,
    "progress_percent": null,
    "progress_message": null,
    "root_password": "a1b2c3d4e5f6!k9m2p1X"
  }
}
POST/projects/:project/instances/:instance/password/acknowledge
Bearer tokenREAD_VPS204

Marks the one-time root password as seen by nulling the encrypted column so it is no longer returned by the detail endpoint.

Path parameters

projectProject uid
instanceInstance uid
Auth permission is READ_VPS (not VPS_CONTROL). Idempotent and destructive: sets instances.root_password_enc = NULL.

Errors

  • 404instance_not_foundNo active instance with this uid in the project
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/password/acknowledge" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
PATCH/projects/:project/instances/:instance
Bearer tokenVPS_CONTROL200

Changes an instance's hostname/name. Validates the new name and enforces per-project uniqueness among active instances.

Path parameters

projectProject uid
instanceInstance uid

Body

namerequiredstringNew hostname: regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$, unique among active instances in the project (excluding self).
Synchronous DB update of instances.name only; does not change the VM hostname inside the guest. No job is enqueued.

Errors

  • 400invalid_requestname fails the hostname regex
  • 404instance_not_foundNo active instance with this uid in the project
  • 409name_takenAnother active instance in the project already uses this name
Request · cURL
curl -X PATCH "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "web-02"
}'
Request body
{
  "name": "web-02"
}
Response · 200
{
  "instance": {
    "uid": "ins_a1b2c3d4e5f6",
    "name": "web-02"
  }
}
DELETE/projects/:project/instances/:instance
Bearer tokenVPS_DELETE202

Enqueues an async instance.delete job that destroys the VM and frees its resources, optionally taking a final backup first.

Path parameters

projectProject uid
instanceInstance uid

Query parameters

final_backupbooleanPass 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_foundNo active instance with this uid in the project
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 202
{
  "job": {
    "uid": "job_223344556677",
    "type": "instance.delete",
    "status": "queued"
  }
}
POST/projects/:project/instances/:instance/actions
Bearer tokenVPS_CONTROL202

Enqueues a power action (start, stop, shutdown, reboot, reset) after checking the instance is in an allowed state for that action.

Path parameters

projectProject uid
instanceInstance uid

Body

typerequiredstringOne 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_requesttype is not one of the allowed power actions (zod enum)
  • 404instance_not_foundNo active instance with this uid in the project
  • 409invalid_stateInstance status does not satisfy the action's precondition (POWER_PRECONDITIONS)
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/actions" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "type": "reboot"
}'
Request body
{
  "type": "reboot"
}
Response · 202
{
  "job": {
    "uid": "job_334455667788",
    "type": "instance.power",
    "status": "queued"
  }
}
POST/projects/:project/instances/:instance/ssh-key
Bearer tokenVPS_CONTROL200

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

projectProject uid
instanceInstance uid

Body

publicKeyrequiredstringA 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_keypublicKey is missing or not a well-formed single-line OpenSSH public key
  • 404instance_not_foundNo active instance with this uid in the project
  • 409invalid_stateInstance has no running VM yet (vmTarget: missing vmid or node_name)
  • 409ssh_key_failedThe guest-agent script returned a non-zero exit code
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/ssh-key" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrGq... me@laptop"
}'
Request body
{
  "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrGq... me@laptop"
}
Response · 200
{
  "added": true,
  "alreadyPresent": false,
  "user": "ubuntu",
  "ip": "185.234.12.7"
}
GET/projects/:project/instances/:instance/containers
Bearer tokenREAD_VPS200

Runs `docker ps -a` live on the VM via the guest agent and returns the parsed container list.

Path parameters

projectProject uid
instanceInstance uid
Synchronous guest-agent exec via pve.agentRun (20s timeout, DOCKER_PS constant). Returns all containers (running and stopped) via `docker ps -a`.

Errors

  • 404instance_not_foundNo active instance with this uid in the project
  • 409invalid_stateInstance has no running VM yet (vmTarget: missing vmid or node_name)
  • 409docker_unavailableDocker is not installed/available (guest-agent DOCKER_PS script returned non-zero)
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/containers" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "containers": [
    {
      "id": "3f9a2b1c4d5e6f70...",
      "name": "gitea",
      "image": "gitea/gitea:1.22",
      "state": "running",
      "status": "Up 3 days",
      "ports": "0.0.0.0:3000->3000/tcp"
    }
  ]
}
GET/projects/:project/instances/:instance/containers/:containerId/logs
Bearer tokenREAD_VPS200

Runs `docker logs` for a container on the VM via the guest agent and returns the tail of combined stdout/stderr.

Path parameters

projectProject uid
instanceInstance uid
containerIdContainer id or name; validated inside dockerLogsScript

Query parameters

tailnumberNumber of trailing log lines passed to dockerLogsScript. Defaults to 500 when omitted.
Synchronous guest-agent exec via pve.agentRun (20s timeout). The returned logs are truncated to the last 100,000 characters (.slice(-100_000)).

Errors

  • 400invalid_requestdockerLogsScript throws (e.g.
  • 404instance_not_foundNo active instance with this uid in the project
  • 409invalid_stateInstance has no running VM yet (vmTarget: missing vmid or node_name)
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/containers/$CONTAINERID/logs" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "logs": "2026-06-19T08:01:22.114Z Server listening on :3000\n2026-06-19T08:01:23.998Z Ready\n"
}
POST/projects/:project/instances/:instance/app/action
Bearer tokenVPS_CONTROL202

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

projectProject uid
instanceInstance uid

Body

actionrequiredstringOne 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).
argsobjectAction 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_requestBody fails zod validation (unknown action enum value)
  • 400invalid_actionbuildActionScript throws an ActionError (e.g.
  • 404instance_not_foundNo active instance with this uid in the project
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/app/action" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "action": "docker.create",
  "args": {
    "image": "nginx:1.27",
    "name": "proxy",
    "ports": [
      "8080:80"
    ],
    "restart": "unless-stopped"
  }
}'
Request body
{
  "action": "docker.create",
  "args": {
    "image": "nginx:1.27",
    "name": "proxy",
    "ports": [
      "8080:80"
    ],
    "restart": "unless-stopped"
  }
}
Response · 202
{
  "job": {
    "uid": "job_445566778899",
    "type": "app.action",
    "status": "queued"
  }
}
POST/projects/:project/instances/:instance/resize
Bearer tokenVPS_CONTROL202

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

projectProject uid
instanceInstance uid

Body

planrequiredstringTarget 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_requestBody fails zod validation (missing plan)
  • 400disk_shrink_unsupportedTarget plan disk_gb is smaller than the current plan disk_gb
  • 404instance_not_foundNo active instance with this uid in the project
  • 404plan_not_foundTarget plan code not found / inactive
  • 409operation_in_progressInstance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/resize" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "plan": "bxs-4-8"
}'
Request body
{
  "plan": "bxs-4-8"
}
Response · 202
{
  "uid": "job_556677889900",
  "type": "instance.resize",
  "status": "queued",
  "createdAt": "2026-06-20T10:15:00.000Z"
}
POST/projects/:project/instances/:instance/rebuild
Bearer tokenVPS_DELETE202

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

projectProject uid
instanceInstance uid

Body

sourcerequiredobjectExactly one of {image: <os_image uid>} or {template: <template uid>} to rebuild from (zod union).
sshKeysstring[]Replacement SSH public keys snapshot (written to instances.ssh_keys_snapshot only if provided).
backupbooleanWhether 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_requestBody fails zod validation (missing/invalid source)
  • 400disk_too_smallsource.image: plan disk_gb below the image min_disk_gb
  • 404instance_not_foundNo active instance with this uid in the project
  • 404image_not_foundsource.image uid not found / inactive
  • 404template_not_foundsource.template uid not found in this project
  • 409operation_in_progressInstance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)
  • 409template_not_readysource.template status is not 'ready'
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/rebuild" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "source": {
    "image": "img_debian12"
  },
  "sshKeys": [
    "ssh-ed25519 AAAAC3... me@host"
  ],
  "backup": true
}'
Request body
{
  "source": {
    "image": "img_debian12"
  },
  "sshKeys": [
    "ssh-ed25519 AAAAC3... me@host"
  ],
  "backup": true
}
Response · 202
{
  "uid": "job_667788990011",
  "type": "instance.rebuild",
  "status": "queued",
  "createdAt": "2026-06-20T10:20:00.000Z"
}
POST/projects/:project/instances/:instance/password
Bearer tokenVPS_CONTROL202

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

projectProject uid
instanceInstance uid

Body

passwordstringNew 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.

Errors

  • 400invalid_requestpassword fails zod validation (length 8-72)
  • 404instance_not_foundNo active instance with this uid in the project
  • 409operation_in_progressInstance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/password" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "password": "S3cure-pass-2026"
}'
Request body
{
  "password": "S3cure-pass-2026"
}
Response · 202
{
  "uid": "job_778899001122",
  "type": "instance.reset_password",
  "status": "queued",
  "password": "a1b2c3d4e5f6!k9m2p1X",
  "createdAt": "2026-06-20T10:25:00.000Z"
}
GET/projects/:project/instances/:instance/events
Bearer tokenREAD_VPS200

Returns the instance's lifecycle event feed (newest first) from the shared actions log, with opaque base64 cursor pagination.

Path parameters

projectProject uid
instanceInstance uid

Query parameters

limitnumberMax events to return, capped at 200 (Math.min(limit,200)). Defaults to 50.
cursorstringOpaque 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_foundNo active instance with this uid in the project
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/events" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "events": [
    {
      "uid": "evt_aabbccddeeff",
      "type": "create.requested",
      "message": "Instance creation requested",
      "meta": null,
      "created_at": "2026-06-18T09:12:44.000Z",
      "user_email": "alex@example.com"
    }
  ],
  "next_cursor": "MTIz"
}
GET/projects/:project/instances/:instance/app
Bearer tokenREAD_VPS200

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

projectProject uid
instanceInstance 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_foundNo instance_apps row exists for this instance
  • 404instance_not_foundNo active instance with this uid in the project
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/app" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "app": {
    "status": "installed",
    "current_step": 7,
    "total_steps": 7,
    "step_name": "finalize",
    "exit_code": 0,
    "info": {
      "display": [
        {
          "label": "Admin URL",
          "value": "https://185.234.12.7:3000"
        },
        {
          "label": "Admin password",
          "value": "s3cr3t-generated-pw"
        }
      ]
    },
    "retry_count": 0,
    "health_checked_at": "2026-06-19T08:10:00.000Z",
    "started_at": "2026-06-18T09:13:00.000Z",
    "finished_at": "2026-06-18T09:21:00.000Z",
    "slug": "gitea",
    "version": "1.22",
    "name": "Gitea",
    "logo_url": "https://assets.suble.io/apps/gitea.svg",
    "display": "[...]"
  }
}
GET/projects/:project/instances/:instance/app/state
Bearer tokenREAD_VPS200

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

projectProject uid
instanceInstance 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_foundNo active instance with this uid in the project
  • 404app_not_foundInstance has a vmid and is running/installing but no installed app (instance_apps JOIN) was found
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/app/state" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "running": true,
  "state": {
    "version": "1.22.3",
    "repos": 42,
    "users": 8,
    "service": "active"
  }
}
GET/projects/:project/instances/:instance/app/tables
Bearer tokenREAD_VPS200

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

projectProject uid
instanceInstance uid

Query parameters

databaserequiredstringDatabase 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_engineInstalled app slug/engine is not mysql, mariadb, or postgresql (SQL_TABLE_ENGINES)
  • 400invalid_databasedbListTablesScript throws for an invalid database identifier
  • 404instance_not_foundNo active instance with this uid in the project
  • 404app_not_foundVM is up and running/installing but no installed app (instance_apps JOIN) found
  • 502db_query_failedThe guest-agent table-listing script returned non-zero
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/app/tables" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "tables": [
    {
      "name": "users",
      "size": "12.50"
    },
    {
      "name": "sessions",
      "size": "3.20"
    }
  ]
}
POST/projects/:project/instances/:instance/app/retry
Bearer tokenVPS_CONTROL202

Re-enqueues the app install job for an instance whose app install previously failed, resetting the app status to pending and incrementing retry_count.

Path parameters

projectProject uid
instanceInstance uid
Async: returns a job (app.install, priority 5, maxAttempts 2, serializeOnInstance true, payload {}) to poll. Side effects (before enqueue): sets instance_apps.status='pending' and increments retry_count.

Errors

  • 404app_not_foundNo instance_apps row exists for this instance
  • 404instance_not_foundNo active instance with this uid in the project
  • 409invalid_stateThe instance_apps.status is not 'failed' (only failed installs can be retried)
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/app/retry" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 202
{
  "job": {
    "uid": "job_889900112233",
    "type": "app.install",
    "status": "queued"
  }
}

Backups, restore, resize

GET/projects/:project/instances/:instance/backups
Bearer tokenREAD_VPS200

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

projectProject uid the instance belongs to (e.g. proj_...). Resolved to an internal id; must exist.
instanceInstance 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

  • 401unauthorizedNo Authorization header / bearer token, or invalid JWT / API key.
  • 403forbiddenAPI key lacks READ_VPS, member lacks READ_VPS, or caller is not authorized for the project.
  • 400project_lookup_failedJWT 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_foundHandler-level getProjectId() finds no project for the :project uid.
  • 404instance_not_foundThe :instance uid does not exist in the project or has status 'deleted'.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backups" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "bak_9fK2mQ7xR1",
    "instanceUid": "ins_4Tb8vC2pL0",
    "type": "manual",
    "status": "success",
    "sizeGb": 12,
    "note": "before kernel upgrade",
    "expiresAt": "2026-07-20T08:00:00.000Z",
    "createdAt": "2026-06-20T08:00:00.000Z"
  },
  {
    "uid": "bak_2aH5nP9wT4",
    "instanceUid": "ins_4Tb8vC2pL0",
    "type": "auto",
    "status": "success",
    "sizeGb": 11,
    "createdAt": "2026-06-19T03:00:00.000Z"
  }
]
POST/projects/:project/instances/:instance/backups
Bearer tokenVPS_CONTROL202

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

projectProject uid (e.g. proj_...).
instanceInstance uid (e.g. ins_...) to snapshot.

Body

notestringOptional 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_requestBody fails the zod schema (e.g.
  • 401unauthorizedMissing/invalid auth token
  • 403forbiddenCaller lacks VPS_CONTROL for the project
  • 404project_not_foundHandler getProjectId() finds no project for the :project uid
  • 404instance_not_foundInstance uid not found in project or status 'deleted'
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backups" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "note": "before kernel upgrade"
}'
Request body
{
  "note": "before kernel upgrade"
}
Response · 202
{
  "uid": "job_7Qd3rZ8mK2",
  "type": "backup.create",
  "status": "queued",
  "createdAt": "2026-06-20T08:00:00.000Z"
}
POST/projects/:project/instances/:instance/backups/:backup/restore
Bearer tokenVPS_CONTROL202

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

projectProject uid (e.g. proj_...).
instanceInstance uid (e.g. ins_...) to restore.
backupBackup 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

  • 401unauthorizedMissing/invalid auth token
  • 403forbiddenCaller lacks VPS_CONTROL for the project
  • 404project_not_foundProject uid not found
  • 404instance_not_foundInstance uid not found in project or deleted
  • 409operation_in_progressInstance status is not 'running' or 'stopped' (busy).
  • 404backup_not_foundNo 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"
Response · 202
{
  "uid": "job_5Hb1tY4nQ7",
  "type": "instance.restore",
  "status": "queued",
  "createdAt": "2026-06-20T08:00:00.000Z"
}
DELETE/projects/:project/instances/:instance/backups/:backup
Bearer tokenVPS_DELETE202

Marks the backup row as 'deleting' and enqueues a backup.delete job to remove the underlying snapshot from PBS. Returns the queued job.

Path parameters

projectProject uid (e.g. proj_...).
instanceInstance uid (e.g. ins_...) the backup belongs to.
backupBackup uid (e.g. bak_...) to delete; must belong to the instance and not already have status 'deleted'.
Async soft-delete. An UPDATE flips the backup to status 'deleting' WHERE uid = ?

Errors

  • 401unauthorizedMissing/invalid auth token
  • 403forbiddenCaller lacks VPS_DELETE for the project
  • 404project_not_foundProject uid not found
  • 404instance_not_foundInstance uid not found in project or deleted
  • 404backup_not_foundThe UPDATE matched 0 rows: no backup with this uid for the instance, or it is already status 'deleted'
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backups/$BACKUP" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 202
{
  "uid": "job_8Wc2pK6rL3",
  "type": "backup.delete",
  "status": "queued",
  "createdAt": "2026-06-20T08:00:00.000Z"
}
POST/projects/:project/instances/:instance/backups/:backup/template
Bearer tokenVPS_CONTROL202

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

projectProject uid (e.g. proj_...).
instanceInstance 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.
backupBackup uid (e.g. bak_...) to base the template on; must belong to the instance and have status 'success'.

Body

namerequiredstringDisplay 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_requestBody fails zod validation (name missing, empty, or longer than 64 chars).
  • 401unauthorizedMissing/invalid auth token
  • 403forbiddenCaller lacks VPS_CONTROL for the project
  • 404project_not_foundProject uid not found
  • 404instance_not_foundInstance uid not found in project or deleted
  • 404backup_not_foundNo backup with this uid for the instance whose status is 'success'
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backups/$BACKUP/template" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "web-stack-base"
}'
Request body
{
  "name": "web-stack-base"
}
Response · 202
{
  "uid": "job_3Rd9mT1xK8",
  "type": "template.create",
  "status": "queued",
  "createdAt": "2026-06-20T08:00:00.000Z"
}
PUT/projects/:project/instances/:instance/backup-tier
Bearer tokenVPS_CONTROL204

Updates the instance's automatic backup tier, which controls scheduled backup frequency/retention and associated billing. Applies synchronously to the instance row.

Path parameters

projectProject uid (e.g. proj_...).
instanceInstance uid (e.g. ins_...) whose backup tier is being set.

Body

tierrequiredstring (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_requestBody fails zod validation (tier missing or not one of none|basic|extended).
  • 401unauthorizedMissing/invalid auth token
  • 403forbiddenCaller lacks VPS_CONTROL for the project
  • 404project_not_foundProject uid not found
  • 404instance_not_foundInstance uid not found in project or deleted
Request · cURL
curl -X PUT "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/backup-tier" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "tier": "extended"
}'
Request body
{
  "tier": "extended"
}

Firewall

GET/projects/:project/instances/:instance/firewall
Bearer tokenREAD_VPS200

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

projectProject UID (e.g. proj_…). With an API key it must equal the key's bound project (mismatch is rejected 403 before the route runs).
instanceInstance 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_foundThe :project UID does not resolve to a project (getProjectId).
  • 404instance_not_foundNo instance with the :instance UID exists in the project with status != 'deleted'.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/firewall" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "fwr_8x2k9q4m1p",
    "position": 1,
    "direction": "in",
    "action": "ACCEPT",
    "proto": "tcp",
    "source": "203.0.113.0/24",
    "dport": "22",
    "comment": "SSH from office",
    "enabled": true
  },
  {
    "uid": "fwr_3a7d2f9c0e",
    "position": 2,
    "direction": "in",
    "action": "DROP",
    "enabled": true
  }
]
POST/projects/:project/instances/:instance/firewall
Bearer tokenVPS_CONTROL201

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

projectProject UID. With an API key it must equal the key's bound project (mismatch -> 403).
instanceInstance UID (ins_…) to attach the rule to. Must belong to the project and have status != 'deleted'.

Body

directionrequiredstring enum: "in" | "out"Traffic direction the rule matches.
actionrequiredstring enum: "ACCEPT" | "DROP" | "REJECT"What to do with matching traffic.
protostring (max 16)Protocol, e.g. tcp, udp, icmp. Omit for any (stored NULL).
sourcestring (max 512)Source address/CIDR/alias to match. Omit for any (stored NULL).
deststring (max 512)Destination address/CIDR/alias to match. Omit for any (stored NULL).
sportstring (max 64)Source port or port range/list (Proxmox syntax). Stored NULL if omitted.
dportstring (max 64)Destination port or port range/list (Proxmox syntax). Stored NULL if omitted.
commentstring (max 128)Free-text label for the rule. Stored NULL if omitted.
enabledbooleanWhether 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_requestBody 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_foundThe :project UID does not resolve (getProjectId).
  • 404instance_not_foundNo instance with the :instance UID exists in the project with status != 'deleted'.
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/firewall" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "direction": "in",
  "action": "ACCEPT",
  "proto": "tcp",
  "source": "203.0.113.0/24",
  "dport": "22",
  "comment": "SSH from office",
  "enabled": true
}'
Request body
{
  "direction": "in",
  "action": "ACCEPT",
  "proto": "tcp",
  "source": "203.0.113.0/24",
  "dport": "22",
  "comment": "SSH from office",
  "enabled": true
}
Response · 201
{
  "uid": "fwr_8x2k9q4m1p",
  "position": 3,
  "direction": "in",
  "action": "ACCEPT",
  "proto": "tcp",
  "source": "203.0.113.0/24",
  "dport": "22",
  "comment": "SSH from office",
  "enabled": true
}
PATCH/projects/:project/instances/:instance/firewall/:rule
Bearer tokenVPS_CONTROL200

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

projectProject UID. With an API key it must equal the key's bound project (mismatch -> 403).
instanceInstance UID (ins_…). Must belong to the project and have status != 'deleted'.
ruleFirewall rule UID (fwr_…) to update; must belong to the instance (matched on uid AND instance_id).

Body

directionstring enum: "in" | "out"New traffic direction.
actionstring enum: "ACCEPT" | "DROP" | "REJECT"New action.
protostring (max 16)New protocol; an empty string (falsy) clears it to NULL (any).
sourcestring (max 512)New source; empty string clears to NULL.
deststring (max 512)New destination; empty string clears to NULL.
sportstring (max 64)New source port spec; empty string clears to NULL.
dportstring (max 64)New destination port spec; empty string clears to NULL.
commentstring (max 128)New comment; empty string clears to NULL.
enabledbooleanEnable/disable the rule (stored as 1/0).
positioninteger (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_requestBody 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_foundThe :project UID does not resolve (getProjectId).
  • 404instance_not_foundNo instance with the :instance UID exists in the project with status != 'deleted'.
  • 404rule_not_foundNo firewall rule with this UID exists for this instance (uid + instance_id lookup).
Request · cURL
curl -X PATCH "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/firewall/$RULE" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": false,
  "comment": "Temporarily disabled",
  "position": 1
}'
Request body
{
  "enabled": false,
  "comment": "Temporarily disabled",
  "position": 1
}
Response · 200
{
  "uid": "fwr_8x2k9q4m1p",
  "position": 1,
  "direction": "in",
  "action": "ACCEPT",
  "proto": "tcp",
  "source": "203.0.113.0/24",
  "dport": "22",
  "comment": "Temporarily disabled",
  "enabled": false
}
DELETE/projects/:project/instances/:instance/firewall/:rule
Bearer tokenVPS_CONTROL204

Permanently removes a firewall rule from the instance by UID, then best-effort enqueues a firewall.sync job to update Proxmox.

Path parameters

projectProject UID. With an API key it must equal the key's bound project (mismatch -> 403).
instanceInstance UID (ins_…). Must belong to the project and have status != 'deleted'.
ruleFirewall 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_foundThe :project UID does not resolve (getProjectId).
  • 404instance_not_foundNo instance with the :instance UID exists in the project with status != 'deleted'.
  • 404rule_not_foundDELETE affected 0 rows (no rule with this UID for the instance).
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/instances/$INSTANCE/firewall/$RULE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"

Private networks

GET/projects/:project/networks
Bearer tokenREAD_VPS200

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

projectProject 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_foundgetProjectId finds no project with the given uid (route-layer error envelope with code).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/networks" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "net_a1b2c3d4e5f6",
    "projectUid": "prj_9z8y7x6w5v4u",
    "name": "backend-lan",
    "cidr": "10.64.3.0/24",
    "status": "active",
    "memberCount": 2,
    "createdAt": "2026-06-20T10:14:52.000Z"
  }
]
GET/projects/:project/networks/:network
Bearer tokenREAD_VPS200

Fetches a single non-deleted private network by uid, including its CIDR and current member count.

Path parameters

projectProject uid (prj_…) that owns the network.
networkNetwork uid (net_…).
Same auth as the list route (middlewareV2 READ_VPS, requireProject). findNetwork scopes the lookup to net.project_id and excludes status='deleted'.

Errors

  • 404project_not_foundNo project exists with the given uid.
  • 404network_not_foundfindNetwork finds no matching non-deleted network in this project.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/networks/$NETWORK" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "uid": "net_a1b2c3d4e5f6",
  "projectUid": "prj_9z8y7x6w5v4u",
  "name": "backend-lan",
  "cidr": "10.64.3.0/24",
  "status": "active",
  "memberCount": 2,
  "createdAt": "2026-06-20T10:14:52.000Z"
}
POST/projects/:project/networks
Bearer tokenORDER_PRODUCT201

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

projectProject uid (prj_…) to create the network in.

Body

namerequiredstringDisplay 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_requestcreateSchema validation fails — name missing, empty, or longer than 32 chars.
  • 404project_not_foundNo project exists with the given uid.
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/networks" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "backend-lan"
}'
Request body
{
  "name": "backend-lan"
}
Response · 201
{
  "uid": "net_a1b2c3d4e5f6",
  "projectUid": "prj_9z8y7x6w5v4u",
  "name": "backend-lan",
  "cidr": "10.64.3.0/24",
  "status": "active",
  "memberCount": 0,
  "createdAt": "2026-06-20T10:14:52.000Z"
}
DELETE/projects/:project/networks/:network
Bearer tokenVPS_DELETE204

Soft-deletes an empty private network (status='deleted'), which frees its VLAN tag and stops billing. The network must have no attached instances.

Path parameters

projectProject uid (prj_…) that owns the network.
networkNetwork uid (net_…) to delete.
Requires the VPS_DELETE permission. Returns 204 No Content with an empty body (res.status(204).end()).

Errors

  • 404project_not_foundNo project exists with the given uid.
  • 404network_not_foundfindNetwork finds no matching non-deleted network in this project.
  • 409network_not_emptyThe network's member_count is > 0;
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/networks/$NETWORK" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
GET/projects/:project/networks/:network/members
Bearer tokenREAD_VPS200

Lists the instances attached to a private network, with each member's allocated private IP and attachment status, ordered by attach time.

Path parameters

projectProject uid (prj_…) that owns the network.
networkNetwork uid (net_…).
Requires READ_VPS. INNER JOINs network_members to instances; each member's status reflects the provisioning lifecycle (e.g.

Errors

  • 404project_not_foundNo project exists with the given uid.
  • 404network_not_foundfindNetwork finds no matching non-deleted network in this project.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/networks/$NETWORK/members" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "instanceUid": "ins_k3m9p2q7r5t1",
    "instanceName": "web-01",
    "ip": "10.64.3.10",
    "status": "attached",
    "attachedAt": "2026-06-20T10:20:03.000Z"
  }
]
POST/projects/:project/networks/:network/members
Bearer tokenVPS_CONTROL202

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

projectProject uid (prj_…) that owns the network.
networkNetwork uid (net_…) to attach the instance to.

Body

instanceUidrequiredstringUid (ins_…) of the instance to attach. Zod: z.string().min(1). Must resolve to a non-deleted instance in the same project.
ipstring (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_requestattachSchema validation fails — instanceUid empty/missing, or ip present but not a valid IPv4 address (ZodError → details.issues).
  • 403unauthenticatedgetUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it.
  • 404project_not_foundNo project exists with the given uid.
  • 404network_not_foundfindNetwork finds no matching non-deleted network in this project.
  • 404instance_not_foundfindInstanceId: instanceUid does not resolve to a non-deleted instance in this project.
  • 409already_attachedA network_members row already exists for this network+instance.
  • 409nic_limitThe instance already has the maximum of 3 private NICs (MAX_PRIVATE_NICS).
  • 409subnet_fullNo free address remains in the /24 (.10–.249 all taken, or the loop yields no IP).
  • 409operation_in_progressenqueueJob with serializeOnInstance:true fails to claim the instance because another job is already running on it.
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/networks/$NETWORK/members" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "instanceUid": "ins_k3m9p2q7r5t1",
  "ip": "10.64.3.20"
}'
Request body
{
  "instanceUid": "ins_k3m9p2q7r5t1",
  "ip": "10.64.3.20"
}
Response · 202
{
  "uid": "job_h8j4k2l6m9n3",
  "type": "network.attach",
  "status": "queued",
  "createdAt": "2026-06-20T10:20:03.000Z"
}
DELETE/projects/:project/networks/:network/members/:instance
Bearer tokenVPS_CONTROL202

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

projectProject uid (prj_…) that owns the network.
networkNetwork uid (net_…).
instanceInstance 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

  • 403unauthenticatedgetUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it (route-layer ApiError, code 'unauthenticated').
  • 404project_not_foundNo project exists with the given uid.
  • 404network_not_foundfindNetwork finds no matching non-deleted network in this project.
  • 404instance_not_foundfindInstanceId: the :instance uid does not resolve to a non-deleted instance in this project.
  • 404member_not_foundNo network_members row exists for this network+instance.
  • 409operation_in_progressenqueueJob with serializeOnInstance:true cannot claim the instance because another job is already running on it.
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/networks/$NETWORK/members/$INSTANCE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 202
{
  "uid": "job_p1q5r9s3t7u2",
  "type": "network.detach",
  "status": "queued",
  "createdAt": "2026-06-20T10:25:41.000Z"
}

Templates

GET/projects/:project/templates
Bearer tokenREAD_VPS200

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

projectProject 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_foundRoute-level: project passes the auth middleware but getProjectId finds no projects row for the uid.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/templates" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "tpl_8sJk2Lq9",
    "projectUid": "proj_4Xy7",
    "name": "web-base-debian12",
    "sourceInstanceName": "web-01",
    "sizeGb": 20,
    "minDiskGb": 20,
    "monthlyPriceOre": 200,
    "status": "ready",
    "createdAt": "2026-06-18T09:14:02.000Z"
  },
  {
    "uid": "tpl_2bQm1Zr4",
    "projectUid": "proj_4Xy7",
    "name": "db-snapshot",
    "sizeGb": 40,
    "minDiskGb": 40,
    "monthlyPriceOre": 400,
    "status": "ready",
    "createdAt": "2026-06-10T11:02:55.000Z"
  }
]
PATCH/projects/:project/templates/:template
Bearer tokenVPS_CONTROL200

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

projectProject uid that owns the template.
templateTemplate uid (e.g. tpl_...) to rename.

Body

namerequiredstringNew 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_requestBody fails the zod schema (name missing, empty, or longer than 64 chars).
  • 404template_not_foundNo template with that uid exists in the project, or its status is 'deleted'.
  • 404project_not_foundRoute-level: getProjectId finds no projects row for the uid (after auth middleware passes).
Request · cURL
curl -X PATCH "https://api.v3.suble.io/projects/$PROJECT/templates/$TEMPLATE" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "web-base-debian12-hardened"
}'
Request body
{
  "name": "web-base-debian12-hardened"
}
Response · 200
{
  "uid": "tpl_8sJk2Lq9",
  "projectUid": "proj_4Xy7",
  "name": "web-base-debian12-hardened",
  "sourceInstanceName": "web-01",
  "sizeGb": 20,
  "minDiskGb": 20,
  "monthlyPriceOre": 200,
  "status": "ready",
  "createdAt": "2026-06-18T09:14:02.000Z"
}
DELETE/projects/:project/templates/:template
Bearer tokenVPS_DELETE202

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

projectProject uid that owns the template.
templateTemplate 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_foundNo template with that uid exists in the project, or its status is already 'deleted'/'deleting' filtered out (findTemplate filters status != 'deleted').
  • 404project_not_foundRoute-level: getProjectId finds no projects row for the uid (after auth middleware passes).
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/templates/$TEMPLATE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 202
{
  "uid": "job_Qa91Lm3",
  "type": "template.delete",
  "status": "queued",
  "createdAt": "2026-06-20T13:45:11.000Z"
}

Jobs (async progress)

GET/projects/:project/jobs
Bearer token200

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

projectProject 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

instancestringInstance 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_foundIn-handler: getProjectId finds no projects row for the :project UID.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/jobs" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "jobs": [
    {
      "uid": "job_8x2kf0a9c1",
      "type": "instance.create",
      "status": "running",
      "instance": "ins_3kf9d0a1b2",
      "progress": {
        "step": 2,
        "total": 4,
        "percent": 50,
        "message": "Provisioning disk"
      },
      "error": null,
      "created_at": "2026-06-20T09:14:02.000Z",
      "started_at": "2026-06-20T09:14:03.000Z",
      "finished_at": null
    },
    {
      "uid": "job_7a1bd9f3e0",
      "type": "backup.create",
      "status": "succeeded",
      "instance": "ins_3kf9d0a1b2",
      "progress": null,
      "error": null,
      "created_at": "2026-06-20T08:02:00.000Z",
      "started_at": "2026-06-20T08:02:01.000Z",
      "finished_at": "2026-06-20T08:03:45.000Z"
    }
  ]
}
GET/projects/:project/jobs/:job
Bearer token200

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

projectProject 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.
jobJob 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_foundgetProjectId finds no projects row for :project.
  • 404job_not_foundNo job with the given :job UID exists within the project (queryOne returns null → notFound("job_not_found", "Job")).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/jobs/$JOB" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "job": {
    "uid": "job_8x2kf0a9c1",
    "type": "instance.create",
    "status": "succeeded",
    "instance": "ins_3kf9d0a1b2",
    "progress": {
      "step": 4,
      "total": 4,
      "percent": 100,
      "message": "Instance ready"
    },
    "error": null,
    "created_at": "2026-06-20T09:14:02.000Z",
    "started_at": "2026-06-20T09:14:03.000Z",
    "finished_at": "2026-06-20T09:16:10.000Z"
  }
}

API keys

GET/projects/:project/api-keys
Bearer tokenREAD_API200

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

projectProject 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

  • 401Auth layer (middlewareV2): no Authorization header/token
  • 403Auth layer: Bearer-JWT principal is a non-owner member without READ_API
  • 400Auth layer (Bearer JWT only): JWT valid but :project param missing
  • 404project_not_foundHandler-level: getProjectId() cannot resolve req.params.project to a projects row.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/api-keys" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "apk_a1b2c3d4e5f6",
    "name": "CI deploy bot",
    "prefix": "sk_proj_3f9a1c7e8b2d",
    "permissions": 2147483647,
    "lastUsedAt": "2026-06-19T14:22:05.000Z",
    "createdAt": "2026-06-01T09:10:00.000Z"
  }
]
POST/projects/:project/api-keys
Bearer tokenWRITE_API201

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

projectProject uid (prj_...) the key is scoped to.

Body

namerequiredstringHuman label for the key. 1-64 characters (zod min(1).max(64)).
permissionsintegerPermissionTypes 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_requestBody fails the zod createSchema (missing/empty name, name longer than 64 chars, or permissions negative/non-integer).
  • 401Auth layer: missing token, invalid JWT, or unknown/invalid sk_proj_ key.
  • 403Auth layer: principal (member or API key) lacks WRITE_API, or sk_proj_ key not valid for this project.
  • 403unauthenticatedHandler-level getUserId(res): res.locals.user.userUID missing, or no users row matches that uid.
  • 400Auth layer (Bearer JWT only): :project missing ("Project ID required.") or project not resolvable for the member ("Project not found.").
  • 404project_not_foundHandler-level getProjectId(): the :project uid does not resolve to a projects row.
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/api-keys" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "CI deploy bot",
  "permissions": 192
}'
Request body
{
  "name": "CI deploy bot",
  "permissions": 192
}
Response · 201
{
  "uid": "apk_a1b2c3d4e5f6",
  "name": "CI deploy bot",
  "prefix": "sk_proj_3f9a1c7e8b2d",
  "permissions": 192,
  "key": "sk_proj_3f9a1c7e8b2d4f6a8c0e1b3d5f7a9c1e3b5d7f9a1c3e5b7d"
}
DELETE/projects/:project/api-keys/:key
Bearer tokenWRITE_API204

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

projectProject uid (prj_...) that owns the key.
keyAPI 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_foundUPDATE 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.
  • 401Auth layer: missing token, invalid JWT, or unknown/invalid sk_proj_ key.
  • 403Auth layer: principal lacks WRITE_API, or sk_proj_ key not valid for this project.
  • 400Auth layer (Bearer JWT only): :project missing ("Project ID required.") or project not resolvable for the member ("Project not found.").
  • 404project_not_foundHandler-level getProjectId(): the :project uid does not resolve to a projects row (runs before the UPDATE).
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/api-keys/$KEY" \
  -H "Authorization: Bearer $SUBLE_API_KEY"

DDoS protection

GET/projects/:project/ddos
Bearer tokenREAD_VPS200

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

projectProject 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_foundAPI-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.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/ddos" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "settings": {
    "businessEnabled": true,
    "webhook": {
      "url": "https://hooks.example.com/ddos",
      "enabled": true
    },
    "emails": [
      "ops@example.com",
      "alerts@example.com"
    ],
    "emailsIncluded": 1,
    "pricing": {
      "businessMonthlyDkk": 550,
      "webhookMonthlyDkk": 50,
      "emailMonthlyDkk": 25
    }
  },
  "protectedIps": [
    {
      "address": "185.221.10.42",
      "instanceUid": "ins_8fK2a",
      "instanceName": "web-1",
      "status": "mitigating"
    },
    {
      "address": "185.221.0.0/22",
      "instanceUid": "",
      "instanceName": "Colo transit prefix",
      "status": "clear"
    }
  ],
  "active": [
    {
      "uid": "ddos_90231",
      "alertId": 90231,
      "targetIp": "185.221.10.42",
      "instanceUid": "ins_8fK2a",
      "instanceName": "web-1",
      "importance": "high",
      "kind": "mitigation",
      "active": true,
      "peakTrafficGbps": 12.4,
      "peakPacketsMpps": 3.1,
      "signatures": [
        "UDP_FLOOD",
        "DNS_AMP"
      ],
      "startedAt": "2026-06-20T09:14:00.000Z",
      "updatedAt": "2026-06-20T09:22:00.000Z",
      "timeline": [
        {
          "state": "started",
          "importance": "medium",
          "peakTrafficGbps": 4.2,
          "peakPacketsMpps": 1,
          "at": "2026-06-20T09:14:00.000Z"
        },
        {
          "state": "updated",
          "importance": "high",
          "peakTrafficGbps": 12.4,
          "peakPacketsMpps": 3.1,
          "at": "2026-06-20T09:22:00.000Z"
        }
      ]
    }
  ],
  "recent": [
    {
      "uid": "ddos_90115",
      "alertId": 90115,
      "targetIp": "185.221.10.42",
      "instanceUid": "ins_8fK2a",
      "instanceName": "web-1",
      "importance": "low",
      "kind": "monitoring",
      "active": false,
      "peakTrafficGbps": 0.8,
      "peakPacketsMpps": 0.2,
      "signatures": [],
      "startedAt": "2026-06-18T02:00:00.000Z",
      "updatedAt": "2026-06-18T02:40:00.000Z",
      "endedAt": "2026-06-18T02:40:00.000Z",
      "timeline": [
        {
          "state": "started",
          "importance": "low",
          "peakTrafficGbps": 0.8,
          "peakPacketsMpps": 0.2,
          "at": "2026-06-18T02:00:00.000Z"
        },
        {
          "state": "ended",
          "importance": "low",
          "peakTrafficGbps": 0,
          "peakPacketsMpps": 0,
          "at": "2026-06-18T02:40:00.000Z"
        }
      ]
    }
  ]
}
PUT/projects/:project/ddos/business
Bearer tokenORDER_PRODUCT200

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

projectProject UID. Resolved to an internal numeric id via getProjectId.

Body

enabledrequiredbooleantrue 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_requestBody fails zod validation (enabled missing or not a boolean) — thrown by .parse via asyncHandler.
  • 403unauthenticatedv3 envelope: the authenticated principal cannot be resolved to a numeric user id (getUserId — e.g.
  • 404project_not_foundAPI-key auth only: getProjectId fails to resolve the :project UID.
Request · cURL
curl -X PUT "https://api.v3.suble.io/projects/$PROJECT/ddos/business" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": true
}'
Request body
{
  "enabled": true
}
Response · 200
{
  "businessEnabled": true,
  "webhook": {
    "url": "https://hooks.example.com/ddos",
    "enabled": true
  },
  "emails": [
    "ops@example.com"
  ],
  "emailsIncluded": 1,
  "pricing": {
    "businessMonthlyDkk": 550,
    "webhookMonthlyDkk": 50,
    "emailMonthlyDkk": 25
  }
}
PUT/projects/:project/ddos/webhook
Bearer tokenORDER_PRODUCT200

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

projectProject UID. Resolved to an internal numeric id via getProjectId.

Body

urlrequiredstring | nullWebhook 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_requestBody fails zod validation (url is not a valid URL, exceeds 512 chars, or the field is missing — it is required, though nullable).
  • 409business_requiredA non-null url is provided while DDoS Business is not enabled.
  • 400invalid_webhookA non-null url passes zod but is not https:// (only checked after the business_required gate).
  • 403unauthenticatedv3 envelope: getUserId cannot resolve the principal to a numeric user id (e.g.
  • 404project_not_foundAPI-key auth only: getProjectId fails to resolve the :project UID.
Request · cURL
curl -X PUT "https://api.v3.suble.io/projects/$PROJECT/ddos/webhook" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://hooks.example.com/ddos"
}'
Request body
{
  "url": "https://hooks.example.com/ddos"
}
Response · 200
{
  "businessEnabled": true,
  "webhook": {
    "url": "https://hooks.example.com/ddos",
    "enabled": true
  },
  "emails": [],
  "emailsIncluded": 1,
  "pricing": {
    "businessMonthlyDkk": 550,
    "webhookMonthlyDkk": 50,
    "emailMonthlyDkk": 25
  }
}
POST/projects/:project/ddos/emails
Bearer tokenORDER_PRODUCT200

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

projectProject UID. Resolved to an internal numeric id via getProjectId.

Body

emailrequiredstringRecipient 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_requestBody fails zod validation (email missing, not a valid email, or over 255 chars).
  • 409business_requiredDDoS Business is not enabled for the project (loadSettings business_enabled is falsy).
  • 409too_many_emailsThe project already has 20 recipients (MAX_EMAILS), checked before insert.
  • 404project_not_foundAPI-key auth only: getProjectId fails to resolve the :project UID.
Request · cURL
curl -X POST "https://api.v3.suble.io/projects/$PROJECT/ddos/emails" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "alerts@example.com"
}'
Request body
{
  "email": "alerts@example.com"
}
Response · 200
{
  "businessEnabled": true,
  "webhook": {
    "url": "https://hooks.example.com/ddos",
    "enabled": true
  },
  "emails": [
    "ops@example.com",
    "alerts@example.com"
  ],
  "emailsIncluded": 1,
  "pricing": {
    "businessMonthlyDkk": 550,
    "webhookMonthlyDkk": 50,
    "emailMonthlyDkk": 25
  }
}
DELETE/projects/:project/ddos/emails
Bearer tokenORDER_PRODUCT200

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

projectProject UID. Resolved to an internal numeric id via getProjectId.

Body

emailrequiredstringRecipient 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_requestBody fails zod validation (email missing, not a valid email, or over 255 chars).
  • 404project_not_foundAPI-key auth only: getProjectId fails to resolve the :project UID.
Request · cURL
curl -X DELETE "https://api.v3.suble.io/projects/$PROJECT/ddos/emails" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "alerts@example.com"
}'
Request body
{
  "email": "alerts@example.com"
}
Response · 200
{
  "businessEnabled": true,
  "webhook": {
    "url": "https://hooks.example.com/ddos",
    "enabled": true
  },
  "emails": [
    "ops@example.com"
  ],
  "emailsIncluded": 1,
  "pricing": {
    "businessMonthlyDkk": 550,
    "webhookMonthlyDkk": 50,
    "emailMonthlyDkk": 25
  }
}

Colocation

GET/projects/:project/colocation
Bearer tokenREAD_COLOCATION200

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

projectProject 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_bodyProject 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.
  • 401unauthorizedNo Authorization header / no token → { error: "Authentication missing1" }.
  • 403forbiddenAPI-key path: key lacks READ_COLOCATION → { error: "API key lacks the required permission." };
  • 400bad_requestUser-bearer path only: requireProject set but no :project → { error: "Project ID required." };
  • 404project_not_foundAPI-key path: getProjectId cannot resolve the :project UID.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/colocation" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "uid": "prod_8fa21c",
  "name": "Rack A — Taastrup",
  "datacenter": "Hørskætten 12, 2630 Taastrup",
  "hall": "21",
  "rackU": 1,
  "access": "Escorted 24/7 access",
  "powerCommitWatts": 1600,
  "createdAt": "2025-11-03T09:14:22.000Z",
  "smartPlugs": [
    {
      "uid": "plug_a1b2c3",
      "name": "PDU-1 Left",
      "coolingWatts": 800,
      "currentWatts": 432
    },
    {
      "uid": "plug_d4e5f6",
      "name": "PDU-2 Right",
      "coolingWatts": 800,
      "currentWatts": 510
    }
  ]
}
GET/projects/:project/colocation/usage
Bearer tokenREAD_COLOCATION200

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

projectProject UID the colocation belongs to (e.g. prj_...). Resolved to a numeric project id via getProjectId.

Query parameters

plugrequiredstringSmart 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 [].
rangestringLookback 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_arrayProject 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
  • 401unauthorizedMissing token → { error: "Authentication missing1" };
  • 403forbiddenAPI-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_requestUser-bearer path only: no :project with requireProject → { error: "Project ID required." };
  • 404project_not_foundAPI-key path: getProjectId cannot resolve the :project UID.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/colocation/usage" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "ts": "2026-06-20T08:00:00.000Z",
    "watts": 418
  },
  {
    "ts": "2026-06-20T09:00:00.000Z",
    "watts": 437
  },
  {
    "ts": "2026-06-20T10:00:00.000Z",
    "watts": 402
  }
]

IP transit

GET/projects/:project/transit
Bearer tokenREAD_COLOCATION200

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

projectProject 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

  • 200nullNo transit row is provisioned for the project — the body is the JSON literal null (not an error envelope).
  • 404project_not_foundThe :project UID does not resolve to a project (getProjectId throws → v3 error envelope).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/transit" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "uid": "trn_8mK2pQ",
  "name": "Primary IP transit",
  "location": "dk-cph-1",
  "asn": "AS64512",
  "portMbps": 10000,
  "ipv4Prefixes": 4,
  "ipv6Prefixes": 2,
  "createdAt": "2026-05-14T09:21:00.000Z",
  "network": {
    "uid": "net_3Tx9Lp",
    "name": "transit-uplink",
    "cidr": "10.20.0.0/24",
    "vlanId": 412
  },
  "bgpSessions": [
    {
      "family": 4,
      "ourIp": "185.205.0.1",
      "theirIp": "185.205.0.2",
      "ourAsn": "AS199545",
      "theirAsn": "AS64512",
      "md5Password": "s3cr3t-md5"
    },
    {
      "family": 6,
      "ourIp": "2a0e:b107::1",
      "theirIp": "2a0e:b107::2",
      "ourAsn": "AS199545",
      "theirAsn": "AS64512",
      "md5Password": "s3cr3t-md5"
    }
  ]
}
GET/projects/:project/transit/stats
Bearer tokenREAD_COLOCATION200

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

projectProject 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

  • 200nullNo transit is provisioned, OR the transit has no linked active private network (VLAN unknown) — the body is the JSON literal null.
  • 404project_not_foundThe :project UID does not resolve to a project (getProjectId).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/transit/stats" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "inMbps": 842.3,
  "outMbps": 1190.7,
  "p95InMbps": 640,
  "p95OutMbps": 905.2,
  "inPps": 118540,
  "outPps": 96231
}
GET/projects/:project/transit/usage
Bearer tokenREAD_COLOCATION200

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

projectProject 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

rangestringTime 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_foundThe :project UID does not resolve to a project (getProjectId).
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/transit/usage" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "ts": "2026-06-19T12:00:00.000Z",
    "inMbps": 803.4,
    "outMbps": 1102.9
  },
  {
    "ts": "2026-06-19T12:15:00.000Z",
    "inMbps": 815.1,
    "outMbps": 1150.3
  },
  {
    "ts": "2026-06-19T12:30:00.000Z",
    "inMbps": 790.7,
    "outMbps": 1088
  }
]

IP ranges

GET/projects/:project/ip-ranges
Bearer tokenREAD_COLOCATION200

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

projectProject 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 missing1No Authorization header / no Bearer token present.
  • 401Invalid token.JWT path (non-sk_proj_ bearer token): jwt.verify returns a falsy payload.
  • 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. 1User-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. 5User-JWT caller has no projectMembers permissions row (project.permissions undefined) and is neither the project owner nor staff.
  • 403Authentication missing4Any exception thrown inside middlewareV2 (e.g.
  • 404project_not_foundThe :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 missing2User-JWT path: the verified token payload has no userUID.
Request · cURL
curl "https://api.v3.suble.io/projects/$PROJECT/ip-ranges" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "ipr_7f3a9c2e1b",
    "cidr": "185.246.88.0/24",
    "family": 4,
    "description": "Primary transit allocation (DK1)",
    "source": "colocation",
    "createdAt": "2026-02-11T09:42:13.000Z"
  },
  {
    "uid": "ipr_2c8d4f6a90",
    "cidr": "2a0e:97c0:df0::/48",
    "family": 6,
    "description": "IPv6 PI block",
    "source": "transit",
    "createdAt": "2026-03-04T14:10:55.000Z"
  }
]

Account (SSH keys, security, limits)

GET/account/sshkeys
Bearer token200

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

  • 401unauthenticatedMissing or invalid Authorization bearer token (middlewareV2 rejects before the handler).
  • 403unauthenticatedgetUserId 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').
Request · cURL
curl "https://api.v3.suble.io/account/sshkeys" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "ssh_keys": [
    {
      "uid": "key_8f2a1c9d4e",
      "name": "laptop",
      "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH... alex@laptop",
      "fingerprint": "SHA256:abcd1234efgh5678ijkl9012mnop3456qrst7890uvwx",
      "created_at": "2026-06-18T09:14:22.000Z"
    }
  ]
}
POST/account/sshkeys
Bearer token201

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

namerequiredstring (1-64 chars)Human label for the key.
public_keyrequiredstringOpenSSH 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_requestBody 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_existsA key with the same computed SHA256 fingerprint already exists on this account (message 'This SSH key is already added').
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X POST "https://api.v3.suble.io/account/sshkeys" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "laptop",
  "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHk... alex@laptop"
}'
Request body
{
  "name": "laptop",
  "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHk... alex@laptop"
}
Response · 201
{
  "ssh_key": {
    "uid": "key_8f2a1c9d4e",
    "name": "laptop",
    "fingerprint": "SHA256:abcd1234efgh5678ijkl9012mnop3456qrst7890uvwx"
  }
}
DELETE/account/sshkeys/:key
Bearer token204

Removes an SSH key from the user's account by its uid. Only deletes keys owned by the authenticated user.

Path parameters

keyThe uid of the SSH key to delete (e.g. key_8f2a1c9d4e).
Returns 204 with an empty body on success. The DELETE is scoped by both uid AND user_id, so another user's key uid yields 404 (not 403).

Errors

  • 404key_not_foundNo SSH key with that uid is owned by the authenticated user (DELETE affected zero rows).
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X DELETE "https://api.v3.suble.io/account/sshkeys/$KEY" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
PATCH/account/profile
Bearer token200

Partially updates the authenticated user's profile (full name and/or UI language) and returns the full current profile DTO.

Body

fullnamestring (1-128 chars)User's display name. Omit to leave unchanged.
languagestring 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_requestBody fails the zod schema (fullname empty/>128 chars, or language not 'da'/'en').
  • 404user_not_foundThe user row cannot be re-read after the update.
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X PATCH "https://api.v3.suble.io/account/profile" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fullname": "Alexander Moeller",
  "language": "en"
}'
Request body
{
  "fullname": "Alexander Moeller",
  "language": "en"
}
Response · 200
{
  "uid": "usr_3b9c0a71f2",
  "email": "alex@example.com",
  "fullname": "Alexander Moeller",
  "country": "DK",
  "language": "en",
  "emailVerified": true,
  "mfaEnabled": false,
  "createdAt": "2025-11-02T08:30:00.000Z"
}
POST/account/mfa/enable
Bearer token200

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_foundThe authenticated user id does not resolve to a user row.
  • 409mfa_already_enabledTwo-factor is already enabled (users.mfa_enabled is truthy);
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X POST "https://api.v3.suble.io/account/mfa/enable" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauthUrl": "otpauth://totp/Suble:alex@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Suble"
}
POST/account/mfa/confirm
Bearer token204

Completes two-factor enrollment by verifying a 6-digit code against the pending encrypted secret, then enabling MFA on the account.

Body

coderequiredstring (exactly 6 digits, /^\d{6}$/)Current 6-digit TOTP code from the authenticator app.
Returns 204 with no body on success. Reads mfa_secret, decrypts it, verifies the code with verifyToken, then sets mfa_enabled=1.

Errors

  • 400invalid_requestcode does not match the /^\d{6}$/ pattern (zod message 'Enter the 6-digit code').
  • 400mfa_not_startedNo pending mfa_secret exists (enrollment was not started via /mfa/enable);
  • 400mfa_code_invalidThe supplied code does not verify against the decrypted pending secret.
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X POST "https://api.v3.suble.io/account/mfa/confirm" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "code": "123456"
}'
Request body
{
  "code": "123456"
}
POST/account/mfa/disable
Bearer token204

Disables two-factor by requiring a current 6-digit code, then clearing the stored secret and the enabled flag.

Body

coderequiredstring (exactly 6 digits, /^\d{6}$/)Current 6-digit TOTP code from the authenticator app.
Returns 204 with no body on success. Side effect: sets mfa_secret=NULL and mfa_enabled=0.

Errors

  • 400invalid_requestcode does not match the /^\d{6}$/ pattern (zod message 'Enter the 6-digit code').
  • 400mfa_not_enabledTwo-factor is not currently enabled (mfa_secret is null or mfa_enabled is 0);
  • 400mfa_code_invalidThe supplied code does not verify against the decrypted stored secret.
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl -X POST "https://api.v3.suble.io/account/mfa/disable" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "code": "123456"
}'
Request body
{
  "code": "123456"
}
GET/account/limits
Bearer token200

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).

Errors

  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl "https://api.v3.suble.io/account/limits" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "key": "instances",
    "used": 3,
    "limit": 10
  },
  {
    "key": "memoryMb",
    "used": 12288,
    "limit": 65536
  },
  {
    "key": "templateStorageGb",
    "used": 22,
    "limit": 100
  },
  {
    "key": "restoresPerMonth",
    "used": 1,
    "limit": 3
  }
]
GET/account/limit-requests
Bearer token200

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.

Errors

  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId cannot resolve the authenticated user.
Request · cURL
curl "https://api.v3.suble.io/account/limit-requests" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "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.000Z"
  }
]
POST/account/limit-requests
Bearer token201

Submits a request to raise a specific account quota, including a justification. Created in 'pending' status for staff review.

Body

quotaKeyrequiredstring enum: "instances" | "memoryMb" | "templateStorageGb" | "restoresPerMonth"Which quota to increase.
requestedrequiredinteger (positive)Desired new limit value for that quota.
useCaserequiredstring (10-512 chars)Justification describing the intended use.
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_requestBody fails zod: quotaKey not in the allowed enum, requested not a positive integer, or useCase shorter than 10 / longer than 512 chars.
  • 401unauthenticatedMissing/invalid bearer token (middlewareV2).
  • 403unauthenticatedgetUserId 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 token200

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

  • 403unauthenticatedAuth passed but no userUID could be resolved from res.locals.user (message: "Authentication required").
  • 404user_not_foundNo row exists in the users table for the resolved userUID (message: "User not found").
Request · cURL
curl "https://api.v3.suble.io/me" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "uid": "usr_8x2k9d",
  "email": "dev@example.com",
  "fullname": "Ada Lovelace",
  "country": "DK",
  "language": "da",
  "emailVerified": true,
  "mfaEnabled": false,
  "role": "user",
  "createdAt": "2026-01-14T09:22:31.000Z"
}

Notifications

GET/notifications
Bearer token200

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

  • 403unauthenticatedToken is valid but carries no userUID, or the resolved user no longer exists in the users table (thrown by getUserId).
Request · cURL
curl "https://api.v3.suble.io/notifications" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "ntf_8sK2mQ",
    "category": "billing",
    "title": "Invoice paid",
    "body": "Your invoice for May 2026 (250000 ore ex. VAT) was paid.",
    "href": "/dashboard/billing/invoices/inv_4Tn9aZ",
    "read": false,
    "createdAt": "2026-06-18T09:14:22.000Z"
  },
  {
    "uid": "ntf_7rJ1pP",
    "category": "job",
    "title": "Instance ready",
    "body": "Instance ins_2Vb8cD finished provisioning.",
    "read": true,
    "createdAt": "2026-06-17T11:02:05.000Z"
  }
]
POST/notifications/read
Bearer token204

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

uidsrequiredstring[]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_requestBody fails zod validation (ZodError), e.g.
  • 403unauthenticatedAuthenticated principal has no resolvable user (no userUID, or user row not found).
Request · cURL
curl -X POST "https://api.v3.suble.io/notifications/read" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "uids": [
    "ntf_8sK2mQ",
    "ntf_7rJ1pP"
  ]
}'
Request body
{
  "uids": [
    "ntf_8sK2mQ",
    "ntf_7rJ1pP"
  ]
}
POST/notifications/read-all
Bearer token204

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

  • 403unauthenticatedAuthenticated 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 token200

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

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found (v3 error envelope: {error:{code,message,details,request_id}}).
Request · cURL
curl "https://api.v3.suble.io/billing/overview" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "rail": "mobilepay",
  "balanceOre": 125000,
  "burnRatePerDayOre": 21600,
  "currentPeriodAccruedOre": 48300,
  "defaultPaymentMethodUid": "pm_a1b2c3d4e5f6",
  "creditExpiringSoonOre": 5000,
  "creditNextExpiryAt": "2026-07-15T00:00:00.000Z",
  "readiness": {
    "canProvision": true,
    "reason": "ok",
    "isCompany": false,
    "country": "DK",
    "minCreditOre": 5000
  }
}
GET/billing/usage
Bearer token200

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

projectUidstringRestrict 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

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/usage" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "resourceUid": "ins_9f8e7d6c5b4a",
    "resourceName": "web-1",
    "resourceType": "instance",
    "projectUid": "prj_1a2b3c4d5e6f",
    "sku": "bxs-2-4",
    "hours": 336,
    "amountOre": 40320
  }
]
GET/billing/invoices
Bearer token200

Returns all invoices for the account, newest first, with provider, kind, billing period, status, and the øre subtotal/credit/VAT/total breakdown.

Bare JSON array from billing_invoices, ordered by created_at DESC. periodYear/periodMonth are null for non-periodic invoices.

Errors

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/invoices" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "inv_a1b2c3d4e5f6",
    "provider": "dinero",
    "kind": "monthly",
    "periodYear": 2026,
    "periodMonth": 5,
    "status": "paid",
    "subtotalOre": 48300,
    "creditAppliedOre": 10000,
    "vatOre": 9575,
    "totalDueOre": 47875,
    "createdAt": "2026-06-01T00:05:00.000Z",
    "paidAt": "2026-06-01T00:06:12.000Z"
  }
]
GET/billing/invoices/:uid/pdf
Bearer token200

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

uidInvoice uid (inv_...). Must belong to the authenticated user.
Read-only; creates nothing. The invoice is loaded by uid AND user_id.

Errors

  • 404invoice_not_foundNo invoice with that uid owned by the user.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/invoices/$UID/pdf" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
%PDF-1.7 ... (binary application/pdf body)
GET/billing/credits
Bearer token200

Returns the account's current prepaid credit balance plus the 100 most recent append-only credit ledger entries.

balanceOre comes from credit_balances (0 when no row). ledger is from credit_ledger, capped at 100 rows ordered by id DESC (newest first).

Errors

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/credits" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "balanceOre": 125000,
  "ledger": [
    {
      "uid": "crd_a1b2c3d4e5f6",
      "type": "topup",
      "amountOre": 100000,
      "balanceAfterOre": 125000,
      "note": "MobilePay top-up",
      "createdAt": "2026-06-18T10:22:00.000Z"
    }
  ]
}
GET/billing/payment-methods
Bearer token200

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

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/payment-methods" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "uid": "pm_a1b2c3d4e5f6",
    "provider": "mobilepay",
    "status": "active",
    "isDefault": true,
    "label": "MobilePay",
    "createdAt": "2026-06-10T09:00:00.000Z"
  }
]
PUT/billing/payment-methods/:method/default
Bearer token204

Marks the given active payment method as the account default, clearing the default flag on all others.

Path parameters

methodPayment method uid (pm_...). Must be an active method owned by the user.
Returns 204 No Content with an empty body. Only an active method can be made default; the not-found check requires status='active'.

Errors

  • 404payment_method_not_foundNo active payment method with that uid owned by the user.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X PUT "https://api.v3.suble.io/billing/payment-methods/$METHOD/default" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
DELETE/billing/payment-methods/:method
Bearer token204

Revokes the given payment method (soft delete) and clears its default flag.

Path parameters

methodPayment method uid (pm_...) owned by the user.
Soft delete: UPDATE sets status='revoked' and is_default=0 (the row is retained, not hard-deleted). Returns 204 No Content.

Errors

  • 404payment_method_not_foundNo matching payment method with that uid owned by the user (zero rows affected).
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X DELETE "https://api.v3.suble.io/billing/payment-methods/$METHOD" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
GET/billing/details
Bearer token200

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

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/details" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "isCompany": false,
  "name": "Jane Hansen",
  "email": "jane@example.com",
  "street": "Vestergade 12",
  "zipCode": "8000",
  "city": "Aarhus",
  "country": "DK",
  "phone": "+4512345678",
  "vatNumber": "DK12345678",
  "eanNumber": "5790000000000"
}
PUT/billing/details
Bearer token204

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

isCompanyrequiredbooleanWhether this is a business account.
namerequiredstringLegal/billing name, 1-128 chars.
emailrequiredstringBilling email (validated as an email).
streetrequiredstringStreet address, 1-160 chars.
zipCoderequiredstringPostal code, 1-16 chars.
cityrequiredstringCity, 1-96 chars.
countryrequiredstringISO 3166-1 alpha-2 country code, exactly 2 chars (uppercased server-side); drives rail routing.
phonestringPhone number, max 32 chars.
vatNumberstringVAT registration number, max 32 chars.
eanNumberstringEAN/GLN number for public-sector invoicing, max 32 chars.
Returns 204 No Content. Upsert into billing_customers keyed by user_id.

Errors

  • 400invalid_requestBody fails the zod schema (e.g.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X PUT "https://api.v3.suble.io/billing/details" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "isCompany": false,
  "name": "Jane Hansen",
  "email": "jane@example.com",
  "street": "Vestergade 12",
  "zipCode": "8000",
  "city": "Aarhus",
  "country": "DK",
  "phone": "+4512345678"
}'
Request body
{
  "isCompany": false,
  "name": "Jane Hansen",
  "email": "jane@example.com",
  "street": "Vestergade 12",
  "zipCode": "8000",
  "city": "Aarhus",
  "country": "DK",
  "phone": "+4512345678"
}
POST/billing/top-up
Bearer token200

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

amountOrerequiredintegerTop-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_requestamountOre missing, non-integer, below 5000, or above 10000000.
  • 503provider_not_configuredStripe rail with no STRIPE_SECRET_KEY, or MobilePay rail with ePayment not configured.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X POST "https://api.v3.suble.io/billing/top-up" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "amountOre": 100000
}'
Request body
{
  "amountOre": 100000
}
Response · 200
{
  "redirectUrl": "https://checkout.stripe.com/c/pay/cs_test_...",
  "status": "pending"
}
POST/billing/payment-methods/stripe
Bearer token200

Creates a Stripe Checkout setup session (get-or-create the Stripe customer first) so the user can save a card. Returns the hosted redirect URL.

No request body. Provider-gated on STRIPE_SECRET_KEY.

Errors

  • 503provider_not_configuredSTRIPE_SECRET_KEY is not set.
  • 403unauthenticatedToken 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/stripe" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "redirectUrl": "https://checkout.stripe.com/c/pay/cs_test_..."
}
POST/billing/payment-methods/mobilepay
Bearer token200

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_configuredVipps/MobilePay is not configured.
  • 502mobilepay_agreement_failedCreating the agreement at Vipps threw;
  • 403unauthenticatedToken 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"
Response · 200
{
  "approvalUrl": "https://api.vipps.no/dwo-api-application/v1/deeplink/...",
  "uid": "pm_a1b2c3d4e5f6"
}
POST/billing/promo
Bearer token200

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

coderequiredstringPromo 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_foundCode does not exist.
  • 400promo_inactiveCode is not active.
  • 400promo_expiredCode's expires_at has passed.
  • 400promo_exhaustedCode has reached max_uses.
  • 409promo_already_usedThis user already redeemed the code (duplicate insert into promo_redemptions).
  • 400invalid_requestcode missing or fails length validation (1-64).
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X POST "https://api.v3.suble.io/billing/promo" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "code": "WELCOME50"
}'
Request body
{
  "code": "WELCOME50"
}
Response · 200
{
  "creditedOre": 5000,
  "balanceAfterOre": 130000
}
GET/billing/alerts
Bearer token200

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

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/alerts" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
[
  {
    "thresholdOre": 10000,
    "armed": true
  },
  {
    "thresholdOre": 5000,
    "armed": false
  }
]
POST/billing/alerts
Bearer token204

Adds a low-balance alert threshold for the account. Upsert: posting an existing threshold is a no-op.

Body

thresholdOrerequiredintegerBalance threshold in øre, integer, min 0, max 100000000 (1,000,000 DKK).
Returns 204 No Content. INSERT ...

Errors

  • 400invalid_requestthresholdOre missing, non-integer, negative, or above 100000000.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X POST "https://api.v3.suble.io/billing/alerts" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "thresholdOre": 10000
}'
Request body
{
  "thresholdOre": 10000
}
DELETE/billing/alerts/:thresholdOre
Bearer token204

Removes the low-balance alert threshold matching the given øre value.

Path parameters

thresholdOreThe threshold value in øre to delete (coerced from the path with Number()).
Returns 204 No Content. Idempotent: deleting a non-existent threshold still returns 204 (plain DELETE, no affectedRows check, no not-found error).

Errors

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X DELETE "https://api.v3.suble.io/billing/alerts/$THRESHOLDORE" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
GET/billing/settings
Bearer token200

Returns the account's auto-recharge configuration (enabled flag, trigger and top-up amount) and whether the suspend warning has been acknowledged.

From billing_settings. Defaults to disabled/null/null/false when no row exists.

Errors

  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl "https://api.v3.suble.io/billing/settings" \
  -H "Authorization: Bearer $SUBLE_API_KEY"
Response · 200
{
  "autorechargeEnabled": true,
  "autorechargeTriggerOre": 5000,
  "autorechargeAmountOre": 100000,
  "suspendAcknowledged": false
}
PUT/billing/settings
Bearer token204

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

autorechargeEnabledrequiredbooleanWhether auto-recharge is enabled.
autorechargeTriggerOreinteger|nullBalance at/below which auto-recharge fires, in øre; 5000-10000000 or null. Required (non-null) when enabling.
autorechargeAmountOreinteger|nullAmount to recharge in øre; 5000-10000000 or null. Required (non-null) when enabling.
suspendAcknowledgedbooleanSet 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_incompleteautorechargeEnabled is true but trigger and/or amount is missing/null.
  • 400invalid_requestBody fails the zod schema (e.g.
  • 403unauthenticatedToken resolves but has no userUID, or the user row is not found.
Request · cURL
curl -X PUT "https://api.v3.suble.io/billing/settings" \
  -H "Authorization: Bearer $SUBLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "autorechargeEnabled": true,
  "autorechargeTriggerOre": 5000,
  "autorechargeAmountOre": 100000,
  "suspendAcknowledged": true
}'
Request body
{
  "autorechargeEnabled": true,
  "autorechargeTriggerOre": 5000,
  "autorechargeAmountOre": 100000,
  "suspendAcknowledged": true
}

Auth (email verification)

POST/auth/login
Public200

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.

Body

emailrequiredstring (email)Account email; matched case-insensitively (lowercased server-side).
passwordrequiredstringAccount password (min length 1).
codestring6-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_required2FA is enabled and no `code` was supplied;
  • 401invalid_credentialsEmail not found, the user has no password set, or the password does not match.
  • 401mfa_code_invalid2FA is enabled and the supplied TOTP `code` fails verification against the decrypted secret.
  • 400invalid_requestBody fails zod validation (missing/invalid email or empty password);
Request · cURL
curl -X POST "https://api.v3.suble.io/auth/login" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "dev@example.com",
  "password": "correct horse battery staple",
  "code": "123456"
}'
Request body
{
  "email": "dev@example.com",
  "password": "correct horse battery staple",
  "code": "123456"
}
Response · 200
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
POST/auth/signup
Public201

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

emailrequiredstring (email)New account email; must be unique, stored lowercased.
passwordrequiredstringPassword, minimum 8 characters; hashed with bcrypt (rounds 10).
fullnamerequiredstringDisplay name, 1-120 characters.
countrystring (ISO 3166-1 alpha-2)2-letter country code; defaults to 'DK', uppercased server-side. Determines the billing rail and purchase-gate.
isCompanybooleanWhether the account is a business; stored as is_company (0/1) on the billing_customers row (defaults to false).
languagestring ('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_takenAn account with the given email already exists.
  • 400invalid_requestBody fails zod validation (invalid email, password < 8 chars, empty/oversized fullname >120, country not exactly 2 chars, or language not 'da'/'en');
Request · cURL
curl -X POST "https://api.v3.suble.io/auth/signup" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "dev@example.com",
  "password": "hunter2hunter2",
  "fullname": "Jane Developer",
  "country": "DK",
  "isCompany": false,
  "language": "en"
}'
Request body
{
  "email": "dev@example.com",
  "password": "hunter2hunter2",
  "fullname": "Jane Developer",
  "country": "DK",
  "isCompany": false,
  "language": "en"
}
Response · 201
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
POST/auth/refresh
Public200

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_tokenrequiredstringA 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_refreshToken fails signature/expiry verification, is not tokenType 'refresh', or carries no userUID.
  • 400invalid_requestBody is missing refresh_token or it is an empty string;
Request · cURL
curl -X POST "https://api.v3.suble.io/auth/refresh" \
  -H "Content-Type: application/json" \
  -d '{
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}'
Request body
{
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response · 200
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
POST/auth/password/forgot
Public204

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

emailrequiredstring (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_requestBody is missing email or contains an invalid email;
Request · cURL
curl -X POST "https://api.v3.suble.io/auth/password/forgot" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "dev@example.com"
}'
Request body
{
  "email": "dev@example.com"
}
POST/auth/password/reset
Public204

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

tokenrequiredstring (64-char hex)Reset token from the email link; must be exactly 64 characters.
passwordrequiredstringNew 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_tokenToken does not exist, was already used (used_at set), or has expired (expires_at <= NOW()).
  • 400invalid_requestBody fails zod validation (token not exactly 64 chars, or password < 8 chars);
Request · cURL
curl -X POST "https://api.v3.suble.io/auth/password/reset" \
  -H "Content-Type: application/json" \
  -d '{
  "token": "3f9c1a2b4d5e6f70819273645566778899aabbccddeeff00112233445566778",
  "password": "my-new-strong-password"
}'
Request body
{
  "token": "3f9c1a2b4d5e6f70819273645566778899aabbccddeeff00112233445566778",
  "password": "my-new-strong-password"
}