# API reference

Base URL: `https://app.glassmkr.com/api/v1`. All requests and responses use JSON over HTTPS.

Authenticated endpoints require a Bearer token:

```
Authorization: Bearer YOUR_API_TOKEN
```

Error responses follow a consistent envelope:

```
{
  "error": "short_code",
  "message": "Human-readable description of what went wrong."
}
```

For programmatic-API specifics (account keys, scopes, audit log) see the [Programmatic API](/docs/programmatic-api) page. For which endpoints are Free vs Pro and how 402 responses behave, see the [Tier gating](/docs/api/tier-gating) page.

## Authentication

### Register

POST
`/auth/register`
Public

Create a new Glassmkr account.

**Request body:**

```
{
  "email": "user@example.com",
  "password": "min-12-characters",
  "name": "Jane Doe"
}
```

**Response (201):**

```
{
  "user": {
    "id": "usr_a1b2c3d4",
    "email": "user@example.com",
    "name": "Jane Doe",
    "verified": false,
    "created_at": "2026-04-05T10:00:00Z"
  },
  "token": "eyJhbGciOiJIUzI1NiIs..."
}
```

A verification email is sent automatically. The account is fully functional before verification, but some features (team invites) require a verified email.

### Login

POST `/auth/login` Public

Authenticate and receive a session token.

```
{
  "email": "user@example.com",
  "password": "min-12-characters"
}
```

**Response (200):**

```
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_at": "2026-04-12T10:00:00Z"
}
```

Session tokens are valid for 7 days. **Error (401)** on bad credentials: `{ "error": "invalid_credentials", "message": "Email or password is incorrect." }`.

### Logout

POST `/auth/logout` Authenticated

Invalidate the current session token. Response (204) no content.

### Get current user

GET `/auth/me` Authenticated

Returns the authenticated user's profile.

```
{
  "id": "usr_a1b2c3d4",
  "email": "user@example.com",
  "name": "Jane Doe",
  "verified": true,
  "role": "owner",
  "created_at": "2026-04-05T10:00:00Z",
  "servers_count": 6,
  "plan": "pro"
}
```

### Verify email

POST `/auth/verify` Public

Confirm an email address using the token from the verification email.

```
{ "token": "verify_abc123def456" }
```

## Servers

### Register server

POST `/servers` Authenticated

Register a new server. Returns a fresh collector key.

**Request body:** `name`, `hostname`, `tags`, and `profile` are accepted. Hardware fields (OS, architecture, core count, RAM) are reported by the agent on each ingest, never on registration.

```
{
  "name": "web-prod-01",
  "hostname": "web-prod-01.example.com",
  "tags": ["production", "web"]
}
```

`name` is required (1-100 chars). `hostname` defaults to `name` and must be a valid RFC 1035 hostname. `tags` is optional, max 20 strings of 1-50 chars each. `profile` is optional: a host-type profile that suppresses the alerts expected by design for that kind of host. The field is named `profile` (not `host_type`); accepted values are `null` (the default "General", no suppression) or `"marketplace_gpu"` (a rented marketplace GPU box, which silences `no_firewall`, `unattended_upgrades_disabled`, and `gpu_power_cap_throttling`). An unknown value returns `400 validation_failed` with `profile must be null or one of: ...`. Other fields are silently dropped (mass-assignment defense).

**Response (201):**

```
{
  "success": true,
  "server": {
    "id": "srv_a1b2c3d4",
    "name": "web-prod-01",
    "hostname": "web-prod-01.example.com",
    "tags": ["production", "web"],
    "api_key": "gmk_cru_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_aBcD"
  },
  "ingest_url": "https://app.glassmkr.com/api/v1/ingest",
  "message": "Save your collector key. It will not be shown again."
}
```

The collector key is shown **once**. Configure it on the agent before the dashboard tile leaves "pending first snapshot". The `Idempotency-Key` header is supported (24h replay window).

### List servers

GET `/servers` Authenticated

List all servers in the account.

**Query parameters:**

| Param | Type | Description |
| --- | --- | --- |
| `tag` | string | Filter by tag. Repeat for multiple tags (AND logic). |
| `limit` | int | Page size, 1-100 (default 100). |
| `cursor` | string | Opaque pagination cursor returned as `next_cursor` on the previous page. |

**Response (200):**

