The media endpoint provides secure access to participant session files such as video, audio, and image uploads. Instead of returning binary data directly, the API issues signed URLs that grant temporary access to files stored in the platform’s media storage. This ensures large media assets can be transferred efficiently without overloading API responses.
Developers can:
Request video or audio recordings for a given responseId.
Fetch image assets (e.g. screenshots, uploads) linked to a study.
Use query parameters to control format, quality, or expiry.
Integrate signed URLs into custom playback, download, or processing pipelines.
Screen, camera, and audio recordings generated as part of a response are available through the recordings field on a TestResultNode
. Each entry includes an expiring signed URL pointing to either an mp4 (video + audio) or ogg (audio-only) file, with the format indicated by the isAudio
field.
Here we'll start our query at the the userTestResult
resolver and from there select the recordings we're interested in, along with their transcripts.
# Start by selected a specific response using its UUID
query specificResult {
userTestResult(
resultUuid: "ut_response_9568e478-f8fe-450d-81e6-741a279c6b5b"
) {
...resultSelection
}
}
# For the UserTestResult we are able to select the combined user & screen recording under `download`...
fragment resultSelection on UserTestResult {
name
responseUUID
status
recordings {
download {
...recordingSelection
}
}
participant {
anonymousId
email
name
}
}
# On the TestSessionRecording we are able to select the media url itself, as well as
# the extracted audio or raw audio if this was an audio-only recording under `attachedMedia`
fragment recordingSelection on TestSessionRecording {
isAudio
mediaUrl
filesize
duration
failedStatus
failedType
mimeType
missingDataWarning
usertestName
startTime
createdAt
transcription
}
Since we can fetch many responses for a single test, we're also able to fetch all media at the same time.
query listResults {
userTest(uuid: "ut_645c4faa-5c2d-49b1-85fa-823ffbbbfa17") {
id
results(first: 4) {
edges {
node {
...resultSelection
}
}
}
}
}
In addition to the structured JSON transcript data, each TestSessionRecording
provides access to various processed media files through the attachedMedia
field. This includes subtitle files (VTT), audio extractions, waveform visualizations, and more.
Understanding attachedMedia structure
The attachedMedia
field returns media files grouped by language, allowing you to access different processed versions of the recording:
Available media types
The attachedMedia
field provides access to the following media types:
SUBTITLES
— WebVTT subtitle file for video playback captioning
TRANSCRIPTION
— Full JSON transcript with word-level timestamps
AUDIO_EXTRACTION
— Extracted audio track from the video recording
WAVEFORM
— JSON waveform visualization data
SUMMARISATION
— AI-generated summary of the session
Each media type includes:
type
— The type of media file (enumerated value)
status
— Processing status (PENDING
, SUCCESS
, FAILED
, WARNING
, NOT_ALLOWED
, NOT_SUPPORTED
, NOT_REQUIRED
)
statusReason
— Additional context if processing failed (e.g., NO_AUDIO
, RECORDING_FAILED_UPLOAD
)
url
— Signed URL to download the file (null if not yet available)
error
— Error message if processing failed
finalisedAt
— Timestamp when processing completed
Example: Fetching VTT subtitle file
To retrieve the WebVTT subtitle file for a recording:
query GetAllResults {
userTest(uuid: "ut_645c4faa-5c2d-49b1-85fa-823ffbbbfa17") {
results(first: 10) {
edges {
node {
pk
recordings {
download {
attachedMedia {
language
media {
type
status
url
}
}
}
}
}
}
}
}
}
Example response:
{
"data": {
"userTest": {
"results": {
"edges": [
{
"node": {
"pk": 12345,
"recordings": {
"download": {
"attachedMedia": [
{
"language": "en",
"media": [
{
"type": "SUBTITLES",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-1-subtitles.vtt?signature=..."
},
{
"type": "TRANSCRIPTION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-1-transcript.json?signature=..."
},
{
"type": "AUDIO_EXTRACTION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-1-audio.mp3?signature=..."
},
{
"type": "WAVEFORM",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-1-wave.json?signature=..."
},
{
"type": "SUMMARISATION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-1-summary.json?signature=..."
}
]
}
]
}
}
}
},
{
"node": {
"pk": 12346,
"recordings": {
"download": {
"attachedMedia": [
{
"language": "en",
"media": [
{
"type": "SUBTITLES",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-2-subtitles.vtt?signature=..."
},
{
"type": "TRANSCRIPTION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-2-transcript.json?signature=..."
},
{
"type": "AUDIO_EXTRACTION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-2-audio.mp3?signature=..."
},
{
"type": "WAVEFORM",
"status": "PENDING",
"url": null
},
{
"type": "SUMMARISATION",
"status": "PENDING",
"url": null
}
]
}
]
}
}
}
},
{
"node": {
"pk": 12347,
"recordings": {
"download": {
"attachedMedia": [
{
"language": "en",
"media": [
{
"type": "SUBTITLES",
"status": "FAILED",
"url": null
},
{
"type": "TRANSCRIPTION",
"status": "FAILED",
"url": null
},
{
"type": "AUDIO_EXTRACTION",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-3-audio.mp3?signature=..."
},
{
"type": "WAVEFORM",
"status": "SUCCESS",
"url": "https://storage.googleapis.com/livekit-staging/recording-3-wave.json?signature=..."
},
{
"type": "SUMMARISATION",
"status": "NOT_REQUIRED",
"url": null
}
]
}
]
}
}
}
}
]
}
}
}
}
Handling media processing status
Media files are processed asynchronously after a recording completes. Always check the status
field before attempting to access the url
:
const attachedMedia = recording.attachedMedia[0].media;
const subtitles = attachedMedia.find(m => m.type === 'SUBTITLES');
if (subtitles.status === 'SUCCESS' && subtitles.url) {
const response = await fetch(subtitles.url);
const vttContent = await response.text();
console.log('VTT subtitles:', vttContent);
} else if (subtitles.status === 'PENDING') {
console.log('Subtitles are still being generated...');
} else if (subtitles.status === 'FAILED') {
console.error('Subtitle generation failed:', subtitles.error);
}
Example: Fetching audio extraction
To retrieve the extracted audio file instead of the full video:
const attachedMedia = recording.attachedMedia[0].media;
const audio = attachedMedia.find(m => m.type === 'AUDIO_EXTRACTION');
if (audio.status === 'SUCCESS' && audio.url) {
audioPlayer.src = audio.url;
audioPlayer.play();
}
Multi-language support
If recordings are processed in multiple languages, the attachedMedia
array will contain separate entries for each language:
{
"attachedMedia": [
{
"language": "en",
"media": [...]
},
{
"language": "es",
"media": [...]
}
]
}
You can filter by language to retrieve the appropriate media files for your use case:
const englishMedia = recording.attachedMedia.find(
m => m.language === 'en'
);
const englishSubtitles = englishMedia.media.find(
m => m.type === 'SUBTITLES'
);
Deprecated media fields
The following fields are deprecated in favor of attachedMedia
:
transcript
— Use attachedMedia
with type TRANSCRIPTION
instead
subtitles
— Use attachedMedia
with type SUBTITLES
instead
waveform
— Use attachedMedia
with type WAVEFORM
instead
While these legacy fields remain functional, new integrations should use attachedMedia
for future compatibility and access to additional media types.
Polling for completion
For long-running processing tasks, you may need to poll the attachedMedia
status:
async function waitForSubtitles(resultUuid, maxAttempts = 20) {
for (let i = 0; i < maxAttempts; i++) {
const result = await fetchUserTestResult(resultUuid);
const recording = result.recordings.download;
const subtitles = recording.attachedMedia[0].media.find(
m => m.type === 'SUBTITLES'
);
if (subtitles.status === 'SUCCESS') {
return subtitles.url;
}
if (subtitles.status === 'FAILED') {
throw new Error(`Subtitle generation failed: ${subtitles.error}`);
}
await new Promise(resolve => setTimeout(resolve, 3000));
}
throw new Error('Subtitle generation timed out');
}
Best practices
Check status before accessing URLs — Always verify that status === 'SUCCESS'
before using the url
field.
Handle missing media gracefully — Not all recordings will have all media types. Some may fail processing due to technical constraints (e.g., no audio in the recording).
Use signed URLs promptly — Media URLs are pre-signed and expire after a period. Don't store URLs long-term; refetch them when needed.
Poll asynchronously — If you need media files immediately after recording completion, implement polling with exponential backoff to avoid excessive API calls.
Request only what you need — Use GraphQL field selection to request only the media types and metadata you actually need, improving query performance.