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.

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), or the boot-time migration runner reports pending/drifted migrations — a bare SELECT 1 is not enough. Missing signing config is a warning, never a failure. Demo mode (no DATABASE_URL) reports not-configured.


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.