# 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. The same API is described by an [OpenAPI 3.1 document](https://recordhtml.com/openapi.json), for code generators and API clients. 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",
"credits": 1
}
```
### 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",
"credits": { "cost": 1, "balance": 49 }
}
```
### 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",
"credits": 1
}
],
"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", "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
}
```
### 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", "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
}
```
## 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. |
| 402 | insufficient_credits | Your account has too few credits for this recording; credits says how many it needs and how many you have. |
| 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));
}
```
# RecordHTML MCP server
> Connect RecordHTML to an AI assistant and ask it for videos. The hosted MCP server records web pages and HTML5 creatives to MP4, WebM or GIF with the same real-browser recorder, limits and storage as the web app and the [API](https://recordhtml.com/docs/api.md).
Server URL: `https://mcp.recordhtml.com` (Streamable HTTP, OAuth).
## Connect
Add a remote MCP server with this URL. It speaks Streamable HTTP, and you sign in with OAuth: there is no key to paste and nothing to run on your machine.
Server URL:
```
https://mcp.recordhtml.com
```
- Use the root URL as shown. `https://mcp.recordhtml.com/mcp` works too; there is no `/sse` endpoint.
- Your client opens a sign-in page: sign in or create an account, approve the access and go back to the client.
- Check that the client lists five tools: `record_html`, `get_recording`, `estimate_recording`, `list_recordings` and `delete_recording`.
- The client must support remote MCP servers with OAuth. Otherwise, [use an API key](#clients).
## Set up your client
### Claude (claude.ai and Claude Desktop)
1. Open Customize → Connectors, click + Add and choose Add custom connector.
2. Name it RecordHTML, enter `https://mcp.recordhtml.com` as the URL and add it.
3. Click Connect and sign in. On Team and Enterprise plans an owner adds the connector under Organization settings → Connectors first; each member then connects their own account. A free Claude account can add one custom connector.
### Claude Code
1. Add the server, then type `/mcp` in Claude Code, choose recordhtml and sign in in the browser.
```sh
claude mcp add --transport http recordhtml https://mcp.recordhtml.com
```
### ChatGPT
1. In Settings → Apps & Connectors → Advanced settings, turn on Developer mode.
2. Create a connector with `https://mcp.recordhtml.com` as the URL and OAuth as the authentication, and sign in.
3. Pick the connector in a chat that has developer mode on.
### Codex
1. Codex sees that the server uses OAuth and opens the sign-in page. To sign in again later, run `codex mcp login recordhtml`.
```sh
codex mcp add recordhtml --url https://mcp.recordhtml.com
```
### Cursor
1. Add the server to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project). Cursor asks you to sign in the first time it uses it.
```json
{
"mcpServers": {
"recordhtml": {
"url": "https://mcp.recordhtml.com"
}
}
}
```
### VS Code
1. Add the server to `.vscode/mcp.json` (or run MCP: Add Server), start it and sign in when VS Code asks. Turn its tools on in the chat tool picker.
```json
{
"servers": {
"recordhtml": {
"type": "http",
"url": "https://mcp.recordhtml.com"
}
}
}
```
### Other clients, or an API key
1. Any client that supports remote MCP servers with OAuth works with the URL alone.
2. Headless agents, and clients that can set headers, can send an API key from the [API settings page](https://recordhtml.com/settings/api) instead: `Authorization: Bearer rh_live_…`. Keys are refused from browser pages.
3. Cursor ignores headers for a server that offers OAuth: sign in there instead.
```sh
claude mcp add --transport http recordhtml https://mcp.recordhtml.com \
--header "Authorization: Bearer $RECORDHTML_API_KEY"
```
## Your first recording
Ask for a video in plain words; the assistant fills in the arguments.
Prompt:
```
Record https://example.com/banner/ at 300×600 for 15 seconds as an MP4.
```
Tool call:
```
record_html { "url": "https://example.com/banner/", "width": 300, "height": 600, "durationSec": 15, "format": "mp4" }
```
What the assistant reads back:
```
Recording 4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10 is ready.
Video: https://…/banner.mp4?…
(signed link, valid until 2026-10-22T10:14:52.004Z; get_recording mints a fresh one)
300×600, 30 fps, 15.0 s, mp4, 1.8 MB
Started at the load event.
Page (for a person; they sign in): https://…/jobs/4c1f0e1e-8f0a-4a52-9d1b-2f5f3c6b9a10
```
The result also carries the same data as structured content (the [job of the API](https://recordhtml.com/docs/api#job), with `pageUrl`) and a link to the video file. To save the file from a shell: `curl --fail --location --output banner.mp4 "