{
  "openapi": "3.1.0",
  "info": {
    "title": "RecordHTML API",
    "version": "1.0.0",
    "description": "Record HTML5 ads, animations and web pages as MP4, WebM or GIF in a real browser. Start a recording with `POST /jobs`, poll `GET /jobs/{id}` until it is `ready` (or let a webhook tell you), then download it from `GET /jobs/{id}/video`.\n\nAuthenticate with an API key from the [API settings page](https://recordhtml.com/settings/api), as `x-api-key` or `Authorization: Bearer`. A key acts as your account, with your limits. The API sends no CORS headers: keep keys on servers.\n\nEach account can make 300 requests a minute, start 20 recordings a minute and request 60 estimates a minute. Recordings in progress, recordings per 24 hours, duration, size and frame rate are limited per account, shared with the web app and MCP.\n\nErrors are JSON: `{ \"error\": \"code\", \"message\": \"…\" }`, plus `issues` for invalid fields or `reasons` for capacity."
  },
  "externalDocs": {
    "description": "API reference",
    "url": "https://recordhtml.com/docs/api"
  },
  "servers": [
    {
      "url": "https://api.recordhtml.com/v1"
    }
  ],
  "security": [
    {
      "apiKey": []
    },
    {
      "bearer": []
    }
  ],
  "tags": [
    {
      "name": "Jobs",
      "description": "Recordings: start, follow, download and delete them."
    }
  ],
  "paths": {
    "/jobs": {
      "post": {
        "operationId": "createJob",
        "tags": [
          "Jobs"
        ],
        "summary": "Start a recording",
        "description": "Queues a recording and answers with the job right away; it records in the background.",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "description": "Makes retries safe: repeating the request with the same key returns the job it created instead of recording again. Scoped to your account; reusing a key for a different body answers 409. When a request failed before the recording service accepted it (5xx), a retry with the same key records.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "pattern": "^[\\x21-\\x7e]+$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobRequest"
              },
              "examples": {
                "banner": {
                  "summary": "A 300×600 banner, 15 seconds",
                  "value": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  }
                },
                "markers": {
                  "summary": "Timed by the creative’s trim_start() marker",
                  "value": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "sync": "markers",
                    "markerWaitSec": 10
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The job, queued.",
            "headers": {
              "idempotent-replayed": {
                "description": "true when an earlier request with this Idempotency-Key created the job.",
                "schema": {
                  "type": "string",
                  "const": "true"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                },
                "example": {
                  "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "status": "queued",
                  "progress": 0,
                  "createdAt": "2026-10-22T09:14:03.120Z",
                  "finishedAt": null,
                  "request": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  },
                  "error": null,
                  "report": null,
                  "video": null,
                  "renderer": "cpu",
                  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "credits": 1
                }
              }
            }
          },
          "400": {
            "description": "invalid_job: the request is not valid, and issues lists each field. bad_request: a malformed JSON body or Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "insufficient_credits: your account has too few credits for this recording; credits says how many it needs and how many you have.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "limit_exceeded: the job is beyond your limits (duration, size, frame rate), see issues. account_disabled: the account is disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "409": {
            "description": "idempotency_conflict: the Idempotency-Key was used for a different request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "description": "exceeds_capacity: too large for the recording service; see reasons.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "rate_limited: too many requests; retry after Retry-After seconds. too_many_active_jobs: wait until one of your recordings finishes. daily_limit: you reached your recordings per 24 hours.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the limit resets.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          },
          "502": {
            "$ref": "#/components/responses/RecorderUnavailable"
          },
          "504": {
            "$ref": "#/components/responses/RecorderTimeout"
          }
        }
      },
      "get": {
        "operationId": "listJobs",
        "tags": [
          "Jobs"
        ],
        "summary": "List recordings",
        "description": "Your recordings, newest first, whether they were started here, in the web app or through MCP. Deleted ones are left out unless you ask for them. Lists leave video.url empty.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "active (queued, running or storing), or a comma-separated list of queued, running, storing, ready, failed, deleted, expired. By default, everything but deleted.",
            "schema": {
              "type": "string"
            },
            "example": "ready,failed"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "nextCursor from the previous page.",
            "schema": {
              "type": "string",
              "maxLength": 300
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of jobs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobPage"
                },
                "example": {
                  "items": [
                    {
                      "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                      "status": "running",
                      "progress": 40,
                      "createdAt": "2026-10-22T09:14:03.120Z",
                      "finishedAt": null,
                      "request": {
                        "url": "https://example.com/banner/",
                        "width": 300,
                        "height": 600,
                        "durationSec": 15,
                        "format": "mp4"
                      },
                      "error": null,
                      "report": null,
                      "video": null,
                      "renderer": "cpu",
                      "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                      "credits": 1
                    }
                  ],
                  "nextCursor": null
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    },
    "/jobs/estimate": {
      "post": {
        "operationId": "estimateJob",
        "tags": [
          "Jobs"
        ],
        "summary": "Estimate a recording",
        "description": "Checks a request against the schema, your limits and the recording service, without recording anything. It answers 200 even when the request can’t run: read ok, and the problems in invalidIssues, limitIssues and capacity.reasons.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobRequest"
              },
              "example": {
                "url": "https://example.com/banner/",
                "width": 300,
                "height": 600,
                "durationSec": 15,
                "format": "mp4"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The verdict and the estimate.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EstimateResult"
                },
                "example": {
                  "ok": true,
                  "invalidIssues": [],
                  "limitIssues": [],
                  "capacity": {
                    "ok": true,
                    "reasons": []
                  },
                  "estimate": {
                    "captureSec": 24,
                    "memoryMb": 900,
                    "diskMb": 300,
                    "intermediateMb": 40,
                    "outputMb": 3
                  },
                  "renderer": "cpu",
                  "credits": {
                    "cost": 1,
                    "balance": 49
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          },
          "502": {
            "$ref": "#/components/responses/RecorderUnavailable"
          },
          "504": {
            "$ref": "#/components/responses/RecorderTimeout"
          }
        }
      }
    },
    "/jobs/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "operationId": "getJob",
        "tags": [
          "Jobs"
        ],
        "summary": "Get a recording",
        "description": "One recording. Once it is ready, video.url is a fresh signed link, valid for one hour.",
        "responses": {
          "200": {
            "description": "The job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                },
                "example": {
                  "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "status": "ready",
                  "progress": 100,
                  "createdAt": "2026-10-22T09:14:03.120Z",
                  "finishedAt": "2026-10-22T09:14:41.877Z",
                  "request": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  },
                  "error": null,
                  "report": {
                    "codec": "h264",
                    "width": 300,
                    "height": 600,
                    "frameRate": "30/1",
                    "frames": 450,
                    "durationSec": 15,
                    "bitrateMbps": 0.98,
                    "bytes": 1843200,
                    "audio": false,
                    "anchor": "load",
                    "droppedPct": 0,
                    "effectiveFps": 30,
                    "pageFps": 60,
                    "consoleErrors": 0,
                    "pageErrors": 0,
                    "failedRequests": 0,
                    "warnings": []
                  },
                  "video": {
                    "url": "https://…",
                    "expiresAt": "2026-10-22T10:14:52.004Z",
                    "bytes": 1843200,
                    "contentType": "video/mp4"
                  },
                  "renderer": "cpu",
                  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "credits": 1
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      },
      "delete": {
        "operationId": "deleteJob",
        "tags": [
          "Jobs"
        ],
        "summary": "Delete a recording",
        "description": "Deletes the stored video. The job stays, with status deleted, so your history and limits still add up. Deleting it again answers the same.",
        "responses": {
          "200": {
            "description": "The job, deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                },
                "example": {
                  "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "status": "deleted",
                  "progress": 100,
                  "createdAt": "2026-10-22T09:14:03.120Z",
                  "finishedAt": "2026-10-22T09:14:41.877Z",
                  "request": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  },
                  "error": null,
                  "report": {
                    "codec": "h264",
                    "width": 300,
                    "height": 600,
                    "frameRate": "30/1",
                    "frames": 450,
                    "durationSec": 15,
                    "bitrateMbps": 0.98,
                    "bytes": 1843200,
                    "audio": false,
                    "anchor": "load",
                    "droppedPct": 0,
                    "effectiveFps": 30,
                    "pageFps": 60,
                    "consoleErrors": 0,
                    "pageErrors": 0,
                    "failedRequests": 0,
                    "warnings": []
                  },
                  "video": null,
                  "renderer": "cpu",
                  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "credits": 1
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    },
    "/jobs/{id}/video": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "operationId": "getJobVideo",
        "tags": [
          "Jobs"
        ],
        "summary": "Download the video",
        "description": "Redirects to the file through a fresh signed link. Send the key as Authorization: HTTP clients drop that header when the redirect leads to another host, so it never reaches the storage.",
        "parameters": [
          {
            "name": "download",
            "in": "query",
            "description": "Serve the file as an attachment.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "The file is at Location, valid for one hour.",
            "headers": {
              "Location": {
                "description": "A signed link to the file.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "not_ready: the video is not available (yet, or any more).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    }
  },
  "webhooks": {
    "job.ready": {
      "post": {
        "operationId": "jobReady",
        "summary": "A recording is ready",
        "description": "Sent to your webhook endpoint when a recording’s video is stored; job.video.url is valid for an hour. Signed with the endpoint’s secret.",
        "security": [],
        "parameters": [
          {
            "name": "x-event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "const": "job.ready"
            }
          },
          {
            "name": "x-delivery-id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The same on every retry of this delivery: use it to skip duplicates."
          },
          {
            "name": "x-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d+$"
            },
            "description": "Unix time in seconds when this attempt was signed."
          },
          {
            "name": "x-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            },
            "description": "sha256= and the hex HMAC-SHA256 of `${x-timestamp}.${raw body}`, keyed with the endpoint’s secret (whsec_…). Check it, and that the timestamp is recent, before trusting the body."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobEvent"
              },
              "example": {
                "event": "job.ready",
                "job": {
                  "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "status": "ready",
                  "progress": 100,
                  "createdAt": "2026-10-22T09:14:03.120Z",
                  "finishedAt": "2026-10-22T09:14:41.877Z",
                  "request": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  },
                  "error": null,
                  "report": {
                    "codec": "h264",
                    "width": 300,
                    "height": 600,
                    "frameRate": "30/1",
                    "frames": 450,
                    "durationSec": 15,
                    "bitrateMbps": 0.98,
                    "bytes": 1843200,
                    "audio": false,
                    "anchor": "load",
                    "droppedPct": 0,
                    "effectiveFps": 30,
                    "pageFps": 60,
                    "consoleErrors": 0,
                    "pageErrors": 0,
                    "failedRequests": 0,
                    "warnings": []
                  },
                  "video": {
                    "url": "https://…",
                    "expiresAt": "2026-10-22T10:14:52.004Z",
                    "bytes": 1843200,
                    "contentType": "video/mp4"
                  },
                  "renderer": "cpu",
                  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "credits": 1
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered. Any other answer, a redirect, or no answer within 10 seconds is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours."
          }
        }
      }
    },
    "job.failed": {
      "post": {
        "operationId": "jobFailed",
        "summary": "A recording failed",
        "description": "Sent to your webhook endpoint when a recording fails; job.error says why. Signed with the endpoint’s secret.",
        "security": [],
        "parameters": [
          {
            "name": "x-event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "const": "job.failed"
            }
          },
          {
            "name": "x-delivery-id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The same on every retry of this delivery: use it to skip duplicates."
          },
          {
            "name": "x-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d+$"
            },
            "description": "Unix time in seconds when this attempt was signed."
          },
          {
            "name": "x-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            },
            "description": "sha256= and the hex HMAC-SHA256 of `${x-timestamp}.${raw body}`, keyed with the endpoint’s secret (whsec_…). Check it, and that the timestamp is recent, before trusting the body."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobEvent"
              },
              "example": {
                "event": "job.failed",
                "job": {
                  "id": "4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "status": "failed",
                  "progress": 0,
                  "createdAt": "2026-10-22T09:14:03.120Z",
                  "finishedAt": "2026-10-22T09:14:09.311Z",
                  "request": {
                    "url": "https://example.com/banner/",
                    "width": 300,
                    "height": 600,
                    "durationSec": 15,
                    "format": "mp4"
                  },
                  "error": {
                    "kind": "creative",
                    "code": "navigation_failed",
                    "message": "navigation failed: net::ERR_NAME_NOT_RESOLVED"
                  },
                  "report": null,
                  "video": null,
                  "renderer": "cpu",
                  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10",
                  "credits": 1
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered. Any other answer, a redirect, or no answer within 10 seconds is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours."
          }
        }
      }
    },
    "webhook.test": {
      "post": {
        "operationId": "webhookTest",
        "summary": "Test delivery",
        "description": "Sent to your webhook endpoint by Send test event on the API settings page. Signed with the endpoint’s secret.",
        "security": [],
        "parameters": [
          {
            "name": "x-event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "const": "webhook.test"
            }
          },
          {
            "name": "x-delivery-id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The same on every retry of this delivery: use it to skip duplicates."
          },
          {
            "name": "x-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d+$"
            },
            "description": "Unix time in seconds when this attempt was signed."
          },
          {
            "name": "x-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            },
            "description": "sha256= and the hex HMAC-SHA256 of `${x-timestamp}.${raw body}`, keyed with the endpoint’s secret (whsec_…). Check it, and that the timestamp is recent, before trusting the body."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TestEvent"
              },
              "example": {
                "event": "webhook.test"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered. Any other answer, a redirect, or no answer within 10 seconds is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours."
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "JobRequest": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 4096,
            "description": "The creative or page to record: a public http(s) URL. The recorder runs on the internet: localhost and private networks are out of reach.",
            "format": "uri"
          },
          "width": {
            "type": "integer",
            "minimum": 16,
            "maximum": 7680,
            "multipleOf": 2,
            "description": "Viewport width in CSS pixels: even, 16–7680. Use the creative’s size, e.g. 300 for a 300×250 banner or 1920 for a full HD page."
          },
          "height": {
            "type": "integer",
            "minimum": 16,
            "maximum": 7680,
            "multipleOf": 2,
            "description": "Viewport height in CSS pixels: even, 16–7680."
          },
          "fps": {
            "type": [
              "number",
              "string"
            ],
            "description": "Frame rate: one of 15, 23.976, 24, 25, 29.97, 30, 59.94, 60 (default 30). For gif, a whole number from 1 to 30."
          },
          "durationSec": {
            "type": "number",
            "minimum": 1,
            "maximum": 300,
            "description": "Length of the video in seconds (1–300; your account limit applies). With trim_end() it is the maximum."
          },
          "startAt": {
            "anyOf": [
              {
                "type": "string",
                "const": "load"
              },
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 120000
              }
            ],
            "description": "Where the video starts: \"load\" (default: the load event, or the first trim_start() call) or that many milliseconds after it (0–120000)."
          },
          "sync": {
            "type": "string",
            "enum": [
              "auto",
              "load",
              "markers"
            ],
            "description": "How trim markers are used: auto (default: markers when the page calls trim_start(), else the load event), load (ignore markers) or markers (fail when trim_start() is not called within markerWaitSec)."
          },
          "markerWaitSec": {
            "type": "number",
            "minimum": 0,
            "maximum": 60,
            "description": "How long to wait for trim_start() after load, in seconds (0–60, default 10)."
          },
          "markerLatencyMs": {
            "type": "number",
            "minimum": 0,
            "maximum": 1000,
            "description": "Overrides the measured delay between a marker call and the frame it marks (0–1000 ms). Rarely needed."
          },
          "format": {
            "type": "string",
            "enum": [
              "mp4",
              "webm",
              "gif"
            ],
            "description": "mp4 (default, H.264), webm or gif."
          },
          "crf": {
            "type": "integer",
            "minimum": 10,
            "maximum": 35,
            "description": "Quality for mp4 and webm, from 10 (best, largest) to 35 (smallest). Not together with bitrateMbps; not for gif."
          },
          "bitrateMbps": {
            "type": "number",
            "minimum": 1,
            "maximum": 100,
            "description": "A target bitrate in Mbps (1–100) instead of crf, for platforms with a bitrate spec (e.g. DOOH). Not for gif."
          },
          "bitrateMode": {
            "type": "string",
            "enum": [
              "vbr",
              "cbr"
            ],
            "description": "vbr (default) or cbr. Needs bitrateMbps."
          },
          "pixelFormat": {
            "type": "string",
            "enum": [
              "yuv420p",
              "yuv422p",
              "yuv444p"
            ],
            "description": "yuv420p (default; plays everywhere), yuv422p or yuv444p."
          },
          "audio": {
            "type": "boolean",
            "description": "Record the page’s audio too. Not for gif."
          },
          "loop": {
            "type": "boolean",
            "description": "gif only: whether the gif loops."
          },
          "crop": {
            "type": "object",
            "properties": {
              "x": {
                "type": "integer",
                "minimum": 0,
                "multipleOf": 2
              },
              "y": {
                "type": "integer",
                "minimum": 0,
                "multipleOf": 2
              },
              "width": {
                "type": "integer",
                "minimum": 16,
                "maximum": 7680,
                "multipleOf": 2
              },
              "height": {
                "type": "integer",
                "minimum": 16,
                "maximum": 7680,
                "multipleOf": 2
              }
            },
            "required": [
              "x",
              "y",
              "width",
              "height"
            ],
            "additionalProperties": false,
            "description": "Record only this region of the viewport: { x, y, width, height } in pixels, even values, inside the viewport."
          },
          "outputSize": {
            "type": "object",
            "properties": {
              "width": {
                "type": "integer",
                "minimum": 16,
                "maximum": 7680,
                "multipleOf": 2
              },
              "height": {
                "type": "integer",
                "minimum": 16,
                "maximum": 7680,
                "multipleOf": 2
              }
            },
            "required": [
              "width",
              "height"
            ],
            "additionalProperties": false,
            "description": "Scale the video to { width, height } (even values); see fit."
          },
          "fit": {
            "type": "string",
            "enum": [
              "fill",
              "contain",
              "cover"
            ],
            "description": "How the frame fills outputSize: fill (stretch), contain (letterbox in background) or cover (crop to fill)."
          },
          "background": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$",
            "description": "Letterbox colour for fit: contain, as #rrggbb."
          },
          "renderer": {
            "type": "string",
            "enum": [
              "auto",
              "cpu",
              "gpu"
            ],
            "description": "auto (default), cpu or gpu. Leave it on auto."
          }
        },
        "required": [
          "url",
          "width",
          "height",
          "durationSec"
        ],
        "additionalProperties": false,
        "description": "What to record. Only url, width, height and durationSec are required; unknown fields are refused. Rules across fields:\n- gif: fps is a whole number from 1 to 30; no crf, bitrateMbps, bitrateMode or audio; the output (outputSize, else crop, else the viewport) is at most 1920×1080 pixels.\n- mp4 and webm: fps is one of 15, 23.976, 24, 25, 29.97, 30, 59.94, 60; loop is gif only.\n- crf or bitrateMbps, not both; bitrateMode needs bitrateMbps.\n- The viewport and outputSize are at most 7680×4320 pixels; crop lies inside the viewport.\n- Your account’s limits (duration, size, frame rate) apply on top."
      },
      "Job": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "storing",
              "ready",
              "failed",
              "deleted",
              "expired"
            ],
            "description": "- queued: Accepted, waiting for a recorder.\n- running: Recording; progress goes from 0 to 100.\n- storing: Recorded; being copied to your storage.\n- ready: Done: the video can be downloaded.\n- failed: Did not finish; error says why (kind, code, message).\n- deleted: You deleted the video.\n- expired: The video passed the storage retention period."
          },
          "progress": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "finishedAt": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ]
          },
          "request": {
            "$ref": "#/components/schemas/JobRequest"
          },
          "error": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/JobError"
              },
              {
                "type": "null"
              }
            ]
          },
          "report": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Report"
              },
              {
                "type": "null"
              }
            ]
          },
          "video": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Video"
              },
              {
                "type": "null"
              }
            ],
            "description": "Set once the job is ready."
          },
          "renderer": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "cpu",
                  "gpu"
                ]
              },
              {
                "type": "null"
              }
            ],
            "description": "What request.renderer resolved to; null until the recording service accepted the job."
          },
          "pageUrl": {
            "type": "string",
            "description": "The recording’s page in the web app, for a person (the owner signs in)."
          },
          "credits": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0
              },
              {
                "type": "null"
              }
            ],
            "description": "Credits the recording used: 0 once a failed recording gave them back, null when it was free."
          }
        },
        "required": [
          "id",
          "status",
          "progress",
          "createdAt",
          "finishedAt",
          "request",
          "error",
          "report",
          "video",
          "renderer",
          "pageUrl",
          "credits"
        ],
        "description": "A recording. New fields may be added; existing ones won’t change."
      },
      "JobPage": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Job"
            }
          },
          "nextCursor": {
            "description": "The cursor of the next page; null on the last one.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "Video": {
        "type": "object",
        "properties": {
          "url": {
            "description": "A signed link to the file, valid until expiresAt. null in lists: GET /jobs/{id} and /jobs/{id}/video mint a fresh one.",
            "type": [
              "string",
              "null"
            ]
          },
          "expiresAt": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ]
          },
          "bytes": {
            "type": [
              "integer",
              "null"
            ]
          },
          "contentType": {
            "description": "e.g. video/mp4.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "url",
          "expiresAt",
          "bytes",
          "contentType"
        ]
      },
      "JobError": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "description": "creative (the page: it crashed, did not load in time or never called trim_start()), input, capture, resource, timeout or infra (the recording service), recorder or storage."
          },
          "code": {
            "type": "string",
            "description": "Machine-readable, e.g. load_timeout or markers_missing."
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "kind",
          "code",
          "message"
        ],
        "description": "Why the recording failed."
      },
      "Report": {
        "type": "object",
        "properties": {
          "codec": {
            "description": "e.g. h264.",
            "type": [
              "string",
              "null"
            ]
          },
          "width": {
            "type": [
              "integer",
              "null"
            ]
          },
          "height": {
            "type": [
              "integer",
              "null"
            ]
          },
          "frameRate": {
            "description": "A fraction, e.g. 30/1 or 30000/1001.",
            "type": [
              "string",
              "null"
            ]
          },
          "frames": {
            "type": [
              "integer",
              "null"
            ]
          },
          "durationSec": {
            "type": [
              "number",
              "null"
            ]
          },
          "bitrateMbps": {
            "type": [
              "number",
              "null"
            ]
          },
          "bytes": {
            "type": [
              "integer",
              "null"
            ]
          },
          "audio": {
            "type": "boolean",
            "description": "The video has an audio track."
          },
          "anchor": {
            "description": "What the video starts from: load (the load event) or trim_start (the creative’s marker).",
            "type": [
              "string",
              "null"
            ]
          },
          "droppedPct": {
            "description": "Frames the capture dropped, in percent.",
            "type": [
              "number",
              "null"
            ]
          },
          "effectiveFps": {
            "description": "The frame rate actually captured.",
            "type": [
              "number",
              "null"
            ]
          },
          "pageFps": {
            "description": "The page’s own animation frame rate (requestAnimationFrame).",
            "type": [
              "number",
              "null"
            ]
          },
          "consoleErrors": {
            "type": "integer",
            "description": "console.error calls on the page."
          },
          "pageErrors": {
            "type": "integer",
            "description": "Uncaught exceptions on the page."
          },
          "failedRequests": {
            "type": "integer",
            "description": "Requests of the page that failed."
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "codec",
          "width",
          "height",
          "frameRate",
          "frames",
          "durationSec",
          "bitrateMbps",
          "bytes",
          "audio",
          "anchor",
          "droppedPct",
          "effectiveFps",
          "pageFps",
          "consoleErrors",
          "pageErrors",
          "failedRequests",
          "warnings"
        ],
        "description": "What was recorded; every key is always present."
      },
      "EstimateResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "POST /jobs would accept the request now."
          },
          "invalidIssues": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Issue"
            },
            "description": "What POST /jobs would answer as 400 invalid_job."
          },
          "limitIssues": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Issue"
            },
            "description": "What is beyond your account’s limits (403 limit_exceeded)."
          },
          "capacity": {
            "type": "object",
            "properties": {
              "ok": {
                "type": "boolean"
              },
              "reasons": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            },
            "required": [
              "ok",
              "reasons"
            ],
            "description": "Whether the recording service can take it (422 exceeds_capacity)."
          },
          "estimate": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Estimate"
              },
              {
                "type": "null"
              }
            ]
          },
          "renderer": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "cpu",
                  "gpu"
                ]
              },
              {
                "type": "null"
              }
            ]
          },
          "credits": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "cost": {
                    "type": "integer",
                    "description": "Credits the recording would use."
                  },
                  "balance": {
                    "type": "integer",
                    "description": "Credits your account has now."
                  }
                },
                "required": [
                  "cost",
                  "balance"
                ]
              },
              {
                "type": "null"
              }
            ],
            "description": "null when recordings aren’t charged. ok is false when cost is above balance (402 insufficient_credits)."
          }
        },
        "required": [
          "ok",
          "invalidIssues",
          "limitIssues",
          "capacity",
          "estimate",
          "renderer",
          "credits"
        ]
      },
      "Estimate": {
        "type": "object",
        "properties": {
          "captureSec": {
            "type": "number"
          },
          "memoryMb": {
            "type": "number"
          },
          "diskMb": {
            "type": "number"
          },
          "intermediateMb": {
            "type": "number"
          },
          "outputMb": {
            "type": "number"
          }
        },
        "required": [
          "captureSec",
          "memoryMb",
          "diskMb",
          "intermediateMb",
          "outputMb"
        ]
      },
      "Issue": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string",
            "description": "The field, dotted when nested (crop.width)."
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "path",
          "message"
        ]
      },
      "ApiError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "description": "The code; each response lists its own."
          },
          "message": {
            "type": "string"
          },
          "issues": {
            "description": "Invalid fields or parameters.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Issue"
            }
          },
          "reasons": {
            "description": "Why the recording service can’t take the job.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "credits": {
            "description": "insufficient_credits: what the recording needs and what your account has.",
            "type": "object",
            "properties": {
              "needed": {
                "type": "integer"
              },
              "balance": {
                "type": "integer"
              }
            },
            "required": [
              "needed",
              "balance"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "JobEvent": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "job.ready",
              "job.failed"
            ]
          },
          "job": {
            "$ref": "#/components/schemas/Job"
          }
        },
        "required": [
          "event",
          "job"
        ]
      },
      "TestEvent": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "const": "webhook.test"
          }
        },
        "required": [
          "event"
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "bad_request: a malformed query parameter or JSON body; issues lists the parameters.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "invalid_api_key: no key, or the key is wrong or revoked.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "Forbidden": {
        "description": "account_disabled: the account is disabled.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "NotFound": {
        "description": "not_found: no such recording in your account.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "payload_too_large: the body is over 64 kB.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "RateLimited": {
        "description": "rate_limited: too many requests; retry after Retry-After seconds.",
        "headers": {
          "Retry-After": {
            "description": "Seconds until the limit resets.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "Internal": {
        "description": "internal: an unexpected error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "RecorderUnavailable": {
        "description": "recorder_unavailable or recorder_auth: the recording service is unavailable; retry later (with the same Idempotency-Key).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      },
      "RecorderTimeout": {
        "description": "recorder_timeout: the recording service did not answer in time; retry later (with the same Idempotency-Key).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ApiError"
            }
          }
        }
      }
    },
    "parameters": {
      "JobId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The job’s id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      }
    },
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Your API key, rh_live_…"
      },
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same key, as Authorization: Bearer rh_live_…"
      }
    }
  }
}