Black-and-white line illustration of a play button and download arrow representing media downloads.
Black-and-white line illustration of a play button and download arrow representing media downloads.

Media

Secure Access to Participant Recordings and Uploads

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.

Download media for a specific response

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
}

Fetch media for all responses to a specific test

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
        }
      }
    }
  }
}


Accessing media files via attachedMedia

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;

// Find the VTT subtitles
const subtitles = attachedMedia.find(m => m.type === 'SUBTITLES');

if (subtitles.status === 'SUCCESS' && subtitles.url) {
  // Download or display the VTT file
  const response = await fetch(subtitles.url);
  const vttContent = await response.text();
  console.log('VTT subtitles:', vttContent);
} else if (subtitles.status === 'PENDING') {
  // Processing still in progress
  console.log('Subtitles are still being generated...');
} else if (subtitles.status === 'FAILED') {
  // Processing 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;

// Find the audio extraction
const audio = attachedMedia.find(m => m.type === 'AUDIO_EXTRACTION');

if (audio.status === 'SUCCESS' && audio.url) {
  // Use the audio URL in your audio player
  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:

// Get English subtitles
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}`);
    }
    
    // Wait 3 seconds before next attempt
    await new Promise(resolve => setTimeout(resolve, 3000));
  }
  
  throw new Error('Subtitle generation timed out');
}

Best practices

  1. Check status before accessing URLs — Always verify that status === 'SUCCESS' before using the url field.

  2. 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).

  3. Use signed URLs promptly — Media URLs are pre-signed and expire after a period. Don't store URLs long-term; refetch them when needed.

  4. Poll asynchronously — If you need media files immediately after recording completion, implement polling with exponential backoff to avoid excessive API calls.

  5. Request only what you need — Use GraphQL field selection to request only the media types and metadata you actually need, improving query performance.