API Reference

Everything the VonLinkage API can do

Every response is enveloped as { "success": true, "data": ... } or { "success": false, "error": ... }. Every endpoint below except /health* requires a service token — pass it once and forget about it.

Every code sample on this page is live — pick a language, fill in the fields, and hit Try it to send a real request to a running VonLinkage instance and see the actual response. Start here, no setup required:

⬇ Download vonlinkage-docs.md

Drop this file into Claude, ChatGPT, or your AI coding assistant of choice and have it write the integration for you.

ping.sh
curl -X GET localhost:3000/health \
  -H "Authorization: Bearer $TOKEN"

🔑 Authentication

VonLinkage uses two different tokens, and confusing them is the main way to get this wrong.

Service token

Authorization: Bearer <jwt>
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

Returned by POST /rooms/{room}/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
⚠️The guard is global. A new endpoint is authenticated by default — forgetting to add auth fails closed, not open.

Minting a service token

Your backend signs its own service token with the shared secret. Example (Node.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.

RolePublishSubscribeData
doctor / patient / nurse / staff / interpreterYesYesYes
observerNoYesNo

🏠 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.

Example response

{
  "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.

create-room.sh
curl -X POST localhost:3000/rooms \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"roomName":"appointment-123","expiresIn":3600,"maxParticipants":4}'

GET /rooms/{roomName}

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

get-room.sh
curl -X GET localhost:3000/rooms/appointment-123 \
  -H "Authorization: Bearer $TOKEN"

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 — a stored copy would disagree with LiveKit the moment someone joins.
end-room.sh
curl -X DELETE localhost:3000/rooms/appointment-123 \
  -H "Authorization: Bearer $TOKEN"

🎟️ Join tokens

POST /rooms/{roomName}/token

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

FieldRequiredNotes
identityYesStable handle for this user, 1–128 chars. Unique within a room.
roleYesOne of the roles above — decides publish/subscribe/data grants.
nameNoDisplay 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.

issue-token.sh
curl -X POST localhost:3000/rooms/appointment-123/token \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"identity":"doctor-1","role":"doctor","name":"Dr John"}'

👥 Participants

GET /rooms/{roomName}/participants

Point-in-time snapshot of who is connected.

Example response

{
  "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.

list-participants.sh
curl -X GET localhost:3000/rooms/appointment-123/participants \
  -H "Authorization: Bearer $TOKEN"

💬 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.

FieldRequiredNotes
senderIdentityYes1–128 chars. Recorded verbatim — not cross-checked against who's actually connected.
senderRoleNoFree text, stored as-is.
bodyYes1–4000 chars. Treated as PHI throughout the system.

Example response

{
  "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.

send-message.sh
curl -X POST localhost:3000/rooms/appointment-123/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"senderIdentity":"dr-smith","senderRole":"doctor","body":"Your results look good. Any questions before we finish?"}'

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 paramRequiredNotes
limitNo1–200, default 50.
beforeNoCursor from a previous response's nextBefore. Omit to start from the newest page.

Example response

{
  "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).

get-history.sh
curl -X GET localhost:3000/rooms/appointment-123/messages \
  -H "Authorization: Bearer $TOKEN"

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.

MethodPathPurpose
POST/rooms/{roomName}/recordingsStart recording (one active at a time).
GET/rooms/{roomName}/recordingsList a room's recordings, including history.
GET/recordings/{recordingId}Status, timing, and output — read live.
GET/recordings/{recordingId}/downloadPresigned, time-limited download URL.
POST/recordings/{recordingId}/stopStop an active recording.
DELETE/recordings/{recordingId}Delete metadata only — the file is retained.

POST /rooms/{roomName}/recordings

Starts a room-composite recording. Refused with 409 RECORDING_ALREADY_ACTIVE if one is already running.

start-recording.sh
curl -X POST localhost:3000/rooms/appointment-123/recordings \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"layout":"grid","audioOnly":false}'

GET /recordings/{recordingId}

Status, timing, and output — read live from the recorder, never cached.

get-recording.sh
curl -X GET localhost:3000/recordings/ \
  -H "Authorization: Bearer $TOKEN"
Fill in every field to send a request.

POST /recordings/{recordingId}/stop

Stops an active recording. 409 RECORDING_NOT_ACTIVE if it already finished.

stop-recording.sh
curl -X POST localhost:3000/recordings//stop \
  -H "Authorization: Bearer $TOKEN"
Fill in every field to send a request.

GET /recordings/{recordingId}/download

A presigned, time-limited download URL for the finished file. Only once COMPLETED.

download-recording.sh
curl -X GET localhost:3000/recordings//download \
  -H "Authorization: Bearer $TOKEN"
Fill in every field to send a request.

DELETE /recordings/{recordingId}

Deletes metadata only — the stored file is retained. Refuses while the recording is still active.

delete-recording.sh
curl -X DELETE localhost:3000/recordings/ \
  -H "Authorization: Bearer $TOKEN"
Fill in every field to send a request.

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

{
  "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).

get-transcript.sh
curl -X GET localhost:3000/recordings//transcript \
  -H "Authorization: Bearer $TOKEN"
Fill in every field to send a request.

🔔 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

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.

CodeStatusMeaning
VALIDATION_FAILED400Bad body or path — details lists the fields.
UNAUTHENTICATED401Missing, malformed, or expired service token.
ROOM_NOT_FOUND404No such room. Create it first.
PARTICIPANT_NOT_FOUND404No such participant in that room.
ROOM_FULL409The room is at its participant cap.
ROOM_EXPIRED410Past its deadline; admits nobody.
IDEMPOTENCY_KEY_REUSED409Same Idempotency-Key sent again with a different body.
RECORDING_NOT_FOUND404No such recording.
RECORDING_ALREADY_ACTIVE409The room already has one running.
RECORDING_NOT_ACTIVE409Already finished — nothing to stop.
RECORDING_NOT_DOWNLOADABLE409Not finished yet — poll until COMPLETED.
RECORDING_ARTEFACT_PURGED410Retention already deleted the file.
TRANSCRIPT_NOT_AVAILABLE409The recording itself hasn't finished.
TRANSCRIPT_NOT_READY409Recording done; transcription isn't yet.
TRANSCRIPT_FAILED410Failed at the vendor — won't retry.
PROVIDER_UNAVAILABLE502LiveKit is unreachable. Your request was fine.
INTERNAL_ERROR500A bug — report it with the correlation id.
{
  "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.