livqenoDocs

AILive Transcription & Captions

Live Transcription & Captions

Caption every speaker in a room in real time, and keep the transcript as JSON, WebVTT or plain text.

Live transcription turns the speech in an RTC room into captions while the call happens. Every client in the room receives the captions over the connection it already has, and Livqeno can keep the final text as a transcript you fetch afterwards.

Where this fits: your backend already creates rooms and mints tokens (Rooms & Participants). Starting a transcription is one more call from your backend. Your app only listens for caption events.

How it works

  • Livqeno adds a hidden transcriber participant to the room, named transcriber-<id>. It subscribes to audio only and never publishes.
  • The transcriber streams each speaker's audio to a speech-to-text provider: Deepgram by default, or OpenAI.
  • Interim captions update as someone talks. A final caption closes each phrase.
  • Captions reach every participant over /v1/rtc. Viewers of a live stream, including those on edge rooms, receive them too.
  • The transcriber doesn't trigger room.participant_* webhooks, isn't counted as a participant for RTC usage, and is billed as transcription minutes instead.
  • A room can have one active transcription at a time.

Start a transcription

const transcription = await livqeno.transcriptions.start(roomId, {
  language: 'en',
  provider: 'deepgram',
  interimResults: true,
  persist: true,
});

The response is 201 with the transcription in STARTING:

{
  "id": "trx_Q2x9mB7cVt1LkPz0aR4s8w",
  "roomId": "5b1f0c7e-2a5d-4a8e-9a63-0f3c1e9d7b21",
  "status": "STARTING",
  "language": "en",
  "provider": "deepgram",
  "model": null,
  "persist": true,
  "participants": null,
  "interimResults": true,
  "profanityFilter": false,
  "segmentCount": 0,
  "usageSeconds": 0,
  "startedAt": "2026-10-06T12:00:00.000Z",
  "endedAt": null,
  "endReason": null,
  "error": null
}
FieldDefaultMeaning
languageenThe spoken language, as a language tag (en, en-US, pt-BR)
providerdeepgramdeepgram or openai
modelthe provider's defaultA provider model id
participantsevery audio publisherOnly transcribe these identities
interimResultstrueSend interim captions as well as final ones
profanityFilterfalseAsk the provider to mask profanity
persisttrueStore final captions as a transcript
providerKeysnoneYour own provider key for this job, for example { "deepgram": "..." }

The status moves to ACTIVE when the transcriber has joined, and to ENDED (or FAILED) at the end. GET /v1/rooms/:roomId/transcription returns the active transcription, or 404 when there is none.

Bring your own provider key

Pass providerKeys to use your own provider account for one job. The key goes to the transcriber's memory for that job only. Livqeno never stores it, never logs it and never returns it. The time is still billed as transcription minutes.

A provider that has no key on this deployment and no key in the request fails with 422 AI_PROVIDER_NOT_CONFIGURED.

Show captions in your app

Each caption arrives as a transcription.segment message:

{
  "type": "transcription.segment",
  "transcriptionId": "trx_Q2x9mB7cVt1LkPz0aR4s8w",
  "participantId": "alice",
  "segmentId": "seg_8d1",
  "text": "hello every",
  "final": false,
  "language": "en",
  "startMs": 120340,
  "endMs": 121010
}
  • Interim segments for a phrase share a segmentId. Each one replaces the last.
  • A final segment with empty text means the provider withdrew the phrase: clear the interim and show nothing.
  • The final segment with that segmentId closes the phrase. Clear the interim text then.
  • startMs and endMs are relative to the start of the transcription.
  • A transcription.state message reports each status change: { "type": "transcription.state", "transcriptionId", "status", "language" }.
  • If a client briefly loses its connection and resumes, it receives the final captions it missed. Interim ones are skipped.
  • An AI agent's own speech arrives as segments too, with participantId set to the agent's identity, so captions show both sides.

