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,
});transcription = livqeno.transcriptions.start(room_id, language="en", provider="deepgram", persist=True)curl -X POST "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/transcription" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"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
}| Field | Default | Meaning |
|---|---|---|
language | en | The spoken language, as a language tag (en, en-US, pt-BR) |
provider | deepgram | deepgram or openai |
model | the provider's default | A provider model id |
participants | every audio publisher | Only transcribe these identities |
interimResults | true | Send interim captions as well as final ones |
profanityFilter | false | Ask the provider to mask profanity |
persist | true | Store final captions as a transcript |
providerKeys | none | Your 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
textmeans the provider withdrew the phrase: clear the interim and show nothing. - The final segment with that
segmentIdcloses the phrase. Clear the interim text then. startMsandendMsare relative to the start of the transcription.- A
transcription.statemessage 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
participantIdset 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
});import { Captions, VideoConference, useTranscription } from '@livqeno/react';
import '@livqeno/react/video/styles.css';
// A Captions button and overlay in the prebuilt call screen:
<VideoConference captions />
// The overlay on its own, inside <LivqenoRoom>:
<Captions maxLines={3} fadeAfterMs={6000} />
// Or build your own:
function Transcript() {
const { segments, interim, active } = useTranscription();
if (!active) return null;
return (
<ul>
{segments.map((s) => <li key={s.segmentId}>{s.participantId}: {s.text}</li>)}
{Object.values(interim).map((s) => <li key={s.segmentId}><em>{s.text}</em></li>)}
</ul>
);
}useTranscription({ participant }) shows one speaker only. See
React components.
import { useTranscription } from '@livqeno/react-native';
function CaptionBar({ room }) {
const { segments, interim } = useTranscription(room);
const last = segments[segments.length - 1];
const live = Object.values(interim)[0];
return <Text>{live ? live.text : last?.text}</Text>;
}room.on('transcriptionSegment', …) works too: it's the same Room as
the web.
room.transcriptionSegments.listen((segment) {
// segment.participantId, segment.segmentId, segment.text
// segment.isFinal (the wire's `final`); an empty final clears the interim
});
room.transcriptionStateChanges.listen((state) {
if (state.status == LivqenoTranscriptionStatus.active) showCaptionsBadge();
});for await event in room.events() {
switch event {
case let .transcriptionSegment(segment):
// segment.isFinal (the wire's "final"); an empty final clears the interim
captions.apply(segment)
case let .transcriptionStateChanged(state):
showCaptionsBadge(state.status == .active)
default:
break
}
}room.events.collect { event ->
when (event) {
// event.segment.isFinal is the wire's "final"; an empty final clears the interim
is RoomEvent.TranscriptionSegment -> captions.apply(event.segment)
is RoomEvent.TranscriptionStateChanged -> showCaptionsBadge(event.state.status == TranscriptionStatus.ACTIVE)
else -> Unit
}
}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);curl -X DELETE "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/transcription" \
-H "Authorization: Bearer $LIVQENO_API_KEY"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 });
}curl "$LIVQENO_API_URL/v1/transcripts/$TRANSCRIPTION_ID?limit=200" \
-H "Authorization: Bearer $LIVQENO_API_KEY"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 aliceconst 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:
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
| Event | Payload |
|---|---|
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.