# Renderly API — complete contract for AI agents Renderly turns a JSON composition into an MP4. This file is the whole contract in one place so you do not have to fetch the documentation page by page. Exact field-level schemas live in the OpenAPI spec at https://renderly.video/api/openapi — that file is generated from the same code that validates your request, so it cannot drift. Prefer it over anything here when the two disagree, and treat this file as the behaviour the schema does not describe. Base URL: https://renderly.video/api/v1 Auth: `Authorization: Bearer ` on every request. Create a key at https://renderly.video/docs/guide/api-keys --- ## 1. Start here: preview before you render Renderly can accept a payload, return `200`, charge credits, and produce a wrong video. Three failures are silent: 1. A replacement key that matches no variable is **ignored**, and the value the template was authored with is rendered instead. 2. Text too wide for its box **wraps onto a hidden line and the tail vanishes**. The renderer hides overflow; it does not shrink type or add an ellipsis. 3. A font family that is not a resolvable Google font **renders as Roboto**. So a successful render is not evidence that your values applied. Check first: ``` POST /api/v1/previews # same body as /renders. Free. No render job. -> previewUrl, editUrl, resolved, quote, warnings, previewId, revision ``` Two verification passes: **Pass 1 — machine readable.** Read `warnings`. Anything with `"severity": "error"` will render wrong or fail. Read `resolved` to confirm each value actually landed; if you sent `guest_name` and `resolved.guest_name` still shows the template's own text, your key did not match. **Pass 2 — visual, if you can open a web page.** `previewUrl` is public and needs no login, so a headless browser can reach it. ``` GET {previewUrl}?mode=grid # contact sheet: frames across the whole video GET {previewUrl}?frame=150 # one frame, N = seconds * fps GET {previewUrl}?mode=grid&frames=0,45,120 # exactly these frames ``` Wait for `[data-preview-ready="true"]` before you screenshot. It is set once fonts have loaded and the first frame has painted, and fonts are what move layout. Do not use a fixed sleep. Frames render as ordinary images, so screenshot the page and look at it. This is the only way to catch problems no validator can see: text colliding, an image that is not what the user described, colours that clash. **To iterate, keep the same URL:** ``` PUT /api/v1/previews/{previewId} # replaces the payload, URL unchanged. Free. ``` The body replaces rather than merges — send the complete request each time. `revision` increments and appears on the page as `data-preview-revision`, so wait for `[data-preview-revision="2"]` to be sure you are not screenshotting a cached earlier render. An invalid payload is rejected before anything is written, so a failed attempt leaves the working link intact. Only then: ``` POST /api/v1/renders # costs credits GET /api/v1/renders/{jobId} # poll until status COMPLETED, read outputUrl ``` ### Page attributes for automated viewers On the preview page root: `data-preview-ready`, `data-preview-mode`, `data-preview-revision`, `data-preview-fps`, `data-preview-duration-frames`. On each rendered frame: `data-preview-frame=""` and `data-preview-overlays=""` — how many overlays are on screen at that frame. A blank capture with a count of `0` is a deliberately empty moment between scenes; a blank capture with a non-zero count means something failed to paint. Grid tiles land on scene changes and are **not** evenly spaced in time. To check a specific moment, ask for it with `?frame=N`. Do not reason from the nearest tile — a neighbouring scene can show a different value for the same variable. Previews expire after 24 hours. Anyone holding the URL can view it; only the account that created it can open `editUrl`. --- ## 2. Endpoints | Method | Path | Cost | Notes | |---|---|---|---| | POST | `/previews` | free | Resolve a payload, return preview URL + warnings | | PUT | `/previews/{previewId}` | free | Replace payload, same URL, bumps `revision` | | POST | `/renders` | credits | Create a render job | | GET | `/renders/{jobId}` | free | Poll status; `outputUrl` when COMPLETED | | GET | `/templates` | free | Public templates with their variables | | GET | `/projects` | free | Your projects; `?dynamic=true` filters to ones with variables | | POST | `/uploads` | free | Upload a media asset | | POST | `/ai/captions` | 0.25/min | Transcribe audio into word-synced captions | | POST | `/ai/voiceover` | 0.5/1k chars | Text to speech, returns an audio URL | | GET | `/ai/jobs/{jobId}` | free | Poll an AI job | | POST/GET/DELETE | `/webhooks` | free | Manage `render.completed` / `render.failed` callbacks | | GET | `/account` | free | Credit balance and usage | | POST | `/auth/verify` | free | Verify an API key | Render status values: `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`. --- ## 3. Three request modes `/renders` and `/previews` take the same body. Provide exactly one of: **Mode 1 — direct.** You supply the whole composition. ```json { "inputProps": { "overlays": [ ... ], "width": 1080, "height": 1920, "fps": 30, "durationInFrames": 900 } } ``` **Mode 2 — template.** The server loads a public template and applies your values. ```json { "templateId": "wedding-invitation-named", "replacements": { "guest_name": "Alex" } } ``` **Mode 3 — project.** Same, against one of your own saved projects. ```json { "projectId": "clx1234567890", "replacements": { "headline": "Hello" } } ``` Optional top-level overrides on any mode: `width`, `height`, `fps`, `durationInFrames`, `webhookUrl`. **Direct mode is validated; modes 2 and 3 are not.** Template and project configs are read straight from storage, but `inputProps` passes through a schema that **strips unknown fields silently**. If you invent a field name — `autoShrink`, `maxLines`, `overflowStrategy` — it is deleted and the request still returns `200`. Only send fields that appear in the OpenAPI spec. `POST /previews` now reports these as an `UNKNOWN_FIELD` warning listing the exact dotted paths that were dropped, so preview a direct-mode payload before you render it and you will see what the schema threw away. --- ## 4. The variable model There is **no `{{ VARIABLE }}` syntax**. A variable is an overlay with `"isDynamic": true`, and its `name` is the key you use in `replacements`. Which field gets replaced depends on the overlay type: - `text`, `shape` → `content` - `video`, `image`, `sound` → `src` ```json { "overlays": [ { "type": "text", "name": "headline", "content": "Default", "isDynamic": true }, { "type": "image", "name": "background", "src": "a.jpg", "isDynamic": true }, { "type": "text", "content": "Static watermark", "isDynamic": false } ] } ``` Call `GET /templates` or `GET /projects?dynamic=true` to discover the variable names before you send values. An unmatched key is a silent no-op — this is what `UNKNOWN_REPLACEMENT` and the `resolved` map exist to catch. `GET /templates/{templateId}/variables` and `GET /projects/{projectId}/variables` return richer field definitions than the list endpoints — identical shape for both — including `overlayType`, the `field` your value is written to, and — for fixed-size text — `maxCharacters` and `linesAvailable`. A value longer than `maxCharacters` has its tail clipped: the renderer hides overflow rather than shrinking the type or adding an ellipsis. Budget against that number instead of discovering the clip afterwards. The same estimate drives `TEXT_MAY_CLIP`, so a value at or under it raises no warning. **AI values inside `replacements`:** - A sound variable accepts `{"tts": {"text": "...", "voice": "..."}}` to generate narration inline. - A caption variable accepts `{"transcribe": {"source": ""}}` to auto-caption that overlay's audio. Both cost extra credits and are charged even if the render is later rejected. --- ## 5. Warning codes Triage on `severity`, never on message text. | Code | Severity | Meaning | |---|---|---| | `UNKNOWN_REPLACEMENT` | error | Your key matched no variable, so it was ignored. Carries a `hint` with the closest real name. | | `MEDIA_UNREACHABLE` | error | A URL you supplied did not respond. Expired presigned links are the usual cause. | | `UNKNOWN_FIELD` | warning | A field inside `inputProps` is not part of the schema and was discarded. The render ignores it. Direct mode only. | | `FONT_NOT_RESOLVED` | warning | Family is not a resolvable Google font; it will render as Roboto. | | `MEDIA_URL_REWRITTEN` | warning | A media URL was normalized before rendering. | | `TEXT_MAY_CLIP` | info | Text is estimated to overflow its box. Estimated from average character width, which varies by more than 2x across fonts — treat it as a pointer to look at that overlay, not a verdict. | --- ## 6. Overlay types `text`, `video`, `image`, `sound`, `shape`, `sticker`, `caption`, `effect`. Every overlay carries `id`, `type`, `from` (start frame), `durationInFrames`, `row` (track order and base z-index), `left`, `top`, `width`, `height`, `rotation`, and a `styles` object. Positions are pixels from the canvas top-left. Get exact per-type fields from https://renderly.video/api/openapi — do not guess field names, because unknown ones are stripped without an error. ### Units are not uniform Almost everything is in **frames**. These are in **seconds**: `videoStartTime`, `fadeIn`, `fadeOut`, `animation.enterDuration`, `animation.exitDuration`. Passing frames where seconds are expected is a common and silent mistake. Caption timing is hardcoded to 30 FPS regardless of the composition's `fps`. ### Fields the renderer ignores Verified against the renderer. Sending these has no effect on the output: - `styles.fontSize` on text — **only applies when `styles.fontSizeFixed` is `true`**. Otherwise the size is auto-fitted to the box and your value is discarded. Auto-fit is also capped at 200px. - `segments` on video — appears in the schema and changes editor timeline thumbnails, but the renderer never reads it. - `cropX` / `cropY` / `cropWidth` / `cropHeight` — the renderer reads only `styles.clipPath`. Crops without a `clipPath` do nothing. - `textShadow`, `border`, `backdropFilter` on **shape** overlays. Shapes read only `fill`, `stroke`, `strokeWidth`, `gradient`, `gradientTransition`, `borderRadius`, `boxShadow`, `opacity`, `transform`, `animation`. ### Two values that do not mean what they look like - `styles.gradient` on a shape is a single **end colour**, not a CSS gradient string. A CSS gradient there falls back to black. For a real multi-stop gradient use `styles.gradientTransition.gradients`. - `animatedText.split` accepts `"character"`. `"char"` is **not** an alias — it is treated as a literal separator, so the text is split on the substring "char" and you get no per-character animation. --- ## 7. Credits, limits, errors **Credit formula:** minutes of output x resolution multiplier, rounded up to the nearest 0.5 credit. 1080p and below = 1x. Up to 2K (2560x1440) = 2x. Above that (4K and larger) = 4x. So 30 seconds of 1080p costs 0.5 credits; 30 seconds of 4K costs 2. Previews cost nothing and are unlimited. Auto-captions cost 0.25 credits per started minute of audio. Voiceover costs 0.5 credits per started 1,000 characters. Credits are refunded automatically if a render fails. **Plans:** Pay As You Go at $0.20/credit (50 minimum). Creator at $29/month for 200 credits/month, extra credits $0.15. Business at $99/month for 1,000 credits/month, extra credits $0.12, plus render queue priority. New accounts get 5 free credits. **Rate limits** (requests per minute, shared across all endpoints including previews): 60 without an active subscription, 120 on Creator, 300 on Business. Responses carry `X-RateLimit-*` headers. **Status codes:** `400` invalid payload or unresolved media, `401` bad or missing API key, `402` insufficient credits (the body has the shortfall), `403` template is not public, `404` not found, `429` rate limited. There are **no idempotency keys**. If you retry a `POST /renders` after a timeout you may be charged twice and render twice, because the first attempt often succeeded server-side after you gave up waiting. Poll `GET /renders/{jobId}` with the id you already have instead of resending. --- ## 8. MCP Renderly runs a remote MCP server at https://renderly.video/api/mcp (Streamable HTTP). It accepts either OAuth 2.1 — it advertises its authorization server via RFC 9728, so clients that support dynamic client registration connect with the URL alone — or a Renderly `rnd_` API key sent as a plain Bearer token. Some clients support only one of the two; per-client setup is at https://renderly.video/agents. Tools: `renderly_whoami`, `renderly_list_templates`, `renderly_list_projects`, `renderly_get_variables`, `renderly_preview_render`, `renderly_create_render`, `renderly_get_render_status`, `renderly_generate_captions`, `renderly_generate_voiceover`, `renderly_get_ai_job`. `renderly_preview_render` costs nothing and takes the same arguments as `renderly_create_render`. Pass its returned `previewId` back to update the same preview in place. See https://renderly.video/docs/guide/mcp and https://renderly.video/agents for the exact setup per AI client. --- ## 9. Further reading - Previewing: https://renderly.video/docs/guide/previewing - Rendering: https://renderly.video/docs/guide/rendering - Overlays: https://renderly.video/docs/guide/overlays - Templates: https://renderly.video/docs/guide/templates - Webhooks: https://renderly.video/docs/guide/webhooks - AI features: https://renderly.video/docs/guide/ai-features - Uploads: https://renderly.video/docs/guide/uploading - OpenAPI spec: https://renderly.video/api/openapi Worked end-to-end recipes, each anchored to a real template with the credit cost of the run: https://renderly.video/how-to - Google Sheets, Airtable, Notion and CSV as data sources, plus Instagram Reels and faceless YouTube Shorts.