Listen with the client SDKs

Every SDK turns these messages into room events: transcriptionSegment(segment) and transcriptionStateChanged(state). The web and React Native SDKs also ship CaptionStore, which applies the interim and final rules above for you. React adds a hook and a ready-made overlay.

import { CaptionStore } from '@livqeno/rtc';
 
room.on('transcriptionSegment', (segment) => {
  // segment.participantId, segment.segmentId, segment.text, segment.final
});
room.on('transcriptionStateChanged', (state) => {
  // state.status: 'STARTING' | 'ACTIVE' | 'STOPPING' | 'ENDED' | 'FAILED'
});
 
// Or let CaptionStore keep the caption state:
const captions = new CaptionStore({ maxSegments: 100 });
const detach = captions.attach(room);
captions.subscribe(() => {
  const { segments, interim, active } = captions.getSnapshot();
  // segments: finals, oldest first; interim: each speaker's open line, by identity
});

On Flutter, iOS and Android, keep a map of open interim lines keyed by segmentId: replace the entry on each interim, and remove it on the final (adding the final to your caption list unless its text is empty).

The transcriber itself is a participant with participant.isTranscriber set. It publishes nothing, so most UIs leave it out of the participant grid. If you only have an identity string, not a full Participant, you can run the same check yourself with isTranscriberIdentity() from @livqeno/rtc — it's the same function participant.isTranscriber uses internally:

import { isTranscriberIdentity, TRANSCRIBER_IDENTITY_PREFIX } from '@livqeno/rtc';
 
isTranscriberIdentity('transcriber-trc_9k2'); // true
TRANSCRIBER_IDENTITY_PREFIX; // 'transcriber-'

Stop

await livqeno.transcriptions.stop(roomId);

DELETE /v1/rooms/:roomId/transcription returns 204. The transcription moves to STOPPING, flushes its last captions, then becomes ENDED.

A transcription also stops by itself:

  • when the room is closed, with reason room_ended;
  • after 10 minutes with no audio publishers, with reason no_audio_publishers;
  • after 24 hours, with reason max_duration. Start a new one to keep going.

Fetch the transcript

When persist is on, every final segment is stored. Fetch the transcript from GET /v1/transcripts/:id, a page at a time:

let page = await livqeno.transcriptions.transcript(transcription.id, { limit: 200 });
while (page.nextCursor) {
  page = await livqeno.transcriptions.transcript(transcription.id, { cursor: page.nextCursor, limit: 200 });
}

The response is the transcription plus segments (each with segmentId, participantId, text, language, startMs and endMs) and a nextCursor, which is null on the last page. limit is 1–500 and defaults to 100.

Export as WebVTT or text

GET /v1/transcripts/:id.vtt returns WebVTT with one cue per segment and the speaker as a voice span. GET /v1/transcripts/:id.txt returns one line per segment:

[00:02:00] alice: hello everyone
[00:02:03] bob: hi alice
const vtt = await livqeno.transcriptions.export(transcription.id, 'vtt');

Erase a participant's speech

To honour an erasure request, delete everything one identity said, across every transcript in the environment:

Terminal
curl -X DELETE "$LIVQENO_API_URL/v1/transcripts/participants/alice" \
  -H "Authorization: Bearer $LIVQENO_API_KEY"
const { deleted } = await livqeno.transcriptions.eraseParticipant('alice');

deleted is how many segments were removed. Deleting a room also deletes its transcripts.

Webhooks

EventPayload
transcription.started{ transcriptionId, roomId, language, at }
transcription.ended{ transcriptionId, roomId, language, at, durationMs }
transcription.failed{ id, roomId, reason, at }
transcript.ready{ transcriptionId, roomId, segmentCount, url }, after the end when persist is on

See Events for every webhook.

Usage

Transcription is billed per minute that the transcriber is in the room, whoever's provider key it uses. It counts as transcription minutes in your usage.