# VonLinkage API Reference

This document is a plain-text mirror of the VonLinkage API docs, meant to be pasted into
Claude, ChatGPT, or any other AI coding assistant so it can help you integrate against the
VonLinkage API without needing to browse the website.

Every response is enveloped as `{ "success": true, "data": ... }` or
`{ "success": false, "error": ... }`. Every endpoint below except `/health*` requires a
service token.

## Authentication

VonLinkage uses two different tokens:

### Service token

- Signed by: your backend, with a shared secret (HS256)
- Verified by: VonLinkage
- Proves: which system is calling
- Sent as: `Authorization: Bearer <jwt>` on every request
- Lifetime: your choice — keep it short

### Join token

- Signed by: VonLinkage, with the LiveKit API secret
- Verified by: LiveKit
- Proves: which person may enter which room, with which role
- Sent as: `access_token` when the client connects to LiveKit
- Lifetime: short-lived — default 5 minutes
- Returned by: `POST /rooms/{room}/token`

The auth guard is global — a new endpoint is authenticated by default, so forgetting to add
auth fails closed, not open.

### Minting a service token (Node.js)

```js
const jwt = require('jsonwebtoken');

const token = jwt.sign({ sub: 'acme-api' }, process.env.JWT_SECRET, {
  expiresIn: '60s',
  algorithm: 'HS256',
});

// Authorization: Bearer <token>
```

### Roles & grants

Roles decide permissions — callers never do. A caller cannot request more than its role allows.

| Role                                                     | Publish | Subscribe | Data |
| -------------------------------------------------------- | ------- | --------- | ---- |
| `doctor` / `patient` / `nurse` / `staff` / `interpreter` | Yes     | Yes       | Yes  |
| `observer`                                               | No      | Yes       | No   |

## Rooms

Rooms are private and cannot be conjured — LiveKit runs with `auto_create: false`, so a room
must be created here before anyone can join it.

### `POST /rooms`

Creates a room. Idempotent — creating one that already exists returns the existing room.

Body: `roomName` (required), `expiresIn` seconds (optional), `maxParticipants` (optional).

Example response:

```json
{
  "success": true,
  "data": {
    "roomId": "RM_z9EcStHbLmvJ",
    "roomName": "appointment-123",
    "joinUrl": "ws://localhost:7880/?room=appointment-123",
    "numParticipants": 0,
    "maxParticipants": 4
  }
}
```

Errors: `400 VALIDATION_FAILED`, `502 PROVIDER_UNAVAILABLE`.

### `GET /rooms/{roomName}`

Read straight from LiveKit, so it's never stale.
Errors: `404 ROOM_NOT_FOUND`, `502 PROVIDER_UNAVAILABLE`.

### `DELETE /rooms/{roomName}`

Destroys the room and disconnects everyone in it. Terminal and honest — a later `GET` returns
`404`. Errors: `404 ROOM_NOT_FOUND`.

Room state (`CREATED` / `ACTIVE` / `EXPIRED` / `ENDED`) is derived from provider truth on
every read, never stored.

## Join tokens

### `POST /rooms/{roomName}/token`

Issues a short-lived credential admitting one identity to one room.

| Field      | Required | Notes                                                           |
| ---------- | -------- | --------------------------------------------------------------- |
| `identity` | Yes      | Stable handle for this user, 1–128 chars. Unique within a room. |
| `role`     | Yes      | One of the roles above — decides publish/subscribe/data grants. |
| `name`     | No       | Display name shown to others. Falls back to identity.           |

Issue one token per participant. A second connection with the same identity evicts the
first — sharing a token means two people fighting over one seat.

Errors: `400 VALIDATION_FAILED`, `404 ROOM_NOT_FOUND`, `410 ROOM_EXPIRED`,
`502 PROVIDER_UNAVAILABLE`.

## Participants

### `GET /rooms/{roomName}/participants`

Point-in-time snapshot of who is connected.

Example response:

```json
{
  "success": true,
  "data": [
    {
      "participantId": "PA_9xKq2mVnR4tY",
      "identity": "doctor-1",
      "state": "joined",
      "isPublisher": true
    }
  ]
}
```

`state` is one of `joining`, `joined`, `active`, `disconnected`.
Errors: `404 ROOM_NOT_FOUND`, `502 PROVIDER_UNAVAILABLE`.

## Chat

Text alongside the call. Sending is REST + best-effort realtime: a message is durably stored
first, then published on the room's LiveKit data channel (topic `vonlinkage.chat`) so anyone
already connected receives it immediately. There is no separate chat WebSocket or SSE endpoint —
a client that reconnects or joined late calls `GET .../messages` to catch up.

