API reference

Every HTTP endpoint — method, auth, request schema, and responses.

HTTP API reference

The complete HTTP surface of the HostSSH control plane (control-plane/web, Next.js 16 App Router, output: standalone), in two audiences:

  • Agent API (/api/v1/*) — called by the zero-dependency Go agent on each managed VPS. It dials out; nothing dials in. Auth is the host's license key as a Bearer token.
  • App / public API — the dashboard's own routes (session + RBAC), the public support chat, the cron-driven monitor runner, and the health probe.

For the operator runbook (curl recipes, env vars, failure modes), see the control-plane-api skill reference. For the agent side, see the agent skill reference and agent-protocol.md.


Conventions

The /v1 → /api/v1 rewrite

The agent calls <base>/v1/* (e.g. https://api.hostssh.com/v1/license/activate). Handlers live under app/api/v1/, bridged by a next.config.ts rewrite (/v1/:path* → /api/v1/:path*). Paths below use the agent-facing /v1/... form. Every /api/v1/* route sets runtime = 'nodejs' and dynamic = 'force-dynamic'.

Request bodies and validation

Bodies are validated by zod schemas in lib/api/schemas.ts via parseBody() in lib/api/validate.ts — a tolerant receiver: unknown keys are stripped (a newer agent adding a field never breaks an older plane), while wrong types / missing required fields return 400 { error: 'invalid request body', details: [...] } (up to 12 messages). Unparseable JSON returns 400 { error: 'invalid JSON body' }. Agent fields use Go omitempty (omitted, never null), so optional fields are .optional(), not .nullable(). Schemas mirror the Go wire structs in agent/internal/{license,telemetry,jobs} — keep them in sync or a legitimate agent gets 400s.

Authentication

MechanismWhereUsed by
License bearerAuthorization: Bearer <license_key> (validateLicenseKey)all agent endpoints
agentGatelicense bearer + per-key rate limit (lib/fleet/agent-auth.ts)heartbeat, jobs claim/status/logs
Cron secretMONITORS_CRON_SECRET, timing-safe, fail-closedmonitor runner
Worker secretPROVISIONING_WORKER_SECRET, timing-safe, fail-closedprovisioning worker
Session + RBACsigned hs_session cookie + permission checkdeploy logs
Origin + rate limitallowlisted Origin + per-IP/global limiterpublic chat

License validation (lib/fleet/agents.ts) accepts a key via: (1) the HOSTSSH_LICENSE_KEYS allow-list, (2) a DB-issued license that is active and non-expired, or (3) dev only — any non-empty key when the allow-list is empty and NODE_ENV !== 'production'. Production with no allow-list and no DB row rejects. The key is the agent's identity; the fingerprint is body data, not a credential. Per-host fencing is enforced at the job layer via claimed_by.

Rate limiting (lib/ratelimit.ts) is in-memory, per-process, per-replica (fixed window on globalThis; resets on deploy; not shared across replicas — fine for today's single container, must move to Redis before horizontal scaling). Client IP comes from x-forwarded-for. Throttled requests return 429 with Retry-After.


Agent API

Auth is the license Bearer token, except license/activate (key may arrive in the body).

POST /v1/license/activate

Exchanges a license key (+ optional fingerprint) for a signed ed25519 activation token the agent verifies against its pinned public key. Mirrors license.Activate in agent/internal/license/license.go. Throttled by IP: activate:<ip>, 10 req / 60 s.

Body (activateBody, all optional — key may be the Bearer token): key (1–512 chars), hostname (≤255), fingerprint (≤512). The token binds the fingerprint when supplied and expires after HOSTSSH_LICENSE_TTL_DAYS (default 7); plan is fixed to business.

StatusBodyWhen
200{ token, plan, features, expires } (expires ISO-8601)success
400{ error: 'license key required' } / invalid bodyno key or schema failure
403{ error: 'license rejected' }key validation failed
429rate-limit>10/min per IP
500{ error: 'could not mint license token' }signing failed
503{ error: 'control plane signing key not configured' }HOSTSSH_LICENSE_PRIVKEY unset

POST /v1/telemetry/heartbeat

The agent reports in; the plane upserts the host and returns a signed, fingerprint-bound refresh that advances the offline-grace clock. A bare 200 never advances it — only a signRefresh-valid token does. Auth: agentGate('heartbeat', 30) — 30 req/min/key.

Body (heartbeatBody): fingerprint (1–512, required); optional hostname (≤255), version (≤128), state (≤64), cpu_pct/mem_pct/disk_pct/disk_total_gb (finite numbers), backup_health { last_capture_at?, success? }, restore_drill { last_at?, pass? }.

StatusBodyWhen
200{ ack: true, next_interval: 60, refresh? } (refresh omitted, not null, without a signing key)success
400invalid bodye.g. missing fingerprint
401 / 429{ error: 'unauthorized' } / rate-limitbad key / over limit

next_interval (seconds) re-paces the agent's heartbeat ticker.

Partial reports preserve the last known scalar usage/storage values and omitted telemetry groups, including build_slots. Explicit wire 0, false, and empty arrays replace previous values. A supplied group such as software replaces that whole snapshot; its missing child fields are not copied from an older report. New nodes without usage observations remain marked unknown (usageKnown=false). The current Go sender still omits some zero usage/storage and false backup-success values; retained readings therefore do not prove that every metric was sampled by the latest beat. Sender validity is a separate protocol concern.

POST /v1/jobs/claim

Pulls the next pending deploy job: atomic UPDATE ... FOR UPDATE SKIP LOCKED LIMIT 1 scoped to the caller's key, also reclaiming stale claimed/running jobs past the 5-minute lease (crash recovery). Auth: agentGate('claim', 60). Body (claimBody): fingerprint (required).

StatusBodyWhen
200{ job }a job was claimed
204(empty)queue empty for this host
400 / 401 / 429as above

POST /v1/jobs/{id}/status

Reports a job's progress/outcome, written under an ownership fence and mirrored onto the dashboard deployment (reflectJobStatus). Auth: agentGate('status', 120). Body (jobStatusBody): fingerprint (required), state (required: pending/claimed/running/succeeded/ failed), exit_code (int), error (≤8192 chars).

StatusBodyWhen
200{ ok: true }written
400 / 401 / 429as above
404{ error: 'job not found / not owned' }not owned by this key+fingerprint (reclaimed after the 5-min lease, or wrong license — late writes rejected by design)

POST /v1/jobs/{id}/logs

Streams build/run log lines, appended (ownership pre-checked) and mirrored onto the deployment log view (reflectJobLogs). Auth: agentGate('logs', 240). Body (jobLogsBody): lines (array of { ts, line }, schema cap 10000; handler additionally caps 1000 lines/request and truncates each line to 8192 chars before insert).

StatusBodyWhen
200{ ok: true, accepted: <count> } (post-cap count)appended
400 / 401 / 429as above
404{ error: 'job not found for this license' }not owned by this key

GET /v1/fleet/servers

Backs hostssh fleet servers: the registered hosts for the caller's key. Bearer key via validateLicenseKey (no agentGate, no rate limit). Returns { servers: [{ id, hostname, label, state, version, org, last_heartbeat }] } (id is agt_<fingerprint[:12]>; last_heartbeat unix timestamp, 0 if never). 401 on a bad key.

GET /v1/fleet/backups

Backs hostssh fleet backups: backup + restore-drill health per host (same auth as above). Optional ?host=<hostname> filter. Returns { backups: [{ hostname, last_capture_at, success, image_count, repo_bytes, last_drill_at, drill_pass }] } (timestamps unix, 0 if never; image_count/repo_bytes currently placeholders). 401 on a bad key.


App / public API

Dashboard routes live at their /api/... paths (no /v1 rewrite).

POST /api/v1/monitors/run and GET /api/v1/monitors/run

Telemetry scheduler entrypoint: a cron hits this on an interval; it runs every due DNS monitor once and piggybacks chat-retention purge on the tick. GET and POST share one handler. Auth: shared MONITORS_CRON_SECRET as Authorization: Bearer <secret> — timing-safe, fail-closed (unset secret rejects everything; monitors never run). Returns 200 { ok: true, ran, changed, alerted, purgedChats } (401 wrong/missing secret; 500 run threw). Retention window: CHAT_RETENTION_DAYS (default 90).

GET /api/deploy/{id}/logs

Dashboard deploy-log viewer: returns the stored transcript (200 { state, logs }). Auth: session + RBAC — valid hs_session and the platform.deploy permission (super_admin has all). 401 no session; 403 lacking permission; 404 no such deployment.

POST /api/chat

Public support chat, streaming SSE (200 text/event-stream of { type: 'meta'|'delta'|'done' }; meta carries conversationId + grounded/mode flags, done carries sources). Answers are grounded on the docs RAG index; without ANTHROPIC_API_KEY it streams a doc-grounded fallback. In human_active mode the AI stays silent and the message is only delivered. Public but defended: allowlisted Origin (defaults https://hostssh.com,https://www.hostssh.com,https://app.hostssh.com; missing Origin allowed; http://localhost off-production), per-IP limit (CHAT_RATE_LIMIT, 15/min) plus a global circuit-breaker (CHAT_GLOBAL_RATE_LIMIT, 200/min). Body (chatBody, all optional): conversationId (≤128), sessionId (≤256), message (≤8000, redacted to 4000), category (general/sales/bug/billing/feature), contact (≤1000). Message + contact are secret-redacted before storage. 400 empty message; 403 bad Origin; 429 rate-limit.

GET /healthz

Liveness/readiness probe (not under /api). No auth. 200 { ok: true, checks } when servable, 503 when not. Live mode (DATABASE_URL set) is 503 if: DB unreachable, any required table missing (admins, agents, jobs, deployments, chat_conversations, tenants, provision_requests, provision_events), the runtime database role is unsafe or unverified, or the boot-time migration runner reports pending/drifted/unknown migration state — a bare SELECT 1 is not enough. Missing signing config is a warning, never a failure. Demo mode (no DATABASE_URL) reports not-configured.

The handler shares one 4,000ms deadline across all readiness work. A timeout returns 503 { "ok": false, "checks": { "readiness": "timed-out" } }. Late database completion cannot replace that response or start the next readiness check. Existing database calls may continue because their APIs do not expose cancellation. Timer delivery can be delayed by event-loop contention; this is not a hard real-time guarantee.

GET /api/health/system

Operator diagnostics use the same report as /system. Requires a session (401 otherwise) and settings.manage (403 otherwise). Returns the grouped report with 200 when report.ok is true, 503 otherwise. All asynchronous probes share one 4,000ms deadline starting in runSystemChecks, after authentication/authorization. Completed results are retained; unfinished probes report failure with a timeout. Owned Cloudflare HTTP requests are aborted through response-body consumption. The deadline does not bound authentication lookup or guarantee cancellation of underlying database/provider APIs. This route is not public readiness.


Additional API surfaces

Same conventions throughout: /api/v1/* routes are license-bearer-authed and, where they act on a Node, fingerprint-fenced. Dashboard mutations are Next.js 'use server' actions (same-origin, session + RBAC), not these HTTP routes.

RouteMethodAuthPurpose
/api/v1/access/pendingGETBearer + agent fingerprint ownershipAgent polls for pending Web-SSH sessions to dial out for
/api/v1/access/cli-sessionPOSTsession (operator)Mint a short-lived Web-SSH/CLI token for hostssh ssh; fails closed without a relay
/api/v1/jobs/{id}/cancelPOSTagentGateCooperative cancellation of a running job
/api/v1/git/hooksPOSTBearerRegister/manage the git-push post-receive deploy hook
/api/v1/git/webhookPOSTBearerGitHub webhook receiver — builds the pushed commit by SHA (clone-by-SHA guard)
/api/v1/fleet/alertsGET/POSTBearerRead fleet alerts; drive the ack lifecycle
/api/v1/node/securityGET/POSTBearerRead/apply a Node's hardening/firewall settings (re-applied on boot)
/api/v1/images/registerPOSTBearerAgent registers a captured .hsi image (restic snapshot id in checksum)
/api/v1/transfer/keysPOSTBearerMint an hssht_v1 peer transfer key, fenced to source/target fingerprints, Ed25519-signed (pin, max_uses 1–8, mode pull|drop)
/api/v1/email/domainsGET/POSTBearerList/add sending domains
/api/v1/email/domains/{id}/verifyPOSTBearerVerify domain DNS (SPF/DKIM/DMARC)
/api/v1/email/domains/{id}/healthGETBearerDeliverability health
/api/v1/email/keys · /keys/{id}GET/POST/DELETEBearerManage sending API keys
/api/v1/email/sendPOSTBearerEnqueue an outbound message
/api/v1/email/alerts/runPOST/GETBearerDrain a send tick for license-expiry / billing-dunning mail
/api/v1/webhooks/endpoints · /endpoints/{id}GET/POST/DELETEBearerRegister/manage outbound webhook endpoints
/api/v1/webhooks/runPOSTBearerDrain the delivery queue (durable, signed, circuit-breaker retry)
/api/v1/attack-surface · /attack-surface/scanGET/POSTBearerOwner-gated (DNS-TXT) self-scan: ports, exposed files, CT subdomains. IP-pinned, SSRF-guarded
/api/v1/browser/agent · /browser/scrapePOSTBearerBrowser-worker observe→plan→act agent + rendered-DOM scrape. Token-gated
/api/v1/billing/checkoutPOSTBearerStripe Checkout session for a plan tier (STRIPE_PRICE_*, license_id metadata)
/api/webhooks/stripePOSTStripe signatureApply checkout/subscription events to licenses.tier/status
/api/v1/provisioning/workerPOSTworker bearerAdvance one durable Hostinger lifecycle step (live opt-in, spend ceiling; no customer license)
/metricsGET—Prometheus text exposition
/api/health/systemGETgod-modeConnection checker (private, no-store)

Job kinds the queue validates and the agent executes: deploy, redeploy, stop, remove, db, restore, firewall, prune, expose, unexpose. The third deploy surface is hostssh push (CLI) — see deploy/README.md. Web-SSH detail: secure-access.md, web-ssh.md.


  • agent-protocol.md — the agent's side of the activate → heartbeat → claim loop.
  • PLATFORM.md — control-plane architecture, the jobs queue, the dual-backend store.
  • ../product/SLOTS.md — the Slots model.
  • Control-plane API skill reference — curl recipes, required env, failure-mode table.
  • Database migrations & roles skill reference — the hostssh (DML) vs hostssh_migrator (DDL) role split.