Developers
RecordHTML API
Record HTML5 ads and animations as MP4, WebM or GIF from your own code. The API uses the same real-browser recorder as the web app: jobs you start with it appear in your recordings, count against the same limits and are stored the same way, with links that keep working for as long as the video is kept.
Every example comes in curl, Node.js, Python and Go: pick a language once and the whole page follows. Each one runs as is with your key in the RECORDHTML_API_KEY environment variable. This reference is also plain Markdown for coding agents, and chat assistants can record through the MCP server.
Authentication
Create a key on the API settings page 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_…. The base URL is https://api.recordhtml.com/v1.
A key acts as your account, with your limits. The API is for servers: browsers can't call it (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. (Try it below is the exception: it runs on this site.)
curl "https://api.recordhtml.com/v1/jobs?limit=1" \
-H "x-api-key: $RECORDHTML_API_KEY"Quick start
1. Start a recording
Send the recording request. The answer is 202 with the job, status queued.
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 the job every few seconds until it is ready or failed, or let a webhook tell you.
curl "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
-H "x-api-key: $RECORDHTML_API_KEY"{
"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"
}3. Download the file
/video redirects to the file. Downloads send the key as Authorization: HTTP clients drop that header when the redirect leads to another host, so the key never reaches the storage.
# -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
Send real requests with one of your keys and see the answer, with the same request as code beside it. An estimate records nothing; a recording started here is a real one, in your recordings and within your limits.
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"
}'Send the request to see the API's answer here.
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. |
Request and response bodies are JSON. Timestamps are ISO 8601 in UTC.
Start a recording
POST/v1/jobs
Queues a recording and answers with the job right away; it records in the background.
bodyJSON, required- The recording request. Only
url,width,heightanddurationSecare required. Idempotency-Keyheader, optional- Makes retries safe: the same key returns the same job instead of recording again. See idempotency.
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"
}'{
"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.
bodyJSON, required- The same body as
POST /v1/jobs.
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"
}'{
"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 or in the web app. Deleted ones are left out unless you ask for them.
statusquery, optionalactive(queued, running or storing), or a comma-separated list of statuses.limitquery, optional- 1 to 100; 20 by default.
cursorquery, optional- The
nextCursorof the previous page; it is null on the last one.
Lists leave video.url empty: ask for the job, or its /video, for a link.
curl "https://api.recordhtml.com/v1/jobs?status=active&limit=20" \
-H "x-api-key: $RECORDHTML_API_KEY"{
"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.
idpath- The job id from the answer that created it, or from the list.
curl "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
-H "x-api-key: $RECORDHTML_API_KEY"{
"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. Before the job is ready, or after the video was deleted or expired, it answers 409 not_ready.
idpath- The job id.
downloadquery, optional1makes the file an attachment, for browsers and download managers.
# -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"HTTP/1.1 302 Found
location: https://…/banner.mp4?…
cache-control: no-storeDelete 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.
idpath- The job id.
curl -X DELETE "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
-H "x-api-key: $RECORDHTML_API_KEY"{
"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, Sync modes and trim_start() / trim_end() markers
POST /v1/jobs/estimate takes the same body and answers with ok, the problems it found (invalidIssues, limitIssues), the time and resources the recording would take, and the renderer it would run on.
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 below 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.
// 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 paste the two calls next to each other and run them together: that marks an empty clip. These functions signal timing; they do not start, stop or wait for your animation. Connect them to your own playback and completion code.
| Form option | API sync | Behavior |
|---|---|---|
| Auto (recommended) | 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. |
| Require markers | markers | Use trim_start(); fail if it is missing when the wait time expires. trim_end() is optional. |
| Ignore markers | 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. In Auto, a missing start marker falls back to the original page-load time, not the end of the wait. The first eligible start marker is used; repeated or late start calls do not restart the clip.
startAt is an offset in milliseconds from the selected start: the marker when used, otherwise page load. Omit it or use "load" for no offset. In the form this is Start → After a delay.durationSec is the output length, or the maximum length when an earlier valid trim_end() shortens it. An end marker before the selected start is ignored. Without an end marker, recording uses the full duration; Require markers only requires the start marker.
markerLatencyMs is an advanced 0–1000 ms override for the delay between JavaScript executing and the corresponding visual change reaching a captured frame. Leave it unset (blank in the form) to use the recorder's automatic measurement. It is neither a readiness timeout nor an intentional start delay.
{
"url": "https://example.com/banner/",
"width": 300,
"height": 600,
"durationSec": 15,
"sync": "markers",
"markerWaitSec": 10
}Example: the page loads, prepares assets for 3 seconds, calls trim_start(), plays a 5-second animation, then calls trim_end(). With the request above and no start delay, the output is about 5 seconds: the loader is excluded, and the 15-second duration is only a cap. Without trim_end(), it keeps 15 seconds from the start marker.
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: where it records, which is how auto resolved; null until the recording service accepts the job) and pageUrl (the recording's page in the web app, for a person to open; 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 (any printable text up to 255 characters, such as your own id for the creative version). 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 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. On top of that, each account can 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 to be told when a recording finishes, instead of polling. We send job.ready when the video is stored and job.failed when a recording fails, for all your recordings, whether they were started here or in the web app. 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_…, shown once; you can rotate it): 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, a redirect or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then given up. Deliveries can arrive more than once or out of order: use x-delivery-id to skip duplicates, and the job's status rather than the order of events. The endpoint must use https and resolve to a public address. The settings page shows recent deliveries and can send a webhook.test event.
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://…", … }, … } }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));
}