### `POST /rooms/{roomName}/messages`

Sends a chat message. The room must exist and still be joinable.

| Field            | Required | Notes                                                                                |
| ---------------- | -------- | ------------------------------------------------------------------------------------ |
| `senderIdentity` | Yes      | 1–128 chars. Recorded verbatim — not cross-checked against who's actually connected. |
| `senderRole`     | No       | Free text, stored as-is.                                                             |
| `body`           | Yes      | 1–4000 chars. Treated as PHI throughout the system.                                  |

Example response:

```json
{
  "success": true,
  "data": {
    "messageId": "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
    "roomName": "appointment-123",
    "senderIdentity": "dr-smith",
    "senderRole": "doctor",
    "body": "Your results look good. Any questions before we finish?",
    "sentAt": "2026-07-20T10:31:00.000Z",
    "delivered": true
  }
}
```

`delivered` reflects only whether a live listener was reached over the data channel right now —
the message is stored either way and will appear in history regardless. Send an
`Idempotency-Key` header to make a retried send safe to repeat.

Errors: `400 VALIDATION_FAILED`, `404 ROOM_NOT_FOUND`, `410 ROOM_EXPIRED`,
`409 IDEMPOTENCY_KEY_REUSED`.

### `GET /rooms/{roomName}/messages`

Reads chat history. Unlike sending, this does **not** require the room to still exist — chat is
stored independently of LiveKit, so history keeps answering after the room itself has been
deleted.

| Query param | Required | Notes                                                                               |
| ----------- | -------- | ----------------------------------------------------------------------------------- |
| `limit`     | No       | 1–200, default 50.                                                                  |
| `before`    | No       | Cursor from a previous response's `nextBefore`. Omit to start from the newest page. |

Example response:

```json
{
  "success": true,
  "data": {
    "messages": [
      {
        "messageId": "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
        "roomName": "appointment-123",
        "senderIdentity": "dr-smith",
        "senderRole": "doctor",
        "body": "Your results look good. Any questions before we finish?",
        "sentAt": "2026-07-20T10:31:00.000Z"
      }
    ],
    "nextBefore": null
  }
}
```

Each page's `messages` are ordered oldest-first. Pass `nextBefore` back as `before` to page
further into the past; `null` means there's nothing older left.

Errors: `400 VALIDATION_FAILED` (bad `limit`/`before`).

### Retention

Off by default (`CHAT_RETENTION_DAYS` unset). Once enabled, a background sweep **permanently
deletes** messages older than the configured window — unlike recordings/transcripts, there is no
tombstone or purge-specific error code. A purged message simply stops appearing in
`GET .../messages`, indistinguishable from a message that was never sent.

## Recording

VonLinkage never touches recording bytes. It hands the job to LiveKit Egress, which composes
the room and uploads the finished file straight to S3-compatible storage.

| Method | Path                                 | Purpose                                      |
| ------ | ------------------------------------ | -------------------------------------------- |
| POST   | `/rooms/{roomName}/recordings`       | Start recording (one active at a time).      |
| GET    | `/rooms/{roomName}/recordings`       | List a room's recordings, including history. |
| GET    | `/recordings/{recordingId}`          | Status, timing, and output — read live.      |
| GET    | `/recordings/{recordingId}/download` | Presigned, time-limited download URL.        |
| POST   | `/recordings/{recordingId}/stop`     | Stop an active recording.                    |
| DELETE | `/recordings/{recordingId}`          | Delete metadata only — the file is retained. |

- `POST /rooms/{roomName}/recordings` — starts a room-composite recording. Body:
  `layout` (`grid` | `speaker` | `single-speaker`, optional), `audioOnly` (optional).
  Refused with `409 RECORDING_ALREADY_ACTIVE` if one is already running.
- `GET /recordings/{recordingId}` — status, timing, and output, read live from the recorder,
  never cached.
- `POST /recordings/{recordingId}/stop` — stops an active recording.
  `409 RECORDING_NOT_ACTIVE` if it already finished.
- `GET /recordings/{recordingId}/download` — a presigned, time-limited download URL for the
  finished file. Only once `COMPLETED`.
- `DELETE /recordings/{recordingId}` — deletes metadata only, the stored file is retained.
  Refuses while the recording is still active.

### Lifecycle

```
STARTING ──▶ RECORDING ──▶ STOPPING ──▶ COMPLETED
    └──────────┴──────────────┴────────▶ FAILED
```

### Integrity & retention

