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
| Mechanism | Where | Used by |
|---|---|---|
| License bearer | Authorization: Bearer <license_key> (validateLicenseKey) | all agent endpoints |
agentGate | license bearer + per-key rate limit (lib/fleet/agent-auth.ts) | heartbeat, jobs claim/status/logs |
| Cron secret | MONITORS_CRON_SECRET, timing-safe, fail-closed | monitor runner |
| Worker secret | PROVISIONING_WORKER_SECRET, timing-safe, fail-closed | provisioning worker |
| Session + RBAC | signed hs_session cookie + permission check | deploy logs |
| Origin + rate limit | allowlisted Origin + per-IP/global limiter | public 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.
| Status | Body | When |
|---|---|---|
200 | { token, plan, features, expires } (expires ISO-8601) | success |
400 | { error: 'license key required' } / invalid body | no key or schema failure |
403 | { error: 'license rejected' } | key validation failed |
429 | rate-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? }.
| Status | Body | When |
|---|---|---|
200 | { ack: true, next_interval: 60, refresh? } (refresh omitted, not null, without a signing key) | success |
400 | invalid body | e.g. missing fingerprint |
401 / 429 | { error: 'unauthorized' } / rate-limit | bad 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).
| Status | Body | When |
|---|---|---|
200 | { job } | a job was claimed |
204 | (empty) | queue empty for this host |
400 / 401 / 429 | as 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).
| Status | Body | When |
|---|---|---|
200 | { ok: true } | written |
400 / 401 / 429 | as 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).
| Status | Body | When |
|---|---|---|
200 | { ok: true, accepted: <count> } (post-cap count) | appended |
400 / 401 / 429 | as 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.
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/v1/access/pending | GET | Bearer + agent fingerprint ownership | Agent polls for pending Web-SSH sessions to dial out for |
/api/v1/access/cli-session | POST | session (operator) | Mint a short-lived Web-SSH/CLI token for hostssh ssh; fails closed without a relay |
/api/v1/jobs/{id}/cancel | POST | agentGate | Cooperative cancellation of a running job |
/api/v1/git/hooks | POST | Bearer | Register/manage the git-push post-receive deploy hook |
/api/v1/git/webhook | POST | Bearer | GitHub webhook receiver — builds the pushed commit by SHA (clone-by-SHA guard) |
/api/v1/fleet/alerts | GET/POST | Bearer | Read fleet alerts; drive the ack lifecycle |
/api/v1/node/security | GET/POST | Bearer | Read/apply a Node's hardening/firewall settings (re-applied on boot) |
/api/v1/images/register | POST | Bearer | Agent registers a captured .hsi image (restic snapshot id in checksum) |
/api/v1/transfer/keys | POST | Bearer | Mint an hssht_v1 peer transfer key, fenced to source/target fingerprints, Ed25519-signed (pin, max_uses 1–8, mode pull|drop) |
/api/v1/email/domains | GET/POST | Bearer | List/add sending domains |
/api/v1/email/domains/{id}/verify | POST | Bearer | Verify domain DNS (SPF/DKIM/DMARC) |
/api/v1/email/domains/{id}/health | GET | Bearer | Deliverability health |
/api/v1/email/keys · /keys/{id} | GET/POST/DELETE | Bearer | Manage sending API keys |
/api/v1/email/send | POST | Bearer | Enqueue an outbound message |
/api/v1/email/alerts/run | POST/GET | Bearer | Drain a send tick for license-expiry / billing-dunning mail |
/api/v1/webhooks/endpoints · /endpoints/{id} | GET/POST/DELETE | Bearer | Register/manage outbound webhook endpoints |
/api/v1/webhooks/run | POST | Bearer | Drain the delivery queue (durable, signed, circuit-breaker retry) |
/api/v1/attack-surface · /attack-surface/scan | GET/POST | Bearer | Owner-gated (DNS-TXT) self-scan: ports, exposed files, CT subdomains. IP-pinned, SSRF-guarded |
/api/v1/browser/agent · /browser/scrape | POST | Bearer | Browser-worker observe→plan→act agent + rendered-DOM scrape. Token-gated |
/api/v1/billing/checkout | POST | Bearer | Stripe Checkout session for a plan tier (STRIPE_PRICE_*, license_id metadata) |
/api/webhooks/stripe | POST | Stripe signature | Apply checkout/subscription events to licenses.tier/status |
/api/v1/provisioning/worker | POST | worker bearer | Advance one durable Hostinger lifecycle step (live opt-in, spend ceiling; no customer license) |
/metrics | GET | — | Prometheus text exposition |
/api/health/system | GET | god-mode | Connection 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.
Related docs
- 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) vshostssh_migrator(DDL) role split.