# Arctop Developer API

Read access to Arctop recording sessions, their score outputs and marker events, a live event stream, and marker injection into running sessions.

All paths are relative to the origin this document was fetched from. The versioned base is `/api/dev/v1`. This reference is complete: a path not listed here does not exist.

## Authentication

Send a developer API key with every request, as an `Authorization: Bearer <key>` header. The header is the only accepted credential transport: keys are not read from cookies, query parameters, or WebSocket subprotocols.

Keys begin with `ak_` and are created on the dashboard at [`/dashboard/developers/keys`](/dashboard/developers/keys). A key is shown once, at creation, and stored as a hash; it cannot be retrieved again. A developer key authenticates on `/api/dev/v1` only, so it cannot sign in to the dashboard or manage keys.

## Key scope

A key is either **personal** or **group**; `GET /me` reports which.

A personal key reads its owner's data and nothing else. A group key reads the data of its developer group's members, under two distinct rules:

- **Historical reads** (sessions, outputs, markers) follow the stamp. The developer group is recorded on a session once, at session start, from the streaming user's active membership at that moment. A session recorded before a user joined is invisible to the group; a session recorded during membership stays readable after they leave. The key owner's own unstamped sessions are also invisible to a group key.
- **Live access** (the roster, the realtime socket, and targeted marker injection) follows current membership, re-read about every 2 seconds on an open socket. Joining grants live access without a reconnect. Leaving ends it mid-socket; that member's final frame is a `stream_state` of `out_of_scope`.

`user_id` query parameters always narrow the scope and never widen it. On a group key, naming a user outside the group yields an empty page, or silence on the socket. On a personal key, naming anyone but the owner is a 403 `outside_key_scope`.

## Endpoints

### GET /api/dev/v1/me

Identifies the key: `key_id`, `label`, `display_prefix`, `scope` (`personal` or `group`), `dev_group_id`, and the owner's `user_id`, `email`, and `name`. Use it to verify that a key works.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/me"
```

```json
{"key_id":"<uuid>","label":"analysis script","display_prefix":"ak_3f2a8c91",
 "scope":"personal","dev_group_id":null,
 "owner":{"user_id":"<uuid>","email":"dev@example.com","name":"Dev User"}}
```

### GET /api/dev/v1/sessions

Lists recording sessions in the key's scope, newest first. Every row carries `user_id`, on personal and group keys alike.

Query parameters:

- `user_id`: filter to one user (repeatable rules above).
- `eeg_device_type`: filter by device type.
- `status`: `active`, `completed`, or `error`.
- `from`, `to`: an RFC 3339 instant or a bare `YYYY-MM-DD` day.
- `page`, `per_page`: pagination; `per_page` defaults to 25, maximum 100.
- `sort`: `started_at`, `duration_seconds`, `packet_count`, or `eeg_device_type`.
- `order`: `asc` or `desc`.

An unrecognized `sort`, `order`, `page`, `per_page`, `from`, or `to` value falls back to its default instead of failing the request.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/sessions?status=completed&per_page=10"
```

### GET /api/dev/v1/sessions/{id}

One session: the list shape plus `agent_version`, `error_message`, and `output_file_count`.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/sessions/<session-id>"
```

### GET /api/dev/v1/sessions/{id}/outputs

Lists the per-paradigm score CSVs that apps wrote for the session: `filename`, `app_name`, `paradigm`, `size_bytes`, `created_at`, and a ready-made `download_url`. Raw EEG is not reachable through this API.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/sessions/<session-id>/outputs"
```

### GET /api/dev/v1/sessions/{id}/outputs/{filename}

Downloads one score CSV (`text/csv`, sent as an attachment). On a group key the disclosure is recorded before the first byte is written; if it cannot be recorded, the file is not served.

```bash
curl -OJ -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/sessions/<session-id>/outputs/<filename>"
```

### GET /api/dev/v1/sessions/{id}/markers

Marker events for the session, ordered by `timestamp` ascending. Timestamps are epoch milliseconds on the emitting client's clock. Only `user_marker` (the participant's own markers), `wear_state` (device on/off head), and `dev_marker` (markers injected through this API) rows are returned; no other event type is returned. Reads on a group key are recorded.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/sessions/<session-id>/markers"
```

### POST /api/dev/v1/sessions/current/markers

Stamps a marker into one or more currently live recordings.

Request body:

- `event_type` (required)
- `label` (optional)
- `timestamp` (optional): epoch milliseconds; defaults to arrival time and is stored exactly as supplied.

The stored row has `app_id` `dev_api`, `event_type` `dev_marker`, and label `event_type:label`. `dev_marker` distinguishes a marker stamped in through this API from a `user_marker` the participant left themselves; both kinds read back through `GET .../markers`. The composed label must be valid UTF-8, contain no control characters, and be at most 256 bytes; invalid text is rejected, never rewritten.

```bash
curl -X POST -H "Authorization: Bearer $ARCTOP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type":"stimulus","label":"face-07"}' \
  "https://<origin>/api/dev/v1/sessions/current/markers"
