RecordHTML

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.)

Check your key: GET /v1/jobs?limit=1
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.

POST /v1/jobs
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.

GET /v1/jobs/{id}
curl "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
  -H "x-api-key: $RECORDHTML_API_KEY"
A 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", "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.

GET /v1/jobs/{id}/video
# -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.

Paste a key, or sign in to create a test key right here. No account yet? Create one.

POST /v1/jobs/estimate
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

MethodPathWhat it does
POST/v1/jobsStart a recording. Answers 202 with the job.
POST/v1/jobs/estimateValidate a request and estimate it against your limits; nothing is recorded.
GET/v1/jobsYour 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}/videoRedirects (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, height and durationSec are required.
Idempotency-Keyheader, optional
Makes retries safe: the same key returns the same job instead of recording again. See idempotency.
POST /v1/jobs
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"
  }'
Response: 202 Accepted
{
  "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.
POST /v1/jobs/estimate
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"
  }'
Response: 200 OK
{
  "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, optional
active (queued, running or storing), or a comma-separated list of statuses.
limitquery, optional
1 to 100; 20 by default.
cursorquery, optional
The nextCursor of 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.

GET /v1/jobs
curl "https://api.recordhtml.com/v1/jobs?status=active&limit=20" \
  -H "x-api-key: $RECORDHTML_API_KEY"
Response: 200 OK
{
  "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.
GET /v1/jobs/{id}
curl "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
  -H "x-api-key: $RECORDHTML_API_KEY"
Response: 200 OK
{
  "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, optional
1 makes the file an attachment, for browsers and download managers.
GET /v1/jobs/{id}/video
# -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"
Response: 302 Found
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.

idpath
The job id.
DELETE /v1/jobs/{id}
curl -X DELETE "https://api.recordhtml.com/v1/jobs/$JOB_ID" \
  -H "x-api-key: $RECORDHTML_API_KEY"
Response: 200 OK
{
  "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.

FieldMeaning
urlRequired. The creative or page to record (http or https, reachable from the internet).
width, heightRequired. Viewport in pixels: even numbers, 16 to 7680.
durationSecRequired. Seconds to record, up to 300 (your account limit may be lower).
formatmp4 (default), webm or gif.
fps15, 23.976, 24, 25, 29.97, 30 (default), 59.94 or 60; for gif a whole number from 1 to 30.
startAt, syncWhen recording starts: on load, after a delay in milliseconds, or at the creative’s timing markers.
audio, loopRecord the page’s audio (mp4, webm); loop a gif.
crf, bitrateMbps, bitrateMode, pixelFormatEncoding quality: a constant quality or a target bitrate (vbr or cbr), and the chroma format.
crop, outputSize, fit, backgroundRecord a region of the viewport and scale it to another output size.
rendererauto (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.
Two separate places in your creative's lifecycle
// 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 optionAPI syncBehavior
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 markersmarkersUse trim_start(); fail if it is missing when the wait time expires. trim_end() is optional.
Ignore markersloadIgnore 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.

API request: require a start marker, keep at most 15 seconds
{
  "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.

StatusMeaning
queuedAccepted, waiting for a recorder.
runningRecording; progress goes from 0 to 100.
storingRecorded; being copied to your storage.
readyDone: the video can be downloaded.
failedDid not finish; error says why (kind, code, message).
deletedYou deleted the video.
expiredThe 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.

HTTPerrorMeaning
400invalid_jobThe request is not valid; issues lists each field.
400bad_requestA bad query parameter, header or JSON body.
401invalid_api_keyNo key, or the key is wrong or revoked.
403account_disabledThe account is disabled.
403limit_exceededThe job is beyond your limits (duration, size, frame rate); see issues.
404not_foundNo such recording in your account.
409not_readyThe video is not available (yet, or any more).
409idempotency_conflictThe Idempotency-Key was used for a different request.
422exceeds_capacityToo large for the recording service; see reasons.
429too_many_active_jobsWait until one of your recordings finishes.
429daily_limitYou reached your recordings per 24 hours.
429rate_limitedToo many requests; retry after the retry-after header’s seconds.
502, 504recorder_unavailable, recorder_timeoutThe 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.

A delivery
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://…", … }, … } }
Check the signature
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));
}