# RecordHTML API

> Record HTML5 ads and animations as MP4, WebM or GIF from your own code, with the same real-browser recorder, limits and storage as the web app.

Base URL: `https://api.recordhtml.com/v1`. Request and response bodies are JSON; timestamps are ISO 8601 in UTC. The examples read your key from the `RECORDHTML_API_KEY` environment variable. Chat assistants without a shell can record through the [MCP server](https://recordhtml.com/docs/mcp.md) instead.

## Authentication

Create a key on the [API settings page](https://recordhtml.com/settings/api) after signing in. It is shown once, so store it with your other secrets. Send it with every request, as `x-api-key: rh_live_…` or `Authorization: Bearer rh_live_…`.

A key acts as your account, with your limits. The API is for servers: it sends no CORS headers, so never put a key in a web page or an app. You can have up to 10 keys and revoke any of them at once.

```sh
curl "https://api.recordhtml.com/v1/jobs?limit=1" \
  -H "x-api-key: $RECORDHTML_API_KEY"
```

## Quick start

1. Start a recording: `POST /v1/jobs` answers `202` with the job, status `queued`.

```sh
curl https://api.recordhtml.com/v1/jobs \
  -H "x-api-key: $RECORDHTML_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: spring-sale-300x600-v3" \
  -d '{
    "url": "https://example.com/banner/",
    "width": 300,
    "height": 600,
    "durationSec": 15,
    "format": "mp4"
  }'
```

2. Wait until it is ready: poll `GET /v1/jobs/{id}` every few seconds until the status is `ready` or `failed`, or let a [webhook](#webhooks) tell you.

```sh
curl "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
  -H "x-api-key: $RECORDHTML_API_KEY"
```

3. Download the file: `GET /v1/jobs/{id}/video` redirects to it. Downloads send the key as `Authorization`, which HTTP clients drop when the redirect leads to another host, so the key never reaches the storage.

```sh
# -L follows the redirect to the file; curl doesn't forward Authorization to another host
curl -L -o "banner.mp4" "https://api.recordhtml.com/v1/jobs/$JOB_ID/video?download=1" \
  -H "Authorization: Bearer $RECORDHTML_API_KEY"
```

## Try it

The [web version of this page](https://recordhtml.com/docs/api#try-it) can send real requests with one of your keys, from your browser.

## Endpoints

| Method | Path | What it does |
| --- | --- | --- |
| POST | /v1/jobs | Start a recording. Answers 202 with the job. |
| POST | /v1/jobs/estimate | Validate a request and estimate it against your limits; nothing is recorded. |
| GET | /v1/jobs | Your recordings, newest first: ?status=, ?limit= (up to 100) and ?cursor= from nextCursor. |
| GET | /v1/jobs/{id} | One recording. Once it is ready, video.url is a fresh link valid for one hour. |
| GET | /v1/jobs/{id}/video | Redirects (302) to the file; ?download=1 makes it an attachment. |
| DELETE | /v1/jobs/{id} | Deletes the stored video. The job stays in the list as deleted. |

### Start a recording: POST /v1/jobs

Queues a recording and answers with the job right away; it records in the background. The body is the [recording request](#the-recording-request): only `url`, `width`, `height` and `durationSec` are required. An optional `Idempotency-Key` header makes retries safe (see [idempotency](#retries-and-idempotency)).

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

### Estimate a recording: POST /v1/jobs/estimate

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`. The body is the same as `POST /v1/jobs`.

```sh
curl https://api.recordhtml.com/v1/jobs/estimate \
  -H "x-api-key: $RECORDHTML_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://example.com/banner/",
    "width": 300,
    "height": 600,
    "durationSec": 15,
    "format": "mp4"
  }'
```

```json
{
  "ok": true,
  "invalidIssues": [],
  "limitIssues": [],
  "capacity": { "ok": true, "reasons": [] },
  "estimate": { "captureSec": 24, "memoryMb": 900, "diskMb": 300, "intermediateMb": 40, "outputMb": 3 },
  "renderer": "cpu"
}
```

### List recordings: GET /v1/jobs

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. Query: `status` (`active`, or a comma-separated list of statuses), `limit` (1 to 100; 20 by default), `cursor` (the `nextCursor` of the previous page; null on the last one). Lists leave `video.url` empty.

```sh
curl "https://api.recordhtml.com/v1/jobs?status=active&limit=20" \
  -H "x-api-key: $RECORDHTML_API_KEY"
```

```json
{
  "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"
    }
  ],
  "nextCursor": null
}
```

### Get a recording: GET /v1/jobs/{id}

One recording. Once it is `ready`, `video.url` is a fresh signed link, valid for one hour.

```json
{
  "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", "durationSec": 15, "bytes": 1843200, "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"
}
```

### Download the video: GET /v1/jobs/{id}/video

Redirects to the file through a fresh signed link; `?download=1` makes it an attachment. Before the job is ready, or after the video was deleted or expired, it answers `409 not_ready`.

```
HTTP/1.1 302 Found
location: https://…/banner.mp4?…
cache-control: no-store
```

### Delete a recording: DELETE /v1/jobs/{id}

Deletes the stored video. The job stays, with status `deleted`, so your history and limits still add up. Deleting it again answers the same.

```sh
curl -X DELETE "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
  -H "x-api-key: $RECORDHTML_API_KEY"
```

```json
{
  "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", "durationSec": 15, "bytes": 1843200, "warnings": [] },
  "video": null,
  "renderer": "cpu",
  "pageUrl": "https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10"
}
```

## The recording request

The body of `POST /v1/jobs` is the same as the recorder form in the web app. Only `url`, `width`, `height` and `durationSec` are required; unknown fields are refused.

| Field | Meaning |
| --- | --- |
| url | Required. The creative or page to record (http or https, reachable from the internet). |
| width, height | Required. Viewport in pixels: even numbers, 16 to 7680. |
| durationSec | Required. Seconds to record, up to 300 (your account limit may be lower). |
| format | mp4 (default), webm or gif. |
| fps | 15, 23.976, 24, 25, 29.97, 30 (default), 59.94 or 60; for gif a whole number from 1 to 30. |
| startAt, sync | When recording starts: on load, after a delay in milliseconds, or at the creative’s timing markers. |
| audio, loop | Record the page’s audio (mp4, webm); loop a gif. |
| crf, bitrateMbps, bitrateMode, pixelFormat | Encoding quality: a constant quality or a target bitrate (vbr or cbr), and the chroma format. |
| crop, outputSize, fit, background | Record a region of the viewport and scale it to another output size. |
| renderer | auto (default), cpu or gpu: what renders the page. auto uses the GPU for viewports above 1920×1080 at up to 30 fps. Choose gpu for heavy pages (large blurs, blend modes, video, WebGL) at large sizes; it reaches about 30 fps at 4K. When no GPU is available, gpu answers 400 invalid_job with an issue on renderer. |

## Timing markers: trim_start() and trim_end()

Add these calls to the creative's JavaScript, not to your API client. The recorder supplies both functions before the page's scripts run, including in iframes. No SDK is needed; the guards let the same creative run normally outside the recorder.

- `trim_start()` marks the beginning of the content to keep. Call it once when your assets are ready, just before starting the animation.
- `trim_end()` marks the end of the content to keep. Call it once from the animation's completion callback, after displaying the final frame and before resetting or looping.

```js
// In your creative, after assets are ready and just before playback:
if (typeof window.trim_start === 'function') window.trim_start();
// Start your animation here.

// In your animation's completion callback, after its final frame:
if (typeof window.trim_end === 'function') window.trim_end();
```

Do not put the two calls next to each other: that marks an empty clip. They signal timing; they do not start, stop or wait for your animation.

| API sync | Behavior |
| --- | --- |
| auto (default) | Use trim_start() if received within the wait time. Otherwise use page load as the start. A later trim_end() can shorten the clip, even after fallback. |
| markers | Use trim_start(); fail if it is missing when the wait time expires. trim_end() is optional. |
| load | Ignore both functions. Start from page load, without waiting for markers, and keep durationSec seconds. |

- `markerWaitSec` is how long to wait for `trim_start()` after page load: 0–60 seconds, default 10. It is not padding added to the output.
- `startAt` is an offset in milliseconds from the selected start (the marker when used, otherwise page load); omit it or use `"load"` for none.
- `durationSec` is the output length, or the maximum length when an earlier `trim_end()` shortens it. An end marker before the selected start is ignored.
- `markerLatencyMs` (0–1000) overrides the measured delay between a call and the frame it marks; leave it unset.

```json
{
  "url": "https://example.com/banner/",
  "width": 300,
  "height": 600,
  "durationSec": 15,
  "sync": "markers",
  "markerWaitSec": 10
}
```

## The job

Every endpoint returns jobs in the same shape: `id`, `status`, `progress`, `createdAt`, `finishedAt`, `request` (what you sent), `error`, `report` (codec, size, frame rate, duration, warnings), `video`, `renderer` (`cpu` or `gpu`) and `pageUrl` (the recording's page in the web app, for a person; they sign in). New fields may be added; existing ones won't change.

| Status | Meaning |
| --- | --- |
| queued | Accepted, waiting for a recorder. |
| running | Recording; progress goes from 0 to 100. |
| storing | Recorded; being copied to your storage. |
| ready | Done: the video can be downloaded. |
| failed | Did not finish; error says why (kind, code, message). |
| deleted | You deleted the video. |
| expired | The video passed the storage retention period. |

`video` is set once the job is ready. Lists leave `video.url` empty; a single job carries a signed link valid for one hour, and `/video` always redirects to a fresh one.

## Retries and idempotency

Send an `Idempotency-Key` header with `POST /v1/jobs` (printable text up to 255 characters). Repeating the request with the same key returns the job it created, with the header `idempotent-replayed: true`, instead of recording again. Reusing a key for a different body answers `409 idempotency_conflict`. If the request failed before the recording service accepted it (a `5xx` answer), retry with the same key: it records then.

## Errors

Errors are JSON: `{ "error": "code", "message": "…" }`, plus `issues` (`path`, `message`) for invalid fields or `reasons` for capacity.

| HTTP | error | Meaning |
| --- | --- | --- |
| 400 | invalid_job | The request is not valid; issues lists each field. |
| 400 | bad_request | A bad query parameter, header or JSON body. |
| 401 | invalid_api_key | No key, or the key is wrong or revoked. |
| 403 | account_disabled | The account is disabled. |
| 403 | limit_exceeded | The job is beyond your limits (duration, size, frame rate); see issues. |
| 404 | not_found | No such recording in your account. |
| 409 | not_ready | The video is not available (yet, or any more). |
| 409 | idempotency_conflict | The Idempotency-Key was used for a different request. |
| 422 | exceeds_capacity | Too large for the recording service; see reasons. |
| 429 | too_many_active_jobs | Wait until one of your recordings finishes. |
| 429 | daily_limit | You reached your recordings per 24 hours. |
| 429 | rate_limited | Too many requests; retry after the retry-after header’s seconds. |
| 502, 504 | recorder_unavailable, recorder_timeout | The recording service is unavailable; retry later (with the same Idempotency-Key). |

## Limits

The API, MCP and the web app share your account's limits: recordings in progress at a time, recordings per 24 hours, and the maximum duration, size and frame rate. Each account can also make 300 API requests a minute, start 20 recordings a minute and request 60 estimates a minute.

## Webhooks

Add an endpoint URL on the [API settings page](https://recordhtml.com/settings/api) to be told when a recording finishes, instead of polling. `job.ready` is sent when the video is stored and `job.failed` when a recording fails, for all your recordings. The `job` in the body is the same object the API returns, with a `video.url` valid for an hour.

Each delivery is signed with your endpoint's secret (`whsec_…`): `x-signature` is `sha256=` followed by the hex HMAC-SHA256 of `${x-timestamp}.${raw body}`. Check it, and that the timestamp is recent, before trusting the body. Answer with any `2xx` within 10 seconds; anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Use `x-delivery-id` to skip duplicates. The endpoint must use https and resolve to a public address.

```
POST https://example.com/hooks/recordhtml
content-type: application/json
x-event: job.ready
x-delivery-id: 9b2d5c1e-…   (the same on every retry: use it to ignore duplicates)
x-timestamp: 1792667692
x-signature: sha256=5f1c…

{ "event": "job.ready", "job": { "id": "4c1f0e1e-…", "status": "ready", "video": { "url": "https://…", … }, … } }
```

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

/** rawBody: the exact bytes received (verify before parsing the JSON). */
export function verifyRecordHtml(rawBody, headers, secret) {
  const ts = headers['x-timestamp'];
  const sig = headers['x-signature'] ?? '';
  // refuse old or replayed requests
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = 'sha256=' + createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex');
  return want.length === sig.length && timingSafeEqual(Buffer.from(want), Buffer.from(sig));
}
```
