---
title: "Uploading recordings"
description: "Upload a video, replay or screenshot in four steps, with media and capture data sent straight to storage through presigned URLs."
canonical: https://faylee.app/docs/api/uploading
---

# 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

`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`](#choose-a-folder) |
| `trim.start_ms`, `trim.end_ms` | integer | no | Videos only. See [Trims and blurred areas](#trims-and-blurred-areas) |
| `blur_regions` | object[] | no | Videos only. See [Trims and blurred areas](#trims-and-blurred-areas) |

`environment` accepts the same keys as the capture format's [Environment](https://faylee.app/docs/api/capture-format#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.

```bash
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:

```json
{
  "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.

:::caution
Every URL in the plan expires one hour after it is issued. Finish steps 2 and 3 within the hour.
:::

### Choose a folder

`GET /folders` lists the workspace's folders, sorted by name:

```json
{ "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

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.

```bash
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](https://faylee.app/docs/workspace/storage.md) if the workspace uses its own bucket.

## 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](https://faylee.app/docs/api/capture-format.md).

```bash
curl -X PUT "$DATA_UPLOAD_URL" -H "Content-Type: application/json" --data-binary @data.json
```

Faylee does not redact this file. Hide secrets in it before you upload. See [Redaction](https://faylee.app/docs/api/capture-format#redaction).

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

```bash
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](https://faylee.app/docs/api/overview#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:

```bash
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](https://faylee.app/docs/api/webhooks.md), notifies connected integrations, and, when the workspace has the assistant on, analyzes it.

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

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.

```json
{
  "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 }]
  }
}
```

:::note
Send trims and blurred areas only for `video` recordings. Blur a screenshot's image before you upload it.
:::

## 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](https://faylee.app/docs/api/webhooks.md).