```

**Untargeted** (no query parameters): the marker lands in the key owner's own live recording. The 201 response carries the `session_id` it landed in and the stored `marker` row byte for byte, so the response is exactly what `GET .../markers` will later return. Injection while the owner is not streaming is a 404 `no_active_session`.

**Targeted** (one or more repeatable `user_id` query parameters): the marker is stamped into each named user's live recording. On a group key a named user must be a current group member; on a personal key naming anyone but the owner is a 403 `outside_key_scope`. Duplicate ids collapse to one write. The response is a 200:

```json
{"marker": {"timestamp":1750000000000,"app_id":"dev_api",
            "event_type":"dev_marker","label":"stimulus:face-07"},
 "results": [
   {"user_id":"<uuid>","status":"written","session_id":"<uuid>"},
   {"user_id":"<uuid>","status":"skipped"}
 ]}
```

Targets succeed independently, in request order: `written` names the recording the marker landed in, and `skipped` means that user has no live recording in this key's scope. A user who is not streaming and a user who is not a current member are deliberately the same `skipped`, so the endpoint cannot be used to probe the roster. Every target skipped is still a 200.

On a group key, each `written` target is recorded in the audit trail (action `marker_inject`, carrying the composed label) before the marker is written; if a record cannot be written the whole request answers 500 and no marker is stamped anywhere.

### GET /api/dev/v1/group/members

Group keys only; a personal key is refused with 403 `group_key_required`. Returns the current roster of the group the key acts for: `id`, `email`, `name`, `role` (`admin` or `member`), and `joined_at`. Never cached, and the read is recorded before the response is composed.

```bash
curl -H "Authorization: Bearer $ARCTOP_API_KEY" \
  "https://<origin>/api/dev/v1/group/members"
```

### GET /api/dev/v1/docs

This reference, as plain markdown (`text/markdown`). The only path that answers without a key.

### GET /api/dev/v1/realtime

WebSocket endpoint; see the next section. A plain GET without the upgrade headers answers 426 `upgrade_required`.

## Realtime WebSocket

Connect to `wss://<origin>/api/dev/v1/realtime` with a client that can set request headers; the key travels in `Authorization`, which browsers cannot set on a WebSocket handshake.

Connect once, at the start of the experiment, and wait. A valid key always gets a socket: it reports `stream_state` for every user in scope immediately and again on every change, then delivers live events as they arrive. Connecting while nothing is streaming is normal and supported, so no retry loop is needed. Narrow the scope with a repeatable `?user_id=` filter.

Every frame is a JSON object with a `type`. Switch on `type` and nothing else: the set may grow, but existing types never change meaning. The socket is read-only; any client frame is answered with a non-fatal `error` frame and the connection stays up.

```json
{"type":"stream_state","user_id":"<uuid>","state":"idle",
 "streamer_running":false,"attached":false,"session_id":"","timestamp":1750000000000}

{"type":"event","user_id":"<uuid>","session_id":"<uuid>","timestamp":1750000000123,
 "event":{"type":"capability_output", ...}}

{"type":"error","error":"unsupported_client_message","message":"..."}

{"type":"closing","code":4401,"reason":"api_key_revoked","message":"..."}
```

### stream_state

The first frame on every connection, once per user in scope, re-sent whenever any field changes.

- `streamer_running`: whether the streamer, the platform's inference engine for that user, is running at all.
- `attached`: whether this server has a live subscription to that streamer's output; `state` is `active` exactly when this is true.
- `session_id`: the recording those events belong to right now.
- `state`: one of the values below.

| State | Meaning |
| --- | --- |
| `active` | This server is attached to the user's running streamer and events are flowing to your socket. |
| `idle` | Everything else: no streamer running, or one this server has not attached to yet. A normal state, not an error. |
| `out_of_scope` | Terminal for that user on a group socket: they left the group, or your `?user_id=` filter stopped naming them. No further frame carries that `user_id` unless they return. |

### Events

On an `event` frame, `timestamp` is when this server received the frame, in epoch milliseconds. The streamer's own clock stays inside `event`; the two are not interchangeable.

Scores arrive inside `event` as type `capability_output`, exactly as the streamer emits them. The subscription is strictly read-only: it never starts the streamer and never activates a paradigm, so you see the paradigms already running for that user, and nothing else.

If your socket falls behind, the loss is reported in-band as an event of type `devstream_gap`, delivered immediately ahead of the first surviving event. A hole is never silent.

