---
title: "Capture format"
description: "Reference for data.json, the CaptureBundle v2 file that holds a recording's console, network, actions, DOM, marks and environment on one timeline."
canonical: https://faylee.app/docs/api/capture-format
---

# Capture format

Every Faylee recording, instant replay and screenshot stores what was captured alongside the media as one JSON file, `data.json`. Its shape is called a capture bundle. This page describes version 2 of the format for anyone who reads bundles: scripts, agents, importers and test generators.

Get a recording's bundle with [`GET /recordings/:id/capture`](https://faylee.app/docs/api/reading#capture-data). You upload one in [step 3 of an upload](https://faylee.app/docs/api/uploading#3-upload-the-capture-data).

## Top-level fields

| Field | Type | Notes |
| --- | --- | --- |
| `version` | `2` | Always the number `2` for this format |
| `kind` | string | `video`, `replay` or `screenshot` |
| `startedAt` | string | ISO 8601. Wall-clock start of the timeline |
| `durationMs` | number | Length of the timeline in milliseconds, pauses excluded. 0 or more |
| `environment` | object or null | The browser and device. See [Environment](#environment) |
| `pages` | object[] | Pages open during the capture. See [Pages](#pages) |
| `console` | object[] | Console messages. See [Console](#console) |
| `errors` | object[] | Uncaught errors and unhandled rejections. See [Errors](#errors) |
| `network` | object[] | Requests, sorted by `at`. See [NetworkEntry](#networkentry) |
| `actions` | object[] | What the user did. See [UserAction](#useraction) |
| `dom` | object[] | rrweb events for the page replay. See [DOM events](#dom-events) |
| `vitals` | object[] | Web Vitals and runtime measurements. See [Vitals](#vitals) |
| `custom` | object[] | Metadata the page attached. See [Custom metadata](#custom-metadata) |
| `sockets` | object[] | WebSocket and EventSource connections. See [SocketConnection](#socketconnection) |
| `linkedSessions` | object[] | The same visit in other tools. See [Linked sessions](#linked-sessions) |
| `edits` | object[] | Changes the reporter made to the page. See [Edits](#edits) |
| `annotations` | object[] | Marks the reporter drew. See [Annotation](#annotation) |

`dom`, `vitals`, `custom`, `sockets`, `linkedSessions`, `edits` and `annotations` were added to version 2 over time. Bundles made before them may not have the key at all. Treat a missing key as an empty list.

Not every kind fills every list. A recording sent through a [recording link](https://faylee.app/docs/api/recording-links.md) records the screen rather than a page, so it has an environment and at most one page, and every other list is empty.

```json
{
  "version": 2,
  "kind": "video",
  "startedAt": "2026-09-30T14:02:23.120Z",
  "durationMs": 48200,
  "environment": { "userAgent": "Mozilla/5.0 ...", "browser": { "name": "Chrome", "version": "131.0.6778.86" } },
  "pages": [{ "at": 0, "url": "https://shop.example.com/checkout", "title": "Checkout" }],
  "console": [],
  "errors": [],
  "network": [],
  "actions": [],
  "dom": [],
  "vitals": [],
  "custom": [],
  "sockets": [],
  "linkedSessions": [],
  "edits": [],
  "annotations": []
}
```

The `environment` above is shortened.

## Time

Almost every entry has `at`: milliseconds into the recording's timeline, never negative. `0` is the moment the timeline starts, and `durationMs` is where it ends.

The timeline skips pauses. When the reporter pauses a video recording and resumes it, the paused time is not on the timeline, and entries captured during it are dropped. So `at` lines up with the video, and an entry at `at: 12400` is about 12.4 seconds into it.

To get the wall-clock time of an entry, add `at` to `startedAt`. This is exact only when the recording was never paused before that entry. The bundle does not record where pauses were.

Instant replays and screenshots are never paused. Their timeline starts at the first event inside the captured window.

Some things describe the page rather than a moment, such as the page that was open when recording started, load metrics and app metadata. When they happened before the timeline started, they are placed at `at: 0`.

DOM events are the one exception to `at`. See [DOM events](#dom-events).

## Pages

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | When the page loaded. The page open when recording started is at 0 |
| `url` | string | |
| `title` | string | |

## Console

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | |
| `level` | string | `log`, `info`, `warn`, `error`, `debug` or `trace` |
| `args` | array | The arguments passed to the console call, as JSON values |
| `stack` | string or null | |

Values that are not plain JSON are converted. Maps become `{ "[[Map]]": [[key, value], ...] }`, sets become `{ "[[Set]]": [...] }`, and long arrays and objects are cut short.

## Errors

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | |
| `message` | string | |
| `source` | string or null | The script URL |
| `line` | number or null | |
| `column` | number or null | |
| `stack` | string or null | |

Stacks are as the browser reported them, usually pointing at minified code.

## NetworkEntry

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | The browser's request id, or `page-<at>-<index>` for a request only the page saw, such as one a service worker answered |
| `at` | number | When the request started |
| `url` | string | |
| `method` | string | |
| `resourceType` | string | The Chrome resource type, such as `xmlhttprequest`, `fetch` or `other` |
| `requestHeaders` | object[] | `{ name, value }` pairs |
| `responseHeaders` | object[] | `{ name, value }` pairs. Empty for requests only the page saw |
| `requestBody` | string or null | Only for `fetch` and `XMLHttpRequest` calls |
| `responseBody` | string or null | Only for `fetch` and `XMLHttpRequest` calls with a text response |
| `outcome` | object | See below |

Bodies are text, cut at 256 KB. A body that is not text is replaced by a description in square brackets, such as `[binary 512 bytes]`, `[image/png]` or `[3145728 bytes, not captured]`. Form data is written as `key=value` pairs joined with `&`, and files in it as `[file name.pdf]`.

`outcome` has a `state` field that decides its other fields:

| `state` | Other fields |
| --- | --- |
| `completed` | `status` (number), `statusText`, `durationMs`, `fromCache` (boolean) |
| `failed` | `error` (the browser's error message), `durationMs` |
| `pending` | None. The request had not finished when the capture ended |

Faylee counts a request as failed when its state is `failed`, when it completed with a status of 400 or more, or when it is a GraphQL request whose JSON response has an `errors` field, since GraphQL servers answer 200 even for errors.

```json
{
  "id": "18234.77",
  "at": 4950,
  "url": "https://shop.example.com/api/orders",
  "method": "POST",
  "resourceType": "fetch",
  "requestHeaders": [{ "name": "content-type", "value": "application/json" }, { "name": "authorization", "value": "[redacted]" }],
  "responseHeaders": [{ "name": "content-type", "value": "application/json" }],
  "requestBody": "{\"cart\":\"c_91\",\"card\":\"saved\"}",
  "responseBody": "{\"error\":\"payment_service_unavailable\"}",
  "outcome": { "state": "completed", "status": 500, "statusText": "Internal Server Error", "durationMs": 812, "fromCache": false }
}
```

## SocketConnection

One WebSocket or EventSource connection and what crossed it while recording.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Stable for the connection within a page |
| `transport` | string | `websocket` or `eventsource` |
| `url` | string | |
| `openedAt` | number | 0 when it was already open when the recording started |
| `closedAt` | number or null | |
| `closeCode` | number or null | |
| `closeReason` | string or null | |
| `failed` | boolean | The connection reported an error |
| `messages` | object[] | `{ at, direction, data, size }`. `direction` is `sent` or `received` |
| `droppedMessages` | number | How many older messages were left out |

A connection keeps its newest 1,000 messages. Text messages are cut at 4 KB, and `size` is the full length. Binary messages are described as `[binary <n> bytes]`.

## UserAction

Each entry in `actions` is `{ at, action }`. `action` has a `type` field that decides its other fields:

| `type` | Fields |
| --- | --- |
| `click` | `target`, `x`, `y` |
| `input` | `target`, `value` |
| `select` | `target`, `value` |
| `submit` | `target` |
| `key` | `target`, `key`, such as `Enter` or `Escape` |
| `scroll` | `x`, `y` |
| `resize` | `width`, `height` |
| `navigation` | `url`, `cause`, and optionally `title` |

`cause` is `load` (a full page load), `history` (a single-page app route change), `hash` (a fragment change) or `tab` (the user switched tabs). `title` is the document title once the new page settled.

`target` describes the element:

| Field | Type | Notes |
| --- | --- | --- |
| `selector` | string | A CSS selector for the element |
| `tag` | string | Lowercase tag name |
| `role` | string or null | Its ARIA role |
| `name` | string or null | Its accessible name |
| `testId` | string or null | Its test id attribute |

`role`, `name` and `testId` are enough to build a Playwright or Testing Library locator. Prefer them over `selector` when they are present.

```json
{ "at": 4900, "action": { "type": "click", "target": { "selector": "form#pay > button", "tag": "button", "role": "button", "name": "Pay now", "testId": null }, "x": 812, "y": 604 } }
```

## Annotation

A mark the reporter drew over the page while recording, or over a screenshot before sharing it. The video or screenshot shows it as pixels. The annotation is the same mark as data.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Stable within the capture. A mark keeps its id when it is moved |
| `at` | number | When it was first drawn. A screenshot's marks are all at 0 |
| `until` | number or null | When it left the page (undone, cleared or faded), or `null` if it was still there at the end |
| `tool` | string | `pen`, `box`, `circle`, `arrow`, `line`, `text` or `blur` |
| `color` | string or null | Hex. `null` for `blur` |
| `from`, `to` | `{ x, y }` or null | Where the drag started and ended. A pen's first and last point. A text note's top left is `from`, with no `to` |
| `points` | `{ x, y }[]` or null | The whole pen stroke. `null` for other tools |
| `box` | `{ x, y, width, height }` | The area the mark covers |
| `text` | string or null | A text note's words |
| `target` | object or null | The element under the mark's anchor, in the same shape as an action's `target`. The anchor is an arrow's head, a note's start, and the middle of anything else |
| `redacted` | boolean | `true` for `blur` |

Positions are in viewport CSS pixels from the top left of the page as it was on screen, so read them against `environment.viewport`. A full-page screenshot's marks can fall above or below the viewport.

A `blur` pixelates what is under it, so it hides rather than points. It keeps only its `box`, with no color, text or target, because naming the element under it could give away what it hides.

## Edits

Changes the reporter made to the page while recording, to show how it should look or read.

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | |
| `type` | string | `style` or `text` |
| `selector` | string | The changed element |
| `property` | string or null | The CSS property for a `style` change, `null` for `text` |
| `before` | string | What the page had |
| `after` | string | What the reporter set |

## Environment

| Field | Type | Notes |
| --- | --- | --- |
| `userAgent` | string | |
| `browser` | `{ name, version }` | |
| `os` | `{ name, version }` | |
| `language` | string | |
| `languages` | string[] | |
| `timezone` | string | An IANA name, such as `Europe/Berlin` |
| `viewport` | `{ width, height }` | CSS pixels |
| `screen` | `{ width, height, pixelRatio }` | |
| `colorScheme` | string | `light` or `dark` |
| `cpuCores` | number or null | |
| `memoryGb` | number or null | |
| `network` | object or null | `{ effectiveType, downlinkMbps, rttMs }` |
| `online` | boolean | |
| `cookiesEnabled` | boolean | |
| `battery` | object, null or absent | `{ level, charging }`, `level` from 0 to 1 |
| `incognito` | boolean, null or absent | |
| `deviceModel` | string, null or absent | |

`battery`, `incognito` and `deviceModel` were added later, so older bundles lack them. The environment is the one of the last page load before the capture ended.

## Vitals

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | |
| `metric` | string | `lcp`, `fcp`, `ttfb`, `cls`, `inp`, `long-task` or `memory` |
| `value` | number | Milliseconds for times, unitless for `cls`, megabytes for `memory` |

## Custom metadata

What the page passed to [`window.faylee.metadata()`](https://faylee.app/docs/capture/app-metadata.md), such as a user id or a plan.

| Field | Type | Notes |
| --- | --- | --- |
| `at` | number | |
| `data` | object | The values passed, as JSON |

A page can call it more than once. To get the current values, merge the entries in order, later values winning.

## Linked sessions

The same visit as recorded by a session replay or monitoring tool on the page.

| Field | Type | Notes |
| --- | --- | --- |
| `provider` | string | `logrocket`, `fullstory`, `sentry`, `posthog` or `datadog` |
| `url` | string or null | A link to the session in that tool |
| `sessionId` | string or null | The tool's session id. A tool gives one or both |

## DOM events

`dom` holds [rrweb](https://github.com/rrweb-io/rrweb) events, kept as rrweb wrote them, for replaying the page. Each has a numeric `type` and a numeric `timestamp`. Pass the list to an rrweb player to rebuild the page.

Their `timestamp` is not an offset. It is `Date.parse(startedAt) + at`: an epoch-like number on the recording's clock. Subtract `Date.parse(startedAt)` to compare it with other entries.

The list starts with the last full snapshot at or before the start of the timeline, so the player has a complete page to apply later changes to.

Masked fields have their values replaced in the DOM events. See [Redaction](#redaction).

## Redaction

The Faylee extension hides secrets before a bundle is uploaded. Expect these markers in place of the original values:

| Marker | Where |
| --- | --- |
| `[redacted]` | A hidden header value, query parameter, JSON value, form field or text match |
| `Bearer [redacted]` | A credential found in text. `Basic` and `Token` credentials keep their scheme the same way |
| `//[redacted]@` | A `user:password@` in a URL |
| `••••` | The value of an `input` action in a sensitive or masked field |

What is hidden:

- **Headers**: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`, `x-csrf-token`, `x-xsrf-token`, `x-amz-security-token`, plus any added in the extension's redaction settings.
- **Keys** in query strings, JSON bodies, form bodies, console arguments and metadata whose names look secret, such as `password`, `secret`, `apiKey`, `token`, `session`, `cvv` or `iban`.
- **Text** that looks like a JWT, a `Bearer`, `Basic` or `Token` credential, or a password in a URL.
- **Fields** that are password inputs, fields whose `autocomplete` asks for card details, a one-time code or a password, and anything inside an element with the `data-faylee-mask` attribute or the `faylee-mask` class. Their values are masked in both `actions` and `dom`.

Page titles, stacks, `environment` and `vitals` are not redacted. The reporter can also remove whole sections or requests before uploading, so a list can be shorter than what happened on the page.

:::note
Redaction runs in the extension. A bundle uploaded through the API is stored as you send it, so redact it yourself first.
:::

## Versioning

`version` is the number `2`. Faylee adds fields to version 2 without changing the number, always as lists that may be missing or as fields that may be absent or `null`. When you parse a bundle:

- Ignore keys you do not know.
- Treat a missing list as empty.
- Treat a missing field inside an object as `null`.

The version changes only when an existing field changes meaning or shape. Recordings made with the older version 1 format have `"version": 1` and a different shape. Faylee's viewer upgrades them as it reads them, but the capture endpoint returns the file as stored. If you read old recordings, check `version` first and skip bundles that are not `2`.
