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