A background sweep verifies every finished recording actually exists in storage at the
reported size — no API call needed. Retention purge is off by default; once enabled, files
older than the configured window are permanently deleted and `GET .../download` starts
returning `410 RECORDING_ARTEFACT_PURGED`.

## Transcript

Batch transcription of a finished recording via AWS Transcribe Medical — not live streaming.
Starts automatically once a recording reaches `COMPLETED`.

### `GET /recordings/{recordingId}/transcript`

Example response:

```json
{
  "success": true,
  "data": {
    "recordingId": "EG_bx3kqYPmVJnR",
    "fullText": "Let's go ahead and get started...",
    "segments": [
      {
        "speakerLabel": "spk_0",
        "text": "Let's go ahead and get started.",
        "startedAtSeconds": 0,
        "confidence": 0.97
      }
    ]
  }
}
```

`speakerLabel` is vendor-assigned diarization (`spk_0`, `spk_1`), not a real participant
identity. Correlate with `GET /rooms/{roomName}/participants` if you need "who said this".

Errors: `404 RECORDING_NOT_FOUND`, `409 TRANSCRIPT_NOT_AVAILABLE` (recording not finished),
`409 TRANSCRIPT_NOT_READY` (transcription in progress), `410 TRANSCRIPT_FAILED` (vendor
failure, won't retry).

## Webhooks

Outbound delivery only, to a single configured receiver. VonLinkage pushes
`recording.completed`, `recording.failed`, `transcript.completed`, and `transcript.failed`.
No transcript text and no PHI of any kind ever leaves this process via a webhook.

```
POST <your endpoint>
Content-Type: application/json
X-VonLinkage-Signature: sha256=<hex>
X-VonLinkage-Delivery: 3f1e2a4b-7c9d-4e5f-8a1b-2c3d4e5f6a7b
X-VonLinkage-Event: recording.completed

{
  "id": "3f1e2a4b-...",
  "type": "recording.completed",
  "data": { "recordingId": "EG_bx3kqYPmVJnR", "status": "COMPLETED" }
}
```

### Verifying the signature

```js
const crypto = require('node:crypto');

function isValid(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

`X-VonLinkage-Delivery` may repeat on retry — dedupe on it. Failed deliveries retry on a
fixed interval up to a configured attempt cap, then stop.

## Error codes

Branch on `error.code`, never on `message` — messages may be reworded between releases.

| Code                         | Status | Meaning                                                  |
| ---------------------------- | ------ | -------------------------------------------------------- |
| `VALIDATION_FAILED`          | 400    | Bad body or path — details lists the fields.             |
| `UNAUTHENTICATED`            | 401    | Missing, malformed, or expired service token.            |
| `ROOM_NOT_FOUND`             | 404    | No such room. Create it first.                           |
| `PARTICIPANT_NOT_FOUND`      | 404    | No such participant in that room.                        |
| `ROOM_FULL`                  | 409    | The room is at its participant cap.                      |
| `ROOM_EXPIRED`               | 410    | Past its deadline; admits nobody.                        |
| `IDEMPOTENCY_KEY_REUSED`     | 409    | Same `Idempotency-Key` sent again with a different body. |
| `RECORDING_NOT_FOUND`        | 404    | No such recording.                                       |
| `RECORDING_ALREADY_ACTIVE`   | 409    | The room already has one running.                        |
| `RECORDING_NOT_ACTIVE`       | 409    | Already finished — nothing to stop.                      |
| `RECORDING_NOT_DOWNLOADABLE` | 409    | Not finished yet — poll until COMPLETED.                 |
| `RECORDING_ARTEFACT_PURGED`  | 410    | Retention already deleted the file.                      |
| `TRANSCRIPT_NOT_AVAILABLE`   | 409    | The recording itself hasn't finished.                    |
| `TRANSCRIPT_NOT_READY`       | 409    | Recording done; transcription isn't yet.                 |
| `TRANSCRIPT_FAILED`          | 410    | Failed at the vendor — won't retry.                      |
| `PROVIDER_UNAVAILABLE`       | 502    | LiveKit is unreachable. Your request was fine.           |
| `INTERNAL_ERROR`             | 500    | A bug — report it with the correlation id.               |

Example error response:

```json
{
  "success": false,
  "error": {
    "code": "ROOM_NOT_FOUND",
    "message": "Room \"appointment-123\" was not found",
    "correlationId": "8f4c1f3e-7b1a-4a5e-9b2f-0c9d1e2a3b4c",
    "path": "/rooms/appointment-123"
  }
}
```

## Correlation IDs

Send `x-correlation-id` to have a trace followed across services — one is generated when
absent. It's echoed on every response and appears in every log line for that request, so
quote it in bug reports.
