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.mdDrop this file into Claude, ChatGPT, or your AI coding assistant of choice and have it write the integration for you.
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
- 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_tokenwhen the client connects to LiveKit- Lifetime
- Short-lived — default 5 minutes
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.
| 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.
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.
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.
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.
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.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.
| 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.
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.
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.
| 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
{
"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.
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 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
{
"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).
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.
| 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. Refused with 409 RECORDING_ALREADY_ACTIVE if one is already running.
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.
curl -X GET localhost:3000/recordings/ \
-H "Authorization: Bearer $TOKEN"POST /recordings/{recordingId}/stop
Stops an active recording. 409 RECORDING_NOT_ACTIVE if it already finished.
curl -X POST localhost:3000/recordings//stop \
-H "Authorization: Bearer $TOKEN"GET /recordings/{recordingId}/download
A presigned, time-limited download URL for the finished file. Only once COMPLETED.
curl -X GET localhost:3000/recordings//download \
-H "Authorization: Bearer $TOKEN"DELETE /recordings/{recordingId}
Deletes metadata only — the stored file is retained. Refuses while the recording is still active.
curl -X DELETE localhost:3000/recordings/ \
-H "Authorization: Bearer $TOKEN"Lifecycle
STARTING ──▶ RECORDING ──▶ STOPPING ──▶ COMPLETED
└──────────┴──────────────┴────────▶ FAILEDIntegrity & 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).
curl -X GET localhost:3000/recordings//transcript \
-H "Authorization: Bearer $TOKEN"🔔 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.
| 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. |
{
"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.