# Spoolr Connector API Reference

The Spoolr Connector API allows printer connectors (and integrators or AI agents) to register with a Spoolr account, push telemetry, respond to remote commands, serve webcam images on demand, and upload backups.

**Base URL:** `/api/v1`

All request and response bodies are JSON unless noted (binary uploads are the exception). Set `Content-Type: application/json` on all JSON requests.

---

## Authentication

### Registration (unauthenticated)

The `POST /api/v1/connectors/register` endpoint is public. It exchanges a one-time **pairing token** — generated in the Spoolr web UI with a 10-minute expiry and single-use policy — for a long-lived connector `id` and `secret`.

> **The plaintext secret is returned exactly once at registration.** Store it immediately; it cannot be retrieved again. The server stores only a SHA-256 digest.

### All other endpoints: two required headers

| Header | Value |
|---|---|
| `Authorization` | `Bearer <secret>` |
| `X-Connector-Id` | `<connector_id>` |

The server locates the connector by `X-Connector-Id`, then verifies the bearer token against the stored digest using a constant-time comparison.

### Backup upload: signed token (exception)

`POST|PUT /api/v1/backups/:id/upload` skips the bearer scheme entirely. It uses a Rails `MessageVerifier`-signed token passed either as:

- Query parameter: `?token=<signed_token>`
- Header: `X-Upload-Token: <signed_token>`

The token encodes the backup ID and an expiry timestamp. Generating this token is handled server-side when a backup job is dispatched.

---

## Connector Lifecycle

A typical connector session follows this sequence:

1. **Register** — exchange a pairing token for credentials and printer IDs.
2. **Heartbeat loop** — `POST /api/v1/connectors/:id/heartbeat` every 30–60 seconds to mark the connector online.
3. **Poll commands** — `GET /api/v1/connectors/:connector_id/commands` every `polling.commands_seconds` (default: 3 s). Returned commands are automatically marked `running`.
4. **Complete commands** — `POST /api/v1/commands/:id/complete` after each command finishes, reporting `succeeded` or `failed`.
5. **Push snapshot batches** — `POST /api/v1/snapshots/batch` every `polling.snapshots_seconds` (default: 30 s) with telemetry for one or more printers.
6. **Serve webcam requests on demand** — poll `GET /api/v1/connectors/:id/webcam_requests`, capture JPEG frames, and upload each via `PUT /api/v1/webcam_requests/:id/upload`.
7. **Upload backups on demand** — when instructed (via a `create_backup` command), stream the archive to `POST|PUT /api/v1/backups/:id/upload` using the signed token.

---

## Endpoints

