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/: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 */
]
}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" | Defaults to the source request's format. |
| idempotencyKey | string | A retry with the same key returns the same request and charges nothing. |
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.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 with the endpoint's signing secret (whsec_…, shown once when you add or rotate the endpoint under Settings → Webhooks): v1 must equal HMAC-SHA256 of t + "." + rawBody. Use the raw request body, not a re-serialised one, and reject old timestamps to stop replays.
import crypto from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
return (
fresh &&
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
);
}Credits
- 1 credit per image, on every request — a carousel of 4 slides costs 4.
- A PDF costs 1 credit per page.
- 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. | |
| 409 EDIT_SESSION_ALREADY_OPENED | The editor link was already used — create a new session. | |
| 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. |