Render requests & editing
Every render call is a render request with its own id (rnd_…), returned as renderId by POST /v1/templates/:id/render. A request holds its files in page order, the exact design it was rendered from (after your modifications) and where it came from. That stored design is what lets you re-render a request later, or let your users edit it in the embedded editor, without touching the master template.
| Param | Type | Notes |
|---|---|---|
| renderId | rnd_… | One render request — a single-page render, a whole carousel, or one PDF. Never shared: two identical calls get two ids. |
| fileId | ren_… | One output file inside a request (the id field on a template render response). GET /v1/renders/:renderId also accepts a file id and returns its request. |
| sourceTemplateId | tpl_… | null | The template the chain started from — carried through rerenders and edits; null once that template is deleted. Renders outlive their template. |
| sourceRenderId | rnd_… | null | The request it was derived from, for a rerender or an edit. |
GET /v1/renders
/v1/rendersWorkspace-wide render history, newest first. Paginated and optionally filtered by format or template — suitable for polling (e.g. a 'new render' trigger).
| Param | Type | Notes |
|---|---|---|
| page | number | Defaults to 1. |
| limit | number | Defaults to 25, max 100. |
| format | "png" | "jpeg" | "pdf" | "mp4" | "webm" | "gif" | Restrict to one output format. |
| templateId | string | A template's public id (tpl_…). |
curl "https://buildonce.app/v1/renders?format=png&limit=10" \ -H "Authorization: Bearer fl_live_YOUR_API_KEY"
{
"items": [
{
"renderId": "rnd_8kq2m4x7ab1c",
"sourceTemplateId": "tpl_123",
"sourceRenderId": null,
"format": "png",
"createdAt": "2026-09-28T12:00:00.000Z",
"credits": { "charged": 3, "balanceAfter": 1877 },
"files": [ { "fileId": "ren_4hd9s0k2mq7z", "pageId": "s1", "url": "https://...", "width": 1080, "height": 1350 } ]
}
/* … */
],
"total": 128,
"page": 1,
"limit": 25
}GET /v1/renders/:renderId
/v1/renders/:renderIdA render request's metadata and ordered files. Accepts a request id (rnd_…) or a file id (ren_…).
curl https://buildonce.app/v1/renders/rnd_8kq2m4x7ab1c \ -H "Authorization: Bearer fl_live_YOUR_API_KEY"
{
"renderId": "rnd_8kq2m4x7ab1c",
"sourceTemplateId": "tpl_123",
"sourceRenderId": null,
"format": "png",
"createdAt": "2026-09-28T12:00:00.000Z",
"credits": { "charged": 3, "balanceAfter": 1877 },
"files": [
{
"fileId": "ren_4hd9s0k2mq7z",
"pageId": "s1",
"url": "https://.../renders/9de8...b5.png",
"width": 1080,
"height": 1350,
"format": "png",
"byteSize": 482113,
"isEmpty": false
}
/* … one entry per page, in page order */
],
"posts": [ /* GET only: the posts its publish block made */ ]
}A video request (mp4, webm, gif) is always queued — poll this same endpoint until it finishes:
{
"renderId": "rnd_8kq2m4x7ab1c",
"status": "queued"
}
/* or "status": "processing" once drawing starts */{
"renderId": "rnd_8kq2m4x7ab1c",
"status": "failed",
"error": {
"status": 422,
"body": { "code": "VIDEO_TOO_LONG", "message": "..." }
}
}Once finished, this endpoint returns the same shape shown above, with status: "succeeded" added and a single file whose pageId is null (a video is always one file for the whole design, never one per page).
posts lists the social posts the request's publish block made (empty without one) — for a video, they appear once it is done.
POST /v1/renders/:renderId/rerender
/v1/renders/:renderId/rerenderRender a request again from its stored design — not from the template's current draft — optionally with new modifications or another format. Returns a new request whose sourceRenderId is the one you passed.
| Param | Type | Notes |
|---|---|---|
| modifications | Record<string,string> | Applied on top of the stored design. Page-scoped keys work here too — see Carousels below. |
| format | "png" | "jpeg" | "pdf" | "mp4" | "webm" | "gif" | Defaults to the source request's format. A video format answers 202 and is not stored — fps/scale/quality reset to their defaults unless passed again. |
| idempotencyKey | string | A retry with the same key returns the same request and charges nothing. |
| publish | { accountIds, content, scheduledAt?, … } | Post the new files to connected social accounts — the same block as the template render takes. |
curl -X POST https://buildonce.app/v1/renders/rnd_8kq2m4x7ab1c/rerender \
-H "Authorization: Bearer fl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "modifications": { "page2@headline": "Updated for Q4" } }'POST /v1/embed/:embedId/edit-sessions
/v1/embed/:embedId/edit-sessionsOpen the embedded editor on a render request, for one of your users. Call it from your server with an API key; load the returned editorUrl in an iframe.
| Param | Type | Notes |
|---|---|---|
| renderId | string (required) | The render request to edit (rnd_…). |
| origin | string (required) | Your page's origin, e.g. https://app.example.com. Must be on the embed's allowed domains when that list is set. The editor only ever posts messages to this origin. |
| allowPageChanges | boolean | Default true: the user may add, remove and reorder slides. false keeps the page structure fixed (switching between slides still works). |
| allowUnlock | boolean | Default false: layers locked in the design (a logo, a disclaimer) stay locked, in place and present. true lets the user unlock and change them. |
| ttlSeconds | number | 60–7200. Defaults to 7200 (2 hours). |
curl -X POST https://buildonce.app/v1/embed/emb_123/edit-sessions \
-H "Authorization: Bearer fl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"renderId": "rnd_8kq2m4x7ab1c",
"origin": "https://app.example.com"
}'{
"sessionId": "res_2mf8a0c4pq9d",
"renderId": "rnd_8kq2m4x7ab1c",
"token": "fedit_…",
"expiresAt": "2026-09-28T14:00:00.000Z",
"editorUrl": "https://buildonce.app/e/emb_123?mode=render&session=fedit_…"
}EDIT_SESSION_ALREADY_OPENED — create a new session instead. Unsaved changes are kept as a draft of that render for 7 days and come back in the next session on it. There is one draft per render — per post, whoever edits it — so the last person to autosave wins until someone saves.Render-mode editing routes
The editor loaded in the iframe calls these itself, authenticated with the Authorization: Bearer fedit_… token minted above — never with your API key. They exist for completeness; building your own editor UI against them instead of embedding BuildOnce's iframe is unsupported, since the design document itself is one of the request/response bodies and never otherwise leaves BuildOnce.
/v1/embed/:embedId/edit-session/openLoad the design to edit — the user's unsaved draft if one exists, otherwise the render request's stored snapshot. Single-use: calling it again on the same session fails with EDIT_SESSION_ALREADY_OPENED.
{
"renderId": "rnd_8kq2m4x7ab1c",
"design": { /* the design document */ },
"restoredDraft": false,
"allowPageChanges": true,
"allowUnlock": false,
"hostOrigin": "https://app.example.com",
"expiresAt": "2026-09-28T14:00:00.000Z",
"brandTitle": "Acme Studio"
}/v1/embed/:embedId/edit-session/draftAutosave the unsaved design. Renders nothing and charges nothing — one draft per render request, 7-day expiry, last write wins.
| Param | Type | Notes |
|---|---|---|
| design | object (required) | The editor's current design document. |
{ "saved": true }/v1/embed/:embedId/edit-session/saveSave: render every page of the edited design as a new render request derived from the one the session opened, clear the draft, and advance the session to the new request. An unchanged design charges nothing and returns the current request.
| Param | Type | Notes |
|---|---|---|
| design | object (required) | The editor's current design document. |
{
"sessionId": "res_2mf8a0c4pq9d",
"renderId": "rnd_3bx8tsv9emk3",
"sourceRenderId": "rnd_8kq2m4x7ab1c",
"sourceTemplateId": "tpl_123",
"unchanged": false,
"files": [{ "pageId": "s1", "url": "https://…", "width": 1080, "height": 1350 }],
"credits": { "charged": 4, "balanceAfter": 1873 }
}This is the same payload the frameline:render:saved message and the render.edited webhook carry, documented below — the editor calls this route for you; you don't normally call it directly.
/v1/embed/:embedId/edit-session/assetsUpload an image or font opened from the editor's asset picker straight into the workspace's asset library.
| Param | Type | Notes |
|---|---|---|
| file | binary (required) | multipart/form-data, up to 15 MB. |
| kind | "image" | "font" (required) |
{ "url": "https://.../asset.png", "fontFamily": null }opened first (EDIT_SESSION_NOT_OPENED otherwise), and refuses any token not minted for this exact embed id (EMBED_MISMATCH) or expired (INVALID_EMBED_SESSION).Editor messages
The editor talks to your page with postMessage, sent only to the origin you gave when creating the session — never to *. Check event.origin on your side too.
| Param | Type | Notes |
|---|---|---|
| frameline:render:ready | { embedId } | The editor page is running. |
| frameline:render:loaded | { renderId, pageCount, restoredDraft } | The design is on the canvas. restoredDraft is true when an unsaved draft was restored. |
| frameline:render:change | { dirty } | Sent when the unsaved-changes state flips — use it to warn before closing. |
| frameline:render:saved | { sessionId, renderId, sourceRenderId, sourceTemplateId, unchanged, files, credits } | A save finished. files: [{ pageId, url, width, height }] in page order. unchanged: true means nothing was edited — the current renderId comes back and nothing is charged. |
| frameline:render:error | { code, message } | Opening or saving failed — see Errors below. |
The editor has its own Save button. To save from your own UI instead, post { type: "frameline:render:save" } to the iframe; it is accepted only from the parent page at the session's origin.
const EDITOR_ORIGIN = "https://buildonce.app";
const iframe = document.querySelector("#editor");
iframe.src = editorUrl; // from your server
window.addEventListener("message", (event) => {
if (event.origin !== EDITOR_ORIGIN) return;
const message = event.data;
switch (message.type) {
case "frameline:render:change":
setUnsaved(message.dirty);
break;
case "frameline:render:saved":
showImages(message.files.map((file) => file.url));
rememberRender(message.renderId); // edit this one next time
break;
case "frameline:render:error":
showError(message.message);
break;
}
});
saveButton.onclick = () =>
iframe.contentWindow.postMessage({ type: "frameline:render:save" }, EDITOR_ORIGIN);render.edited webhook
Every save that changed something is also sent to the webhook endpoints subscribed to render.edited (Settings → Webhooks) — the reliable record even if the user closed the page before the message arrived. The data is the same object as the saved message, plus the embedId. Failed deliveries are retried.
BuildOnce-Event: render.edited
BuildOnce-Delivery-Id: whd_6tq2k9m4xa1b
BuildOnce-Signature: t=1790606243,v1=5f0c…e91a
{
"id": "evt_8m2kq4x7tb1c",
"type": "render.edited",
"created_at": "2026-09-28T12:05:00.000Z",
"workspace_id": "ws_123",
"data": {
"sessionId": "res_2mf8a0c4pq9d",
"renderId": "rnd_3bx8tsv9emk3",
"sourceRenderId": "rnd_8kq2m4x7ab1c",
"sourceTemplateId": "tpl_123",
"embedId": "emb_123",
"files": [{ "pageId": "s1", "url": "https://…", "width": 1080, "height": 1350 }],
"credits": { "charged": 4, "balanceAfter": 1873 }
}
}Verify every delivery the same way as any other webhook — see verifying a delivery for the signature header format and working Node/Python verification code.
Credits
- 1 credit per image, on every request — a carousel of 4 slides costs 4.
- A PDF costs 1 credit per page.
- A video (
mp4,webm,gif) costs 1 credit per started second, at every size and quality — a design under 60 s plays in full; longer than that is rejected before rendering. - A request identical to an earlier one is still a new request and is still charged; its files are served from storage, not drawn again.
- A retry with the same
idempotencyKeyreturns the original request and costs 0. - An editor save with no changes costs 0 and returns the current request (
unchanged: true). A save that changed one slide renders — and charges for — every slide, because it is a complete new request.
Carousels
- Files always match the design: same number, same order. A page with nothing on it renders as a blank image with
isEmpty: true— it is never silently skipped. - Slides added, removed or reordered in the editor come back that way in the new request's
files. - Set a value on one slide with a page-scoped key:
page2@headline(by position, from 1) ors2@headline(by page id). A page id beats a position, which beats a plainheadline. Works on template renders and rerenders.
{
"headline": "Used on every slide without its own value",
"page1@headline": "Slide one",
"s3@headline": "The slide whose id is s3"
}Errors
| Param | Type | Notes |
|---|---|---|
| 404 | No render request with that id in your workspace — including another workspace's id. | |
| 400 INVALID_MODIFICATIONS | A modification value is not a string. | |
| 403 EDIT_SESSION_FORBIDDEN | Edit sessions are created with an API key or dashboard session, not an embed token. | |
| 403 EMBED_DISABLED | The embed is turned off. | |
| 400 INVALID_ORIGIN | origin is not an http(s) origin. | |
| 403 DOMAIN_NOT_ALLOWED | origin is not on the embed's allowed domains. | |
| 403 EDIT_SESSION_REQUIRED | A render-mode route was called without a fedit_ token. | |
| 403 EMBED_MISMATCH | The fedit_ token was minted for a different embed id. | |
| 409 EDIT_SESSION_ALREADY_OPENED | The editor link was already used — create a new session. | |
| 409 EDIT_SESSION_NOT_OPENED | draft, save or assets was called before open. | |
| 401 INVALID_EMBED_SESSION | The editor link has expired or is invalid. | |
| 400 PAGE_CHANGES_NOT_ALLOWED | A save changed the slides of a session with allowPageChanges: false. | |
| 400 LOCKED_LAYER_CHANGED | A save moved, removed or unlocked a locked layer in a session with allowUnlock: false. | |
| 400 INVALID_VIDEO_OPTIONS | video.fps, video.scale or video.quality is out of range. | |
| 422 VIDEO_TOO_LONG | The design's total duration is over 60 s. Shorten page durations or render fewer pages. | |
| 503 VIDEO_ENCODER_UNAVAILABLE | The video encoder is unavailable. Nothing was charged — retry the request. | |
| 503 QUEUE_UNAVAILABLE | Could not read the render queue. Try again in a moment. | |
| 500 RENDER_FAILED | A queued render failed after retrying. Try again. |
Was this page helpful?