### Endpoint Summary

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/api/v1/connectors/register` | None | Register a connector |
| `POST` | `/api/v1/connectors/:id/heartbeat` | Bearer | Heartbeat / keep-alive |
| `GET` | `/api/v1/connectors/:connector_id/commands` | Bearer | Poll pending commands |
| `POST` | `/api/v1/commands/:id/complete` | Bearer | Mark a command complete |
| `GET` | `/api/v1/connectors/:id/webcam_requests` | Bearer | Poll pending webcam requests |
| `PUT` | `/api/v1/webcam_requests/:id/upload` | Bearer | Upload a webcam frame |
| `GET` | `/api/v1/connectors/:id/webcam_stream` | Bearer | Poll printers a viewer is live-watching |
| `PUT` | `/api/v1/printers/:id/webcam_stream_frame` | Bearer | Push a live MJPEG stream frame |
| `POST` | `/api/v1/snapshots/batch` | Bearer | Push telemetry snapshots |
| `POST` / `PUT` | `/api/v1/backups/:id/upload` | Signed token | Upload a backup archive |

---

### 1. Register Connector

`POST /api/v1/connectors/register`

**Authentication:** None (public endpoint).

Exchanges a pairing token for connector credentials and printer IDs. The pairing token must not be expired or previously used.

#### Request body

```json
{
  "pairing_token": "abc123...",
  "site_name": "My Workshop",
  "device": {
    "hostname": "K1Max-1814",
    "arch": "mipsle",
    "os": "linux",
    "version": "0.1.0",
    "ip": "192.0.2.50",
    "ui_port": 4409
  },
  "printers": [
    {
      "name": "K1 Max",
      "host": "192.0.2.60",
      "moonraker_port": 7125,
      "ui_port": 4409
    }
  ]
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `pairing_token` | string | Yes | One-time token from the web UI |
| `site_name` | string | No | Falls back to the token's site name |
| `device` | object | No | Connector host information |
| `device.hostname` | string | No | |
| `device.arch` | string | No | e.g. `mipsle`, `arm64`, `amd64` |
| `device.os` | string | No | e.g. `linux` |
| `device.version` | string | No | Connector firmware version |
| `device.ip` | string | No | Used as fallback printer host |
| `device.ui_port` | integer | No | Connector UI port |
| `printers` | array | No | Omit to auto-create one placeholder printer |
| `printers[].name` | string | No | Defaults to `Printer #N` |
| `printers[].host` | string | No | Printer LAN host; falls back to `device.ip` |
| `printers[].moonraker_port` | integer | No | Defaults to `7125` |
| `printers[].ui_port` | integer | No | Defaults to `device.ui_port` or `80` |

#### Success response — `201 Created`

```json
{
  "connector": {
    "id": 42,
    "site_name": "My Workshop"
  },
  "credentials": {
    "secret": "spoolr_live_xxxxxxxxxxxxxxxxxxxxxxxx"
  },
  "printers": [
    { "id": 17, "name": "K1 Max" }
  ],
  "polling": {
    "commands_seconds": 3,
    "snapshots_seconds": 30
  }
}
```

> Store `credentials.secret` immediately — it is shown only once.

#### Error responses

| Status | Condition |
|---|---|
| `400 Bad Request` | `pairing_token` is missing or blank |
| `401 Unauthorized` | Token not found, already used, or expired |
| `422 Unprocessable Entity` | Printer record creation failed (validation error) |

#### curl example

```bash
curl -s -X POST https://your-spoolr-host/api/v1/connectors/register \
  -H "Content-Type: application/json" \
  -d '{
    "pairing_token": "your-pairing-token",
    "site_name": "My Workshop",
    "device": {
      "hostname": "my-connector",
      "arch": "amd64",
      "os": "linux",
      "version": "0.1.0",
      "ip": "192.0.2.50",
      "ui_port": 4409
    },
    "printers": [
      { "name": "Ender 3", "host": "192.0.2.61", "moonraker_port": 7125 }
    ]
  }'
```

---

### 2. Heartbeat

`POST /api/v1/connectors/:id/heartbeat`

**Authentication:** Bearer + `X-Connector-Id`.

Marks the connector as active and updates its device metadata. Call every 30–60 seconds.

The `:id` in the path must match the authenticated connector's own ID; mismatches return `401`.

#### Request body

```json
{
  "hostname": "K1Max-1814",
  "arch": "mipsle",
  "os": "linux",
  "version": "0.1.1"
}
```

All fields are optional.

#### Success response — `200 OK`

```json
{ "ok": true }
```

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing/invalid credentials, or `:id` does not match the authenticated connector |
| `422 Unprocessable Entity` | Validation failure on the connector record |

#### curl example

```bash
curl -s -X POST https://your-spoolr-host/api/v1/connectors/$CONNECTOR_ID/heartbeat \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID" \
  -H "Content-Type: application/json" \
  -d '{ "hostname": "my-connector", "version": "0.1.1" }'
```

---

### 3. Poll Commands

`GET /api/v1/connectors/:connector_id/commands`

**Authentication:** Bearer + `X-Connector-Id`.

Returns up to 20 queued commands for this connector. **Side effect:** all returned commands are immediately transitioned to `running` status.

#### Query parameters

| Parameter | Default | Notes |
|---|---|---|
| `limit` | `20` | Max `20`; values ≤ 0 reset to `20` |

#### Success response — `200 OK`

```json
[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "printer_id": 17,
    "action": "start_print",
    "params": { "filename": "benchy.gcode" }
  }
]
```

An empty array `[]` means no commands are pending.

#### Action enum

| Action | Description |
|---|---|
| `pause` | Pause the active print |
| `resume` | Resume a paused print |
| `cancel` | Cancel the active print |
| `start_print` | Start printing a file; params include `filename` |
| `sync_files` | Sync the file list from the printer |
| `upload_file` | Upload a G-code file to the printer |
| `delete_file` | Delete a file from the printer |
| `create_backup` | Create a printer configuration backup |
| `homing` | Home the printer axes |
| `import_history` | Import print history from the printer |

#### curl example

```bash
curl -s https://your-spoolr-host/api/v1/connectors/$CONNECTOR_ID/commands \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID"
```

---

### 4. Complete a Command

`POST /api/v1/commands/:id/complete`

**Authentication:** Bearer + `X-Connector-Id`.

Reports the outcome of a command. The `:id` is the command UUID returned by the poll endpoint.

> If the command does not belong to the authenticated connector, the server returns `404` rather than `403` — this is intentional to avoid existence disclosure.

#### Request body — succeeded

```json
{
  "status": "succeeded",
  "result": {
    "files_synced": 12
  }
}
```

#### Request body — failed

```json
{
  "status": "failed",
  "error_message": "Printer unreachable"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `status` | string | Yes | `"succeeded"` or `"failed"` |
| `result` | object | No | Arbitrary result data (on success) |
| `error_message` | string | No | Human-readable error detail (on failure) |

#### Success response — `200 OK`

```json
{ "ok": true }
```

#### Error responses

| Status | Condition |
|---|---|
| `404 Not Found` | Command not found or belongs to a different connector |
| `422 Unprocessable Entity` | `status` is not `"succeeded"` or `"failed"` |

#### curl example

```bash
curl -s -X POST https://your-spoolr-host/api/v1/commands/$COMMAND_ID/complete \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID" \
  -H "Content-Type: application/json" \
  -d '{ "status": "succeeded", "result": { "files_synced": 5 } }'
```

---

### 5. Poll Webcam Requests

`GET /api/v1/connectors/:id/webcam_requests`

**Authentication:** Bearer + `X-Connector-Id`.

Returns up to 10 pending webcam requests for this connector. Each entry represents a Spoolr user waiting for a live snapshot from a specific printer.

#### Success response — `200 OK`

```json
[
  {
    "id": 99,
    "printer_id": 17,
    "status": "pending",
    "requested_at": "2024-11-01T14:32:00.000Z"
  }
]
```

Results are ordered oldest-first so the connector can serve them in FIFO order.

#### curl example

```bash
curl -s https://your-spoolr-host/api/v1/connectors/$CONNECTOR_ID/webcam_requests \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID"
```

---

### 6. Upload Webcam Frame

`PUT /api/v1/webcam_requests/:id/upload`

**Authentication:** Bearer + `X-Connector-Id`.

Uploads a JPEG frame for a pending webcam request. The request body must be the raw image bytes.

#### Request

- **Content-Type:** `image/jpeg`
- **Body:** raw JPEG bytes

#### Success response — `200 OK`

```json
{
  "status": "success",
  "image_url": "https://your-spoolr-host/rails/active_storage/blobs/..."
}
```

#### Error responses

| Status | Condition |
|---|---|
| `403 Forbidden` | The webcam request belongs to a different connector |
| `404 Not Found` | Webcam request not found |
| `422 Unprocessable Entity` | Upload or record update failed |

#### curl example

```bash
curl -s -X PUT https://your-spoolr-host/api/v1/webcam_requests/$REQUEST_ID/upload \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID" \
  -H "Content-Type: image/jpeg" \
  --data-binary @/tmp/snapshot.jpg
```

---

### 6a. Live Webcam Stream (smooth feed)

The webcam-request flow above delivers one frame per request — fine for a periodic
snapshot, but a slideshow for live viewing. For a near-real-time feed, the connector
relays the printer's MJPEG stream while a browser is actively watching.

**Poll who is watching:**

`GET /api/v1/connectors/:id/webcam_stream` — Bearer + `X-Connector-Id`.

Returns the connector's own printers that currently have an active viewer. Empty
(the common case) when no one is watching, so polling it frequently is cheap.

```json
[ { "printer_id": 12, "expires_in_ms": 8000 } ]
```

For each printer listed, open its MJPEG stream (e.g. mjpg-streamer's
`http://<printer>:8080/?action=stream`) and relay frames until `expires_in_ms`
elapses. A subsequent poll that still lists the printer extends the window.

**Push a stream frame:**

`PUT /api/v1/printers/:id/webcam_stream_frame` — Bearer + `X-Connector-Id`.

Body is the raw JPEG bytes of one frame (`Content-Type: image/jpeg`). Only the
latest frame is kept (in cache, not durable storage); push at a capped cadence
(~8fps is plenty). Returns `204 No Content` on success, `403` if the printer
isn't owned by this connector, `422` on an empty body.

```bash
curl -s -X PUT https://your-spoolr-host/api/v1/printers/$PRINTER_ID/webcam_stream_frame \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID" \
  -H "Content-Type: image/jpeg" \
  --data-binary @/tmp/frame.jpg
```

---

### 7. Push Snapshot Batch

`POST /api/v1/snapshots/batch`

**Authentication:** Bearer + `X-Connector-Id`.

Submits a batch of telemetry snapshots for one or more printers. Each printer in the batch is marked `online` and its `last_seen_at`, `last_snapshot_at`, and (optionally) `moonraker_latency_ms` are updated.

All printers in the batch must belong to this connector's user. `captured_at` is required on every snapshot.

#### Request body

```json
{
  "snapshots": [
    {
      "printer_id": 17,
      "captured_at": "2024-11-01T14:32:00.000Z",
      "payload": {
        "result": {
          "status": {
            "extruder": { "temperature": 215.3, "target": 215.0 },
            "heater_bed": { "temperature": 60.1, "target": 60.0 }
          }
        },
        "normalized": {
          "nozzle_temp": 215.3,
          "bed_temp": 60.1,
          "nozzle_target": 215.0,
          "bed_target": 60.0,
          "print_progress": 0.42,
          "state": "printing"
        },
        "latency_ms": 18
      }
    }
  ]
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `snapshots` | array | Yes | Must be a non-empty array |
| `snapshots[].printer_id` | integer | Yes | Must belong to this connector's user |
| `snapshots[].captured_at` | string (ISO 8601) | Yes | RFC 3339 timestamp from the connector |
| `snapshots[].payload` | object | No | Arbitrary telemetry; structure below |
| `payload.result.status` | object | No | Raw Moonraker status by sensor name |
| `payload.result.status.<sensor>.temperature` | number | No | Current temperature in °C |
| `payload.normalized` | object | No | Canonical cross-protocol telemetry fields |
| `payload.normalized.nozzle_temp` | number | No | Nozzle temperature in °C |
| `payload.normalized.bed_temp` | number | No | Bed temperature in °C |
| `payload.normalized.nozzle_target` | number | No | Nozzle target temperature |
| `payload.normalized.bed_target` | number | No | Bed target temperature |
| `payload.normalized.print_progress` | number | No | 0.0 – 1.0 |
| `payload.normalized.state` | string | No | Printer state string |
| `payload.latency_ms` | integer | No | Round-trip latency to the printer |

#### Success response — `201 Created`

```json
{ "inserted": 1 }
```

#### Error responses

| Status | Condition |
|---|---|
| `400 Bad Request` | `snapshots` key missing or not an array |
| `422 Unprocessable Entity` | Any snapshot missing `captured_at`, or a `printer_id` not owned by this connector's user |

#### curl example

```bash
curl -s -X POST https://your-spoolr-host/api/v1/snapshots/batch \
  -H "Authorization: Bearer $SPOOLR_SECRET" \
  -H "X-Connector-Id: $CONNECTOR_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "snapshots": [
      {
        "printer_id": 17,
        "captured_at": "2024-11-01T14:32:00Z",
        "payload": {
          "normalized": { "nozzle_temp": 215.3, "bed_temp": 60.1, "state": "printing" },
          "latency_ms": 18
        }
      }
    ]
  }'
