Developers

AutoToast Render API

Managed HyperFrames rendering over a simple REST API, built for your apps and AI agents like Lovable, ChatGPT and Claude.

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 used

GET /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.com

Polling (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.