Uploading recordings
An upload has four steps. You create the recording, upload the media in parts, upload the capture data, and tell Faylee the upload is complete. The bytes go from your client straight to the storage bucket through presigned URLs. They never pass through the API.
| Step | Request | Sent to |
|---|---|---|
| 1 | POST /recordings | Faylee |
| 2 | PUT each media part | Storage, through part_urls |
| 3 | PUT the capture data | Storage, through data_upload_url |
| 4 | POST /recordings/:id/complete | Faylee |
A replay has no media, so it skips step 2. Every recording, including screenshots and replays, uploads capture data in step 3.
1. Create the recording
Section titled “1. Create the recording”POST /recordings with a recording object.
| Field | Type | Required | Notes |
|---|---|---|---|
kind | string | yes | video, replay or screenshot |
title | string | no | |
description | string | no | |
page_url | string | no | The page the capture starts on |
duration_ms | integer | no | Length of the recording. For a trimmed video, the length after the trim |
error_count | integer | no | Shown in the library and used by the problems filter |
failed_request_count | integer | no | Shown in the library and used by the problems filter |
media.content_type | string | for video and screenshot | video/webm, video/mp4, image/png or image/jpeg |
media.byte_size | integer | for video and screenshot | Greater than 0, at most 2 GB |
environment | object | no | The browser environment. See below |
folder_id | integer | no | A folder from GET /folders |
trim.start_ms, trim.end_ms | integer | no | Videos only. See Trims and blurred areas |
blur_regions | object[] | no | Videos only. See Trims and blurred areas |
environment accepts the same keys as the capture format’s Environment: userAgent, language, languages, timezone, colorScheme, cpuCores, memoryGb, online, cookiesEnabled, browser (name, version), os (name, version), viewport (width, height), screen (width, height, pixelRatio) and network (effectiveType, downlinkMbps, rttMs). Other keys are dropped.
curl -X POST https://app.faylee.app/api/v1/recordings \ -H "Authorization: Bearer $FAYLEE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "recording": { "kind": "video", "title": "Checkout fails", "page_url": "https://shop.example.com/checkout", "duration_ms": 42000, "error_count": 2, "media": { "content_type": "video/webm", "byte_size": 23068672 }, "environment": { "browser": { "name": "Chrome", "version": "131.0" } } } }'201 returns the upload plan:
{ "id": 4812, "share_url": "https://app.faylee.app/s/V1StGXR8_Z", "data_upload_url": "https://storage.example.com/recordings/V1StGXR8_Z/data.json?X-Amz-Signature=...", "media_upload": { "upload_id": "2~kQ7v...", "part_size": 10485760, "part_urls": [ "https://storage.example.com/recordings/V1StGXR8_Z/media.webm?partNumber=1&uploadId=...", "https://storage.example.com/recordings/V1StGXR8_Z/media.webm?partNumber=2&uploadId=...", "https://storage.example.com/recordings/V1StGXR8_Z/media.webm?partNumber=3&uploadId=..." ] }}| Field | Notes |
|---|---|
id | The recording’s numeric id. Use it to complete the upload |
share_url | Where the recording will be once it is ready |
data_upload_url | Presigned PUT URL for the capture data |
media_upload | null for a replay |
media_upload.upload_id | The storage multipart upload |
media_upload.part_size | Bytes per part, 10 MB. The last part may be smaller |
media_upload.part_urls | One presigned PUT URL per part, in order. There are ceil(byte_size / part_size) of them |
The recording now exists with status uploading. It does not appear in the library or in GET /recordings until it is ready.
Choose a folder
Section titled “Choose a folder”GET /folders lists the workspace’s folders, sorted by name:
{ "folders": [{ "id": 12, "name": "Checkout" }, { "id": 7, "name": "Onboarding" }] }Pass one as recording.folder_id. A folder from another workspace gets 404.
2. Upload the media parts
Section titled “2. Upload the media parts”Split the file into part_size chunks. PUT chunk n to part_urls[n - 1] with the media’s Content-Type, and keep the ETag header from each response.
split -b 10485760 -d -a 1 checkout.webm part-
curl -X PUT "$PART_URL_1" -H "Content-Type: video/webm" --data-binary @part-0 -D - -o /dev/null | grep -i '^etag'# ETag: "a1b2c3d4e5f6..."Repeat for every part. Keep each ETag exactly as returned, including the quotes.
If you upload from a browser, the bucket must expose the ETag header through CORS. Without it the browser cannot read the header and you cannot complete the upload. See Storage if the workspace uses its own bucket.
3. Upload the capture data
Section titled “3. Upload the capture data”PUT the capture bundle to data_upload_url with Content-Type: application/json. The bundle is described in Capture format.
curl -X PUT "$DATA_UPLOAD_URL" -H "Content-Type: application/json" --data-binary @data.jsonFaylee does not redact this file. Hide secrets in it before you upload. See Redaction.
4. Complete the upload
Section titled “4. Complete the upload”POST /recordings/:id/complete with the parts in order. :id must be the numeric id from the upload plan, and only the person who created the recording can complete it.
| Field | Type | Notes |
|---|---|---|
parts | object[] | One per media part. Empty for a replay |
parts[].part_number | integer | Starting at 1 |
parts[].etag | string | The ETag storage returned for that part |
curl -X POST https://app.faylee.app/api/v1/recordings/4812/complete \ -H "Authorization: Bearer $FAYLEE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "parts": [ { "part_number": 1, "etag": "\"a1b2c3d4e5f6...\"" }, { "part_number": 2, "etag": "\"0f9e8d7c6b5a...\"" }, { "part_number": 3, "etag": "\"99aa88bb77cc...\"" } ] }'200 returns the recording object with status ready, or processing when the video has a trim or blurred areas to apply.
For a replay, send an empty list:
curl -X POST https://app.faylee.app/api/v1/recordings/4813/complete \ -H "Authorization: Bearer $FAYLEE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "parts": [] }'Once the recording is ready, Faylee makes a thumbnail for media, sends the recording.ready webhook, notifies connected integrations, and, when the workspace has the assistant on, analyzes it.
Errors
Section titled “Errors”Creating a recording returns 422 when a field is invalid, with the validation message:
| Message | Cause |
|---|---|
Kind is not included in the list | kind is missing or unknown |
Media content type is not included in the list | The media type is not one of the four accepted types |
Media byte size is not a number | media.byte_size is missing |
Media byte size must be greater than 0 | media.byte_size is 0 |
Media byte size must be less than or equal to 2147483648 | The file is larger than 2 GB |
Trim end ms must be greater than ... | trim.end_ms is not after trim.start_ms |
This workspace has no storage configured yet | The workspace has no bucket to upload to |
Completing an upload returns 422 with one of these messages:
| Message | Cause |
|---|---|
This upload was already finished | The recording is no longer uploading. It was completed already, or it expired |
The capture data never reached storage | Nothing was uploaded to data_upload_url |
No media parts were uploaded | parts is empty for a video or screenshot |
Storage refused the upload: ... | Storage rejected the parts, for example a wrong or missing ETag. The recording becomes failed |
After a Storage refused the upload error the recording cannot be completed. Start again from step 1.
Trims and blurred areas
Section titled “Trims and blurred areas”A video can ask Faylee to cut it or blur parts of it before anyone can watch it. Faylee re-encodes the stored video, so the removed parts are gone rather than hidden.
| Field | Notes |
|---|---|
trim.start_ms | Where the kept part starts, in milliseconds into the uploaded video. 0 or more |
trim.end_ms | Where the kept part ends. Greater than start_ms |
blur_regions | Areas to blur for the whole video, each { x, y, width, height } as fractions of the frame from the top left, between 0 and 1. Malformed areas are dropped |
A trim needs both values. Upload the full video as recorded, with media.byte_size of the full file. The capture data is not trimmed by the server: upload a bundle already cut to the same range, with its times starting at 0, and set duration_ms to the trimmed length.
{ "recording": { "kind": "video", "duration_ms": 15000, "media": { "content_type": "video/mp4", "byte_size": 31457280 }, "trim": { "start_ms": 12000, "end_ms": 27000 }, "blur_regions": [{ "x": 0.7, "y": 0.05, "width": 0.25, "height": 0.1 }] }}Processing states
Section titled “Processing states”| Status | Meaning |
|---|---|
uploading | Created in step 1 and waiting for completion |
processing | Completed, and the video is being trimmed or blurred. Nobody can watch it yet |
ready | Visible in the library and to the API |
failed | Storage refused the upload, the video could not be edited, or the upload was abandoned |
A recording left in uploading for 24 hours is abandoned: its multipart upload is aborted and it becomes failed. A video that cannot be trimmed or blurred becomes failed rather than being shown uncut.
GET /recordings lists only ready recordings. To follow one upload, fetch it with GET /recordings/:id, or subscribe to the recording.ready webhook.