```

---

### 8. Upload Backup Archive

`POST /api/v1/backups/:id/upload`  
`PUT /api/v1/backups/:id/upload`

**Authentication:** Signed token — NOT the bearer scheme.

Accepts a `tar.gz` archive for the identified backup record. The backup must currently be in `queued` or `running` state; any other state returns `422`.

The signed token is a Rails `MessageVerifier` token containing the `backup_id` and an `expires_at` Unix timestamp. The server generates it when dispatching a backup command. Pass it as either:

- Query parameter: `?token=<signed_token>`
- Header: `X-Upload-Token: <signed_token>`

The request body must be the raw `tar.gz` bytes (no multipart encoding).

#### Request

- **Content-Type:** `application/gzip`
- **Body:** raw `tar.gz` bytes

#### Success response — `200 OK`

```json
{
  "ok": true,
  "backup_id": 7,
  "status": "available"
}
```

#### Error responses

| Status | Condition |
|---|---|
| `400 Bad Request` | No file data in the request body |
| `401 Unauthorized` | Token missing, invalid signature, expired, or does not match the backup ID |
| `404 Not Found` | Backup record not found |
| `422 Unprocessable Entity` | Backup is not in an uploadable state (`queued` or `running`) |
| `500 Internal Server Error` | Unexpected error during storage |

#### curl example

```bash
curl -s -X POST \
  "https://your-spoolr-host/api/v1/backups/$BACKUP_ID/upload?token=$UPLOAD_TOKEN" \
  -H "Content-Type: application/gzip" \
  --data-binary @/tmp/printer_backup.tar.gz
