Quickstart
- Buy a Render plan at autotoast.com/render. Your server builds in a few minutes and you get an email when it's ready.
- Open Render → API keys, create a key, tick the servers it may use. The key (rk_…) is shown once.
- Press Copy prompt and paste it into Lovable, ChatGPT, Claude or Cursor. The agent asks for the key in its secure secret box.
- Your server code calls GET /v1/health to confirm, then POST /v1/render-jobs and polls until the MP4 is ready.
Authentication
Every request needs Authorization: Bearer <RENDER_SERVER_SECRET>. Keys only work on servers you ticked, can expire, are rate-limited (default 60 requests/minute) and can be revoked instantly; revocation takes effect within about 10 seconds. Call the API from server code only — never ship the key to a browser. Base URL is your server address, e.g. https://render-ab12cd34.toastsrv.com.
GET /v1/health
Checks the key and the server.
curl -H "Authorization: Bearer $RENDER_SERVER_SECRET" $RENDER_SERVER_URL/v1/health
→ 200 { "ok": true, "authenticated": true, "serverId": "…", "version": "0.2.0", "hyperframes": "0.8.139",
"sizes": ["920x1080","1080x1920","1000x1000"], "concurrency": 2, "queue": 0, "running": 0 }POST /v1/render-jobs
Submit a HyperFrames project. Send an Idempotency-Key header so retries never create duplicates.
POST $RENDER_SERVER_URL/v1/render-jobs
Authorization: Bearer $RENDER_SERVER_SECRET
Idempotency-Key: ad-1234-v1
Content-Type: application/json
{
"width": 1080, "height": 1920, // required: 1080x1920, 920x1080 or 1000x1000
"project": { // required: HyperFrames project, index.html required
"files": {
"index.html": "<!doctype html>…", // text files as strings
"assets/logo.png": { "base64": "iVBOR…" } // binary files as base64
}
},
"audio": { "url": "https://…/voice.mp3" }, // required: { "url" } (public https) or { "base64" }
"webhookUrl": "https://yourapp.com/api/render-done" // optional, public https
}
→ 202 { "id": "8f1c…", "status": "preparing", "width": 1080, "height": 1920, "downloadUrl": null, … }
→ 200 same body if the Idempotency-Key was already usedGET /v1/render-jobs/{id}
→ 200 {
"id": "8f1c…",
"status": "queued|preparing|rendering|encoding|checking|uploading|ready|failed",
"width": 1080, "height": 1920,
"submittedAt": "…", "startedAt": "…", "finishedAt": "…",
"durationSeconds": 30.0, "sizeBytes": 8123456,
"downloadUrl": "https://… (signed, valid 1 hour; null until ready)",
"error": null
}GET /v1/render-jobs?limit=20
Lists the most recent jobs created with this key (max 100).
→ 200 { "jobs": [ { …job… } ] }HyperFrames project format
A HyperFrames project is an HTML page with a root composition element and a paused GSAP timeline registered on window.__timelines. Renders run with no internet access, so include GSAP (and any fonts or images) as project files rather than loading them from a CDN. The audio track is required and must not be silent; the video length follows the composition duration and is trimmed to the audio. Minimal example (files map):
{
"index.html": "<!doctype html><html><head><meta charset="UTF-8"><script src="gsap.min.js"></script>
<style>html,body{margin:0;width:1080px;height:1920px;overflow:hidden;background:#241E1A}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#title{color:#E7A84B;font:700 120px sans-serif}</style></head><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="6" data-width="1080" data-height="1920">
<h1 id="title" class="clip" data-start="0" data-duration="6" data-track-index="0">Hello</h1>
</div>
<script>const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { opacity: 0, y: 40 }, { opacity: 1, y: 0, duration: 0.8 }, 0);
window.__timelines["main"] = tl; tl.seek(0);</script></body></html>",
"gsap.min.js": "<contents of https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js>"
}
Rules: root has data-composition-id, data-width and data-height matching the job size; every timed element has data-start/data-duration; register window.__timelines[compositionId]. Full reference: https://hyperframes.heygen.comPolling (recommended)
async function waitForVideo(id: string) {
for (;;) {
const r = await fetch(`${process.env.RENDER_SERVER_URL}/v1/render-jobs/${id}`, {
headers: { Authorization: `Bearer ${process.env.RENDER_SERVER_SECRET}` },
});
if (r.status === 429) { await new Promise((s) => setTimeout(s, 30_000)); continue; }
const job = await r.json();
if (job.status === "ready") return job.downloadUrl;
if (job.status === "failed") throw new Error(job.error);
await new Promise((s) => setTimeout(s, 5_000));
}
}Webhooks
If you pass webhookUrl we POST the job JSON when it finishes. Verify x-autotoast-signature = hex HMAC-SHA256(your API key, x-autotoast-timestamp + "." + raw body) and reject timestamps older than 5 minutes.
// Node
import { createHmac, timingSafeEqual } from "node:crypto";
const expected = createHmac("sha256", process.env.RENDER_SERVER_SECRET!).update(`${ts}.${rawBody}`).digest("hex");
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
# Python
import hmac, hashlib
expected = hmac.new(secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, signature)Errors
- 400 — invalid body (size, missing index.html or audio, bad URL). The message says what to fix.
- 401 — missing, malformed, expired or revoked key, or key not allowed on this server.
- 404 — job not found on this server.
- 413 — request over 60 MB. Send audio by URL instead of base64.
- 429 — rate limit reached. Respect Retry-After (seconds) then retry.
- 502/503 — temporary problem reaching the control plane or storage. Retry with backoff; your Idempotency-Key prevents duplicates.
Limits
- Sizes: 1080x1920, 920x1080, 1000x1000.
- Request 60 MB, audio 40 MB, up to 500 files, 20 minutes per render.
- Concurrent renders follow your plan (Starter 1, Pro 2, Scale 3); extra jobs queue automatically.
- Download links last 60 minutes; fetch a fresh one with GET /v1/render-jobs/{id}.
- Renders run in isolated containers with no network access.
Guide: Lovable (TanStack Start)
// src/lib/render.functions.ts — server function; the key never reaches the browser
import { createServerFn } from "@tanstack/react-start";
export const startRender = createServerFn({ method: "POST" })
.inputValidator((d: { html: string; audioUrl: string }) => d)
.handler(async ({ data }) => {
const r = await fetch(`${process.env.RENDER_SERVER_URL}/v1/render-jobs`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RENDER_SERVER_SECRET}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ width: 1080, height: 1920, project: { files: { "index.html": data.html } }, audio: { url: data.audioUrl } }),
});
if (!r.ok) throw new Error((await r.json()).error);
return r.json();
});Guide: Next.js route handler
// app/api/render/route.ts
export async function POST(req: Request) {
const body = await req.json();
const r = await fetch(`${process.env.RENDER_SERVER_URL}/v1/render-jobs`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RENDER_SERVER_SECRET}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return new Response(await r.text(), { status: r.status });
}Guide: Python
import os, requests, time
H = {"Authorization": f"Bearer {os.environ['RENDER_SERVER_SECRET']}"}
URL = os.environ["RENDER_SERVER_URL"]
job = requests.post(f"{URL}/v1/render-jobs", headers=H, json={"width": 1080, "height": 1920,
"project": {"files": {"index.html": open("index.html").read()}}, "audio": {"url": "https://…/voice.mp3"}}).json()
while (j := requests.get(f"{URL}/v1/render-jobs/{job['id']}", headers=H).json())["status"] not in ("ready", "failed"):
time.sleep(5)
print(j["downloadUrl"] or j["error"])Changelog
- 2026-10-07 — v0.2.0: /v1/health, list jobs, rate limits (429 + Retry-After), key expiry, faster revocation.
- 2026-10-07 — v0.1.0: first release.
Ready to start? See Render plans or open Render → API keys. Spec: openapi.json.