```
{
  "servers": [
    {
      "id": "srv_a1b2c3d4",
      "name": "web-prod-01",
      "hostname": "web-prod-01.example.com",
      "ip": "10.0.1.42",
      "os_type": "ubuntu",
      "os_version": "24.04 LTS",
      "status": "active",
      "suspended_at": null,
      "suspended_reason": null,
      "last_seen_at": "2026-05-09T07:00:00Z",
      "collector_version": "0.13.3",
      "active_alerts": 0,
      "disk_health_rollup": "healthy",
      "created_at": "2026-04-05T10:00:00Z",
      "tags": ["production", "web"],
      "dmi_vendor": "GIGABYTE",
      "dmi_product": "R292-4S1-00",
      "ipmi_sensors_count": 106
    }
  ],
  "next_cursor": null
}
```

Per-snapshot metrics (CPU usage, RAM usage, disk usage) are not on the list endpoint. Use `GET /servers/:id/health` for the latest snapshot from a specific server.

`status` is `active` for normal operation, `suspended` when the server is disabled (see [Billing](#billing)). `disk_health_rollup` is the worst per-drive state across all SMART-monitored drives: `healthy`, `declining`, `failing`, or `broken`.

### Get server

GET `/servers/:server_id` Authenticated

Get full details for a single server. Same shape as the list endpoint plus a few read-only fields (`config_overrides`, `free_analysis_used`).

### Update server

PATCH `/servers/:server_id` Authenticated

Update `name`, `tags`, or `profile` (same values as on registration; send `"profile": null` to clear it). `hostname` is intentionally not updatable so ops can find a box by hostname after a rename.

```
{
  "name": "web-prod-renamed",
  "tags": ["production", "web", "fra1"]
}
```

### Delete server

DELETE `/servers/:server_id?confirm=true` Authenticated

Remove a server and all its stored metrics. Irreversible. `?confirm=true` is required; a bare DELETE returns 400.

Per-endpoint sub-limit: 100 deletes/hour/account.

### Rotate collector key

POST `/servers/:server_id/rotate-key` Authenticated

Issue a fresh collector key for an existing server. The previous key stops working immediately. Update `/etc/glassmkr/crucible.yaml` (legacy installs: `/etc/glassmkr/collector.yaml`; the agent reads either) on the agent host and restart the service before the next ingest cycle to avoid a gap.

```
{
  "success": true,
  "server": { "id": "srv_a1b2c3d4" },
  "collector_key": "gmk_cru_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_aBcD",
  "rotated_at": "2026-05-09T07:30:00Z",
  "message": "Save this collector key. It will not be shown again."
}
```

Rate-limited to 10/hour/account. Note: the field name on this endpoint is `collector_key`, not `api_key` as on POST /servers.

### Restore server

POST `/servers/:server_id/restore` Authenticated

Restore a single suspended server. Used when a server was disabled because no payment method was on file. Requires a card on file at call time (unless the account is exempt).

**Error (400)** when no card is on file: `{ "error": "no_card_on_file", "message": "Add a payment method in Settings to restore this server." }`.

### Restore all suspended servers

POST `/servers/restore-all` Authenticated

Bulk-restore every server suspended for `no_card_on_file`. Used by the dashboard's Settings -> Disabled servers -> Restore all button.

## Ingest

### Push snapshot

POST `/ingest` Collector key

Submit a Crucible snapshot. Called by the agent every collection interval (default 60 seconds for Crucible v0.10.0+). Authenticated by the collector key in the `Authorization: Bearer gmk_cru_live_...` header. Rate-limited to one ingest per server per 55 seconds; subsequent calls return 429.

**Request body** (abbreviated; the agent emits the full Snapshot type):

```
{
  "system":   { "hostname": "web-prod-01", "ip": "10.0.1.42",
                "os": "Ubuntu 24.04 LTS", "os_id": "ubuntu",
                "kernel": "6.8.0-31-generic", "uptime_seconds": 86400 },
  "cpu":      { "user_percent": 15.2, "system_percent": 5.3,
                "iowait_percent": 1.1, "idle_percent": 78.4,
                "load_1m": 0.4, "load_5m": 0.6, "load_15m": 0.5,
                "cores": [{ "core": 0, "user_percent": 20.1, "system_percent": 4.2,
                            "iowait_percent": 0.5, "idle_percent": 75.2 }] },
  "memory":   { "total_mb": 65536, "used_mb": 44032,
                "available_mb": 21504, "swap_total_mb": 8192,
                "swap_used_mb": 0 },
  "disks":    [{ "device": "/dev/nvme0n1p2", "mount": "/",
                 "total_gb": 500, "used_gb": 225, "available_gb": 250,
                 "percent_used": 47, "fstype": "ext4",
                 "io_read_mb_s": 15.2, "io_write_mb_s": 3.8,
                 "latency_p99_ms": 0.4,
                 "inodes_total": 32768000, "inodes_used": 1245000 }],
  "smart":    [{ "device": "/dev/nvme0n1", "model": "Samsung 990 Pro 2TB",
                 "health": "PASSED", "temperature_c": 38,
                 "percentage_used": 12, "power_on_hours": 8760 }],
  "network":  [{ "interface": "eth0", "speed_mbps": 10000,
                 "rx_bytes_sec": 125000, "tx_bytes_sec": 42000,
                 "rx_errors": 0, "tx_errors": 0,
                 "rx_drops": 0, "tx_drops": 0 }],
  "raid":     [],
  "ipmi":     { "available": true, "sel_entries_count": 12,
                "ecc_errors": { "correctable": 0, "uncorrectable": 0 },
                "sensors": [{ "name": "CPU1_TEMP", "value": 52, "unit": "C",
                              "status": "ok", "type": "temperature",
                              "upper_critical": 90 }] },
  "os_alerts": { "oom_kills_recent": 0, "zombie_processes": 0,
                 "time_drift_ms": 0 },
  "thermal":  { "available": true, "source": "hwmon coretemp Package id 0",
                "max_cpu_celsius": 52,
                "cpu_readings": [{ "chip": "coretemp-isa-0000",
                                    "label": "Package id 0", "celsius": 52 }] },
  "dmi":      { "available": true, "vendor": "supermicro",
                "raw_vendor": "Supermicro Inc.",
                "product_name": "SYS-1029P-WTR",
                "bios_version": "3.4", "bios_date": "2023-01-12",
                "is_virtual": false },
  "gpu":      { "available": true, "tier": "nvidia-smi",
                "devices": [{ "index": 0, "name": "NVIDIA L4",
                              "temperature_c": 48, "utilization_percent": 12,
                              "memory_used_mb": 4096, "memory_total_mb": 24564 }] },
  "collector_version": "0.13.3",
  "timestamp": "2026-05-22T07:00:00Z"
}
```

Optional top-level blocks: `security`, `zfs`, `io_errors`, `io_latency`, `conntrack`, `systemd`, `ntp`, `file_descriptors`, `thermal`, `dmi`, `gpu`, `expected_reboot`. Unknown fields are accepted via `passthrough`; new collector versions can extend the schema without a coupled Dashboard release.

**Response (200):**

```
{
  "success": true,
  "received_at": "2026-05-22T07:00:00.123Z",
  "new_alerts": 0,
  "active_alerts": 0
}
```

## Health

### Get server health

GET `/servers/:server_id/health` Authenticated

Current health status and latest metric values for a server.

```
{
  "server_id": "srv_a1b2c3d4",
  "status": "healthy",
  "last_seen": "2026-04-05T10:05:00Z",
  "current": {
    "cpu_percent": 21.6,
    "ram_percent": 67.2,
    "swap_used_mb": 0,
    "disk_max_percent": 45.0,
    "network_rx_mbps": 120.5,
    "network_tx_mbps": 40.2,
    "cpu_temp_c": 52,
    "active_alerts": 0
  }
}
```

### Get health history

GET `/servers/:server_id/health/history` Authenticated

Time-series metric data.

| Param | Type | Description |
| --- | --- | --- |
| `metric` | string | `cpu`, `memory`, `disk`, `network`, `temperature`. |
| `from` | ISO 8601 | Start time (default 1 hour ago). |
| `to` | ISO 8601 | End time (default now). |
| `resolution` | string | `1m`, `5m`, `1h`, `1d` (auto if omitted). |

### Get server alerts

GET `/servers/:server_id/alerts` Authenticated

**Defaults to `status=all`, which includes resolved history**; pass `?status=active` for only the alerts currently firing. Query params: `status` (`active`, `resolved`, `all`; default `all`), `severity` (`critical`, `warning`, `info`), `from`, `to`, `page`.

## Channels

### Create channel

POST `/channels` Write scope

Create a notification channel. Supported types: `email`, `telegram`, `slack`, `discord`, `pagerduty`, `webhook`.

```
{
  "name": "ops-telegram",
  "type": "telegram",
  "config": {
    "bot_token": "7123456789:AAH1bGciOiJSUzI1NiIs",
    "chat_id": "-1001234567890"
  }
}
```

Email config takes `recipients`; Slack and Discord take `webhook_url`; PagerDuty takes `routing_key`; webhook takes `url` and optional `secret`.

### List channels

GET `/channels` Authenticated

### Get channel

GET `/channels/:channel_id` Authenticated

Sensitive fields like bot tokens are partially masked in GET responses.

### Update channel

PUT `/channels/:channel_id` Write scope

### Delete channel

DELETE `/channels/:channel_id` Write scope

### Test channel

POST `/channels/:channel_id/test` Write scope

Send a test notification through the channel. **Error (502)** on delivery failure with the upstream error message.

## Alerts

### Acknowledge alert

POST `/alerts/:alert_id/acknowledge` Write scope

Silence notifications for the current occurrence; does not disable the rule. Event-type alerts (e.g. `unexpected_reboot`) auto-clear acknowledgement when a new occurrence stacks onto the card.

### Resolve alert

POST `/alerts/:alert_id/resolve` Write scope

Manually resolve an alert without waiting for the underlying condition to clear. Mostly used for event-type alerts (24-hour TTL otherwise) and for force-clearing stuck state alerts.

### List muted rules

GET `/servers/:server_id/mutes` Authenticated

### Mute a rule

POST `/servers/:server_id/mutes` Write scope

Mute one alert rule for this server, one rule per request. Any currently active alert of that type is resolved immediately. Returns the updated `muted_rules` list.

```
{ "alert_type": "disk_space_high" }
```

### Unmute a rule

DELETE `/servers/:server_id/mutes` Write scope

Remove one rule from the muted list, one rule per request. Returns the updated `muted_rules` list.

```
{ "alert_type": "disk_space_high" }
```

## Billing

### Billing status

GET `/billing/status` Authenticated

Returns plan, billing-period bounds, payment-method state, and the count of servers currently disabled for missing card.

```
{
  "plan": "pro",
  "has_default_payment_method": true,
  "current_period_end": "2026-06-09T00:00:00Z",
  "cancel_at_period_end": false,
  "billing_enforcement_exempt": false,
  "active_server_count": 7,
  "suspended_no_card_count": 0,
  "free_server_quota": 3
}
```

Pro customers without a payment method see `has_default_payment_method: false`. The grace period is the later of `current_period_end` and `customer.created_at + 30 days`; after that, servers beyond the free quota are suspended.

### Other billing endpoints

- `POST /billing/checkout`: create a Stripe Checkout session for plan upgrade.
- `POST /billing/portal`: create a Stripe Customer Portal session.
- `POST /billing/resume`: re-enable auto-renew on a cancelled subscription.
- `POST /billing/downgrade`: schedule a downgrade to Free at period end.

## Meta

### Version

GET `/version` Public

Returns the latest published Crucible version and the minimum supported version.

```
{
  "crucible": {
    "latest": "0.13.3",
    "min_supported": "0.7.0",
    "changelog_url": "https://github.com/glassmkr/crucible/releases"
  },
  "dashboard": { "version": "1.0.0" }
}
```

The `latest` value is sourced from the npm registry's `@glassmkr/crucible` `latest` dist-tag.

## Rate limits

Token-bucket limiter applied as four overlapping tiers (first failure wins; failures still cost a token on the per-IP debit so brute-force probing burns budget):

| Tier | Capacity | Refill | Applies to |
| --- | --- | --- | --- |
| Per-IP | 100 | 10/sec | Every request, including pre-auth. |
| Per-key | 1000 | 100/sec | Authenticated requests, scoped to one collector or account key. |
| Per-account | 5000 | 500/sec | All authenticated requests within one customer. |
| POST /servers | 100 | 100/hour | Server registration sub-limit. |
| DELETE /servers/:id | 100 | 100/hour | Deletion sub-limit. |
| POST /servers/:id/rotate-key | 10 | 10/hour | Key-rotation sub-limit. |

The ingest endpoint enforces a per-server soft limit of one push per 55 seconds (returns 429, separate from the token-bucket layer). When token-bucket-rate-limited, the API returns `429 Too Many Requests` with a `Retry-After` header.

## Pagination

List endpoints (currently `GET /servers`) use opaque cursor pagination: pass `?limit=` (1-100, default 100) and the previous response's `next_cursor` as `?cursor=`. `next_cursor` is `null` on the final page.

## Idempotency

`POST /servers` honors an `Idempotency-Key` header (1-255 printable ASCII). The first response (success or deterministic 4xx) is cached for 24 hours; replays return the cached response with an `Idempotency-Replayed: true` header. Concurrent retries with the same key while the original is still in flight return 409.

Last verified: 2026-05-22 against Crucible v0.13.3 and Dashboard v1.0. For tier-gating details see [/docs/api/tier-gating](/docs/api/tier-gating).