```

---

## Error Format

All error responses follow one of two envelopes.

**Single error:**

```json
{ "error": "message describing what went wrong" }
```

**Validation errors:**

```json
{
  "errors": {
    "field_name": ["error message one", "error message two"]
  }
}
```

### Common status codes

| Code | Meaning |
|---|---|
| `200 OK` | Request succeeded |
| `201 Created` | Resource created successfully |
| `400 Bad Request` | Required parameter missing or malformed input |
| `401 Unauthorized` | Missing, invalid, or expired credentials |
| `403 Forbidden` | Authenticated but not permitted to act on this resource |
| `404 Not Found` | Resource not found (also used instead of 403 in some cases to avoid existence disclosure) |
| `422 Unprocessable Entity` | Validation failed or resource is in an invalid state for the operation |
| `500 Internal Server Error` | Unexpected server-side error |

---

## Reference: Enum Values

### Connector status

| Value | Description |
|---|---|
| `online` | Connector has sent a heartbeat recently |
| `offline` | Connector has not sent a heartbeat within the expected window |

### Printer status

| Value | Description |
|---|---|
| `online` | Printer has sent a snapshot recently |
| `offline` | Printer has not sent data within the expected window |
| `unknown` | Initial state after pairing; no data received yet |
| `error` | Printer is reporting an error condition |

### Command status

| Value | Description |
|---|---|
| `queued` | Command is waiting to be picked up by the connector |
| `running` | Command has been polled and is in progress |
| `succeeded` | Connector reported successful completion |
| `failed` | Connector reported a failure |

### Webcam request status

| Value | Description |
|---|---|
| `pending` | Waiting for the connector to upload a frame |
| `completed` | Frame uploaded successfully |
| `failed` | Upload failed |
| `expired` | Request timed out before a frame was received |

---

## Machine-Readable Access

This reference is also available as raw Markdown for tools and AI agents.

Append `.md` to this URL: `/docs/api.md`

The raw Markdown is served directly from the source file without any HTML processing, making it suitable for ingestion by language models, documentation generators, and automated integration tests.
