API reference
Every HTTP endpoint — method, auth, request schema, and responses.
HTTP API reference
This is the complete HTTP API surface of the HostSSH control plane (control-plane/web, Next.js 16 App Router, output: standalone). It is split into two audiences:
- Agent API (
/api/v1/*) — called by the zero-dependency Go agent running on each managed VPS. It dials out to the control plane; 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-mode table), see the control-plane-api skill reference. For the agent side of these calls, see the agent skill reference and agent-protocol.md.
Conventions
The /v1 → /api/v1 rewrite
The Go agent calls the control plane at <base>/v1/* (for example https://api.hostssh.com/v1/license/activate). The App Router routes live under app/api/v1/, and next.config.ts bridges the two:
async rewrites() {
return [{ source: '/v1/:path*', destination: '/api/v1/:path*' }]
}
So POST /v1/jobs/claim and POST /api/v1/jobs/claim are the same handler. Paths in this doc are written as /v1/... (the agent-facing form) for the agent API; the app-side routes are served only at their /api/... path.
Every /api/v1/* route declares runtime = 'nodejs' and dynamic = 'force-dynamic' (no caching, full Node runtime for crypto).
Request bodies and validation
Every route that accepts a body validates it with a zod schema in lib/api/schemas.ts, run through parseBody() in lib/api/validate.ts. The validator is a tolerant receiver:
- Unknown keys are stripped — a newer agent that adds a field (e.g.
ts,storage) does not break an older control plane. - Wrong types and missing required fields are rejected with
400 { error: 'invalid request body', details: [...] }(up to 12 field-scoped messages). - Invalid/unparseable JSON returns
400 { error: 'invalid JSON body' }.
Because agent fields are marshalled with Go's omitempty (omitted, never null), optional fields use .optional() rather than .nullable(). The schemas mirror the Go wire structs in agent/internal/{license,telemetry,jobs} exactly — keep them in sync or you will 400 a legitimate agent.
Authentication mechanisms
| Mechanism | Where | Used by |
|---|---|---|
| License bearer | Authorization: Bearer <license_key>, validated by 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 |
| Provisioning worker secret | PROVISIONING_WORKER_SECRET, timing-safe, fail-closed | paid provider lifecycle worker |
| Session + RBAC | signed hs_session cookie + permission check | deploy logs |
| Origin + rate limit | allowlisted Origin + per-IP/global limiter | public chat |
License key validation (lib/fleet/agents.ts, validateLicenseKey) accepts a key in this order:
- The
HOSTSSH_LICENSE_KEYSenv allow-list (comma-separated bootstrap keys). - A DB-issued license that is active, non-expired, and not
revoked/suspended/expired. - Dev only: if the allow-list is empty and
NODE_ENV !== 'production', any non-empty key is accepted. In production with no allow-list and no DB row, the key is rejected.
The license key is the agent's identity — there is no separate cryptographic per-host proof yet. The fingerprint is data carried in the body, not an auth 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 — a fixed-window counter on globalThis. It resets on every deploy and is not shared across replicas. With the single Coolify container in production today this is effective; horizontal scaling would require moving it to Redis. The client IP is read from x-forwarded-for (set by Traefik). A throttled request returns 429 with a Retry-After header.
Agent API
All agent endpoints are served under /v1/* (rewritten to /api/v1/*). Auth is the host's license key as a bearer token, except license/activate, which may carry the key in the body and validates it before doing any work.
POST /v1/license/activate
Purpose. The agent exchanges a license key (plus optional host fingerprint) for a signed ed25519 activation token, which it verifies against its pinned public key and stores. Mirrors license.Activate in agent/internal/license/license.go.
Auth. None until the key is checked. The key arrives in the body (key) or as the bearer token. It is then run through validateLicenseKey. Throttled by IP: activate:<ip>, 10 requests / 60 s.
Request body (activateBody — all optional, since the key may instead be the bearer token):
| Field | Type | Constraints |
|---|---|---|
key | string | 1–512 chars |
hostname | string | ≤255 chars |
fingerprint | string | ≤512 chars |
Behavior. When the agent supplies a fingerprint, the minted token is bound to it. The token expires after HOSTSSH_LICENSE_TTL_DAYS (default 7). A finite TTL is deliberate: a non-expiring token would pass the agent's offline "strong path" forever and could never be revoked. The plan is fixed to business with features ['deploy', 'db', 'mcp', 'clone', 'migrate'].
Responses.
| Status | Body | When |
|---|---|---|
200 | { token, plan, features, expires } | success; expires is an ISO-8601 timestamp |
400 | { error: 'license key required' } / invalid body | no key supplied, or schema failure |
403 | { error: 'license rejected' } | validateLicenseKey failed |
429 | rate-limit | more than 10/min per IP |
500 | { error: 'could not mint license token' } | signing produced no token |
503 | { error: 'control plane signing key not configured' } | HOSTSSH_LICENSE_PRIVKEY unset |
POST /v1/telemetry/heartbeat
Purpose. The agent reports in. The control plane upserts the host into the live registry and returns a signed, fingerprint-bound refresh that advances the agent's offline-grace clock. A bare 200 does not advance the clock — only a signRefresh-valid token does, so the agent never trusts an unsigned 200.
Auth. agentGate('heartbeat', 30) — license bearer + 30 requests / min / key.
Request body (heartbeatBody):
| Field | Type | Required | Constraints |
|---|---|---|---|
fingerprint | string | yes | 1–512 chars |
hostname | string | no | ≤255 |
version | string | no | ≤128 |
state | string | no | ≤64 |
cpu_pct / mem_pct / disk_pct / disk_total_gb | number (finite) | no | |
backup_health | object { last_capture_at?: number, success?: boolean } | no | |
restore_drill | object { last_at?: number, pass?: boolean } | no |
Responses.
| Status | Body | When |
|---|---|---|
200 | { ack: true, next_interval: 60, refresh? } | success; refresh is omitted (not null) when no signing key is configured |
400 | invalid body | e.g. missing fingerprint |
401 | { error: 'unauthorized' } | bad/missing license key |
429 | rate-limit |
next_interval (seconds) re-paces the agent's heartbeat ticker.
POST /v1/jobs/claim
Purpose. The agent pulls its next pending deploy job. Claiming is an atomic UPDATE ... FOR UPDATE SKIP LOCKED LIMIT 1 over the jobs table scoped to the caller's key, and also reclaims stale claimed/running jobs past the 5-minute lease (crash recovery).
Auth. agentGate('claim', 60) — license bearer + 60/min/key.
Request body (claimBody):
| Field | Type | Required |
|---|---|---|
fingerprint | string (1–512) | yes |
Responses.
| 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
Purpose. The agent reports a job's progress/outcome. The state is written under an ownership fence and mirrored onto the deployment the dashboard shows (reflectJobStatus).
Auth. agentGate('status', 120) — license bearer + 120/min/key.
Request body (jobStatusBody):
| Field | Type | Required | Constraints |
|---|---|---|---|
fingerprint | string | yes | 1–512 |
state | enum | yes | one of pending, claimed, running, succeeded, failed |
exit_code | integer | no | |
error | string | no | ≤8192 |
Responses.
| Status | Body | When |
|---|---|---|
200 | { ok: true } | written |
400 / 401 / 429 | as above | |
404 | { error: 'job not found / not owned' } | the job isn't owned by this key+fingerprint — e.g. it was reclaimed after the 5-min lease, or wrong license. Late writes from a reclaimed agent are rejected by design |
POST /v1/jobs/{id}/logs
Purpose. The agent streams build/run log lines for a job. Lines are appended (with an ownership pre-check) and mirrored onto the deployment's log view (reflectJobLogs).
Auth. agentGate('logs', 240) — license bearer + 240/min/key.
Request body (jobLogsBody):
| Field | Type | Required | Constraints |
|---|---|---|---|
lines | array of { ts: number, line: string } | no | array ≤10000 (schema cap) |
The handler additionally caps each request to 1000 lines and truncates each line to 8192 chars before insert (the schema cap only stops zod walking a pathological payload; the handler caps what reaches the DB).
Responses.
| Status | Body | When |
|---|---|---|
200 | { ok: true, accepted: <count> } | lines appended (accepted reflects the post-cap count) |
400 / 401 / 429 | as above | |
404 | { error: 'job not found for this license' } | job not owned by this key |
GET /v1/fleet/servers
Purpose. Backs the agent's hostssh fleet servers CLI. Returns the registered hosts for the caller's license key, in the compact shape the CLI expects.
Auth. Bearer license key, validated directly by validateLicenseKey (this route does not use agentGate and is not rate-limited).
Request body. None.
Responses.
| Status | Body | When |
|---|---|---|
200 | { servers: [{ id, hostname, label, state, version, org, last_heartbeat }] } | scoped to the caller's key; id is agt_<fingerprint[:12]>, last_heartbeat is a unix timestamp (0 if never) |
401 | { error: 'unauthorized' } | bad/missing key |
GET /v1/fleet/backups
Purpose. Backs hostssh fleet backups. Returns backup + restore-drill health per host.
Auth. Bearer license key via validateLicenseKey (no agentGate, no rate limit).
Query parameters. ?host=<hostname> — optional filter to a single host.
Request body. None.
Responses.
| Status | Body | When |
|---|---|---|
200 | { backups: [{ hostname, last_capture_at, success, image_count, repo_bytes, last_drill_at, drill_pass }] } | last_capture_at/last_drill_at are unix timestamps (0 if never); image_count and repo_bytes are currently placeholders (0) |
401 | { error: 'unauthorized' } | bad/missing key |
App / public API
These are the dashboard's own routes and the public-facing surface. They live at their /api/... paths (no /v1 rewrite).
POST /api/v1/monitors/run and GET /api/v1/monitors/run
Purpose. The telemetry scheduler entrypoint. A cron (Coolify scheduled task, GitHub Actions, an external pinger) hits this on an interval. It runs every due DNS monitor once, returns a summary, and piggybacks chat-retention purge on the tick. Infra-agnostic — both GET and POST route to the same handler.
Auth. Shared secret MONITORS_CRON_SECRET, supplied as Authorization: Bearer <secret> or ?key=<secret>. Compared with a timing-safe equality check. Fail-closed: if MONITORS_CRON_SECRET is unset, every request is rejected and monitors never run.
Request body. None.
Responses.
| Status | Body | When |
|---|---|---|
200 | { ok: true, ran, changed, alerted, purgedChats } | success; ran = monitors executed, changed/alerted = drift counts, purgedChats = rows purged (retention is best-effort and never fails the run) |
401 | { error: 'unauthorized' } | wrong/missing secret, or secret unset (fail-closed) |
500 | { ok: false, error } | the monitor run threw |
The chat-retention window defaults to CHAT_RETENTION_DAYS (90 days).
GET /api/deploy/{id}/logs
Purpose. Polled by the dashboard's deploy log viewer. Today it returns the stored deployment transcript; once the agent's streaming /logs is wired through, it will proxy that.
Auth. Session + RBAC. Requires a valid hs_session (admin or product user) and the platform.deploy permission (userHasPermission). Deploy logs can carry build output and config, so they are gated to the deploy surface. super_admin has all permissions.
Request body. None.
Responses.
| Status | Body | When |
|---|---|---|
200 | { state, logs } | the deployment's current state + stored log lines |
401 | { error: 'unauthorized' } | no session |
403 | { error: 'forbidden' } | session lacks platform.deploy |
404 | { error: 'not found' } | no such deployment |
POST /api/chat
Purpose. The public customer support chat. Streams a Server-Sent Events response. Answers are grounded on the docs/blog RAG index. Uses the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise streams a doc-grounded canned fallback so the experience works in any environment. When a human has taken over the conversation (mode === 'human_active'), the AI stays silent and the message is just delivered to the agent.
Auth. Public and unauthenticated — but defended three ways, because each request writes to Postgres and may fire a billable LLM call:
- Origin allowlist (CSRF defense). When an
Originheader is present it must be inCHAT_ALLOWED_ORIGINS(defaults tohttps://hostssh.com,https://www.hostssh.com,https://app.hostssh.com). A missing Origin (non-browser) is allowed.http://localhostis allowed off-production. - Per-IP rate limit —
CHAT_RATE_LIMIT(default 15) requests/min/IP. - Global circuit-breaker —
CHAT_GLOBAL_RATE_LIMIT(default 200) requests/min across all IPs, to bound a rotating-IP flood that per-IP limiting alone can't stop.
Request body (chatBody — all optional):
| Field | Type | Constraints |
|---|---|---|
conversationId | string | ≤128 |
sessionId | string | ≤256 (truncated to 128 on create) |
message | string | ≤8000 (further trimmed/redacted to 4000 in the handler) |
category | enum | one of general, sales, bug, billing, feature |
contact | string | null | ≤1000 (truncated to 200 in the handler) |
Both the message and the contact are run through a secret-redactor before storage.
Responses.
| Status | Body | When |
|---|---|---|
200 | text/event-stream | SSE stream of { type: 'meta' | 'delta' | 'done', ... } events; meta carries conversationId and a grounded/mode flag, delta carries text chunks, done carries sources |
400 | { error: 'Empty message' } / invalid body | message empty after trim, or schema failure |
403 | { error: 'forbidden' } | Origin not allowlisted |
429 | rate-limit | per-IP or global limit hit |
GET /healthz
Purpose. Liveness/readiness probe for Coolify and uptime checks. Served at /healthz (not under /api).
Auth. None.
Responses.
| Status | Body | When |
|---|---|---|
200 | { ok: true, checks } | app can serve |
503 | { ok: false, checks } | not ready |
In live mode (DATABASE_URL set) the probe is 503 if: the DB is unreachable, any required table is missing (admins, agents, jobs, deployments, chat_conversations, tenants), or the boot-time migration runner reports pending or drifted migrations. A bare SELECT 1 passing is not enough — the table and migration checks catch a partially-applied schema that would otherwise 500 login/chat/activation while reporting healthy. Licensing/signing being unset is surfaced as a warning, not a failure (the marketing site and sign-in work without it). In demo/in-memory mode (DATABASE_URL unset) the DB check reports not-configured.
Additional API surfaces
These route groups shipped after the core loop above. They follow the same conventions: /api/v1/*
routes are bearer-authed (license key) and, where a route acts on a Node, fingerprint-fenced; the
dashboard's own mutations are Next.js 'use server' actions (same-origin, session + RBAC), not
these HTTP routes.
Access (Web-SSH)
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/v1/access/pending | GET | bearer + agent fingerprint ownership | The agent polls for pending Web-SSH sessions to dial out for. Fenced so only the owning agent can claim a session. |
/api/v1/access/cli-session | POST | session (operator) | Mint a short-lived Web-SSH/CLI session token for hostssh ssh; fails closed without a configured relay. |
See secure-access.md and web-ssh.md.
Jobs (extended)
| Route | Method | Purpose |
|---|---|---|
/api/v1/jobs/{id}/cancel | POST | Request cancellation of a running job (cooperative — the agent tears down in-progress work). |
Job kinds the queue validates and the agent executes: deploy, redeploy, stop, remove, db,
restore, firewall, prune, expose, unexpose.
Deploy surfaces (local-dev → live node)
| Route | Method | Purpose |
|---|---|---|
/api/v1/git/hooks | POST | Register/manage the git-push post-receive deploy hook for an app. |
/api/v1/git/webhook | POST | GitHub webhook receiver — builds the pushed commit by SHA (guard: clone-by-SHA, not branch). |
The third surface is hostssh push (CLI). See deploy/README.md.
Fleet & node
| Route | Method | Purpose |
|---|---|---|
/api/v1/fleet/alerts | GET/POST | Read fleet alerts and drive the ack lifecycle. Alerts are persisted and delivered on the scheduler tick. |
/api/v1/node/security | GET/POST | Read/apply a Node's security (hardening/firewall) settings — panel-configurable, re-applied on boot. |
/api/v1/images/register | POST | The agent registers a captured .hsi image into the ledger (carries the restic snapshot id in checksum). |
/api/v1/transfer/keys | POST | Mint a one-time hssht_v1 peer transfer key, fenced to the caller's source/target fingerprints, Ed25519-signed. |
Email (native engine)
| Route | Method | Purpose |
|---|---|---|
/api/v1/email/domains | GET/POST | List/add sending domains. |
/api/v1/email/domains/{id}/verify | POST | Verify a domain's DNS (SPF/DKIM/DMARC). |
/api/v1/email/domains/{id}/health | GET | Domain deliverability health. |
/api/v1/email/keys · /keys/{id} | GET/POST/DELETE | Manage sending API keys. |
/api/v1/email/send | POST | Enqueue an outbound message. |
/api/v1/email/alerts/run | POST/GET | Drain a send tick for license-expiry / billing-dunning mail. |
Webhooks (outbound delivery add-on)
| Route | Method | Purpose |
|---|---|---|
/api/v1/webhooks/endpoints · /endpoints/{id} | GET/POST/DELETE | Register/manage outbound webhook endpoints. |
/api/v1/webhooks/run | POST | Drain the delivery queue (durable, signed, circuit-breaker retry). |
Attack-surface & browser
| Route | Method | Purpose |
|---|---|---|
/api/v1/attack-surface · /attack-surface/scan | GET/POST | Owner-gated (DNS-TXT) self-scan: port sweep, exposed-file probe, CT subdomains, fingerprint. IP-pinned, SSRF-guarded. |
/api/v1/browser/agent · /browser/scrape | POST | Browser-worker: observe→plan→act agent, and rendered-DOM scrape. Token-gated. |
Billing & provisioning
| Route | Method | Purpose |
|---|---|---|
/api/v1/billing/checkout | POST | Create a Stripe Checkout session mapping a plan tier to STRIPE_PRICE_*, with license_id metadata. |
/api/webhooks/stripe | POST | Verify raw-body Stripe signatures and apply checkout/subscription events to licenses.tier/status. |
/api/v1/provisioning/worker | POST | Advance one durable Hostinger lifecycle step. Requires its dedicated bearer, live opt-in, verified server-side credential, catalog mappings, template, and spend ceiling. A customer license cannot call it. |
Metrics
| Route | Method | Purpose |
|---|---|---|
/metrics | GET | Prometheus text exposition (rate-limit backend, scheduler, uptime series). |
/api/health/system | GET | God-mode connection checker (private, no-store). |
Related docs
- agent-protocol.md — the agent's side of the activate → heartbeat → claim loop.
- PLATFORM.md — control-plane architecture, the jobs queue, and the dual-backend store pattern.
- ../product/SLOTS.md — the Slots model.
- Control-plane API skill reference — operator runbook: curl recipes, required env, and the failure-mode table.
- Database migrations & roles skill reference — the
hostssh(DML) vshostssh_migrator(DDL) role split and the boot-time migration runner that/healthzchecks.