### Close codes

The socket closes with a private-range code (4000 to 4999) that echoes the corresponding HTTP status, preceded by a `closing` frame carrying the same numbers. Key off the numeric code, never the reason text.

| Code | Reason | Meaning |
| --- | --- | --- |
| 4401 | the matching 401 code | The key stopped authenticating mid-connection: revoked, expired, owner deactivated, or deleted. The key is re-checked about every 30 seconds. |
| 4403 | `dev_group_unavailable` | The key still authenticates, but its developer group stopped authorizing: suspended, or gone. Re-checked about every 2 seconds on a group socket. |
| 4408 | `keepalive_timeout` | No pong, and no other inbound frame, arrived in time. The server pings every 30 seconds and expects something back within 60. |
| 4429 | `slow_consumer` | This connection fell far enough behind to exhaust its buffer. Read faster, or do less work on the socket loop. Reconnecting immediately is legal. |
| 4500 | `internal_error` | The subscription ended for an unmodeled reason. Retryable. |
| 4503 | `server_shutting_down` | This server is shutting realtime down. Reconnect with backoff. |
| 1000 | `closed` | Normal closure; nothing to report. |

Code 4409 is not part of this list. It belongs to the streaming connection a recording device holds: the platform allows one active streaming session per user, resolved newest wins, so a second device preempts the first and the first is closed 4409, which is terminal for that device. Your realtime socket sees the same handover as a new `session_id` in `stream_state`, never as a close.

## Errors

Every non-2xx response body is `{"error": "<code>", "message": "<human text>"}`. Branch on `error`; it is a stable machine-readable value, and the message is not.

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `missing_api_key` | No `Authorization: Bearer` credential on the request. |
| 401 | `invalid_api_key` | The bearer value is not a developer key (it does not begin with `ak_`), or no key matches it. Both cases answer identically, so the surface cannot be used to probe whether a key exists. |
| 401 | `api_key_revoked` | The key was revoked. Revocation is immediate: the key is re-checked on every request. |
| 401 | `api_key_expired` | The key is past its `expires_at`. |
| 401 | `api_key_owner_inactive` | The key is valid, but the account it acts as has been deactivated. |
| 403 | `dev_group_unavailable` | The key resolved, but its developer group is suspended or gone. Deliberately not a 401: the key works, its group currently authorizes nothing. Re-resolved per request. |
| 403 | `group_key_required` | A personal key on a group-only route. |
| 403 | `outside_key_scope` | A personal key named someone other than its owner in `user_id`. |
| 400 | `invalid_session_id` | The `{id}` path segment is not a UUID. |
| 400 | `invalid_user_id` | A `user_id` value is not a UUID. |
| 400 | `invalid_request_body` | The marker body is not a JSON object. |
| 400 | `invalid_marker` | `event_type` is missing or blank, `timestamp` is not positive, or the composed label is over 256 bytes, not valid UTF-8, or carries a control character. |
| 404 | `session_not_found` | No session with that id inside this key's scope. A session the key cannot read is indistinguishable from one that never existed. |
| 404 | `output_not_found` | No output file of that name for that session, or the file is no longer readable on the server. |
| 404 | `no_active_session` | Untargeted marker injection while nothing is recording for the key owner. The targeted form answers 200 with per-user `skipped` instead. |
| 426 | `upgrade_required` | `GET /realtime` without a WebSocket upgrade handshake. |
| 429 | none | Rate limit exceeded; see below. The body is `{"error": "Too many requests"}` with no machine-readable code. |
| 500 | `internal_error` | The server failed. Also the answer when a group-key read cannot be recorded in the audit trail, in which case nothing was served. |
| 503 | `realtime_unavailable` | Realtime is not available on this server right now, or is shutting down. Retryable. |
| 503 | `live_write_unavailable` | Marker injection is not available on this server right now. Retryable. |

## Rate limits

The whole `/api/dev` namespace shares a per-IP budget of 300 requests per minute with a burst of 60. The 429 response comes from a shared limiter and carries no machine-readable code.

## Group read auditing

A group key touching another member's data leaves an append-only record naming the key, its owner, the member, the session, and the action. The recorded actions today are `member_list`, `session_markers`, `output_download`, `realtime_attach`, and `marker_inject`; the list grows. The last of these is a write, stamping a marker into a member's recording, and its record carries the composed label. The record is written before the response bytes: a read whose record cannot be written answers 500 and serves nothing, and a marker injection whose record cannot be written stamps nothing. Personal keys are not recorded, because reading your own data through your own key discloses nothing to anyone else.

## Compatibility

Paths and field names in this document are a contract. A field may be added, never renamed or removed. A breaking change ships under a new version prefix.
