POST /v1/templates/:id/render
/v1/templates/:id/renderRasterize a template's current draft to an image, PDF or video, optionally substituting variable values first. Every call is a new render request (renderId); images and PDFs cost 1 credit per image or per page, a video costs 1 credit per started second — only a retry with the same idempotencyKey is free.
| Param | Type | Notes |
|---|---|---|
| format | "png" | "jpeg" | "pdf" | "mp4" | "webm" | "gif" | Defaults to "png". mp4/webm/gif render every page as one animated video — each element's enter/exit animation and each page's transition, exactly as the studio plays it. |
| video | { fps?, scale?, quality? } | mp4/webm/gif only, ignored for images. See the video options below. |
| modifications | Record<string,string> | Variable name → replacement value, applied before render. Page-scoped keys (page2@headline, <pageId>@headline) set one slide. |
| animation | { pages?, layers? } | Change timing and animation for this render only, so one template gives different cuts. See the animation options below. |
| pageId | string | Which page to render. Defaults to the active page. |
| allPages | boolean | Render every page, in order. PDF → one multi-page file; png/jpeg → one file per page. A page with nothing on it renders blank (isEmpty: true) rather than being skipped. Ignores pageId. Video always renders every page regardless of this flag. |
| idempotencyKey | string | Reuse the same value on a retry: you get the original request back and are not charged again. |
| publish | { accountIds, content, scheduledAt?, … } | Also post the rendered files to connected social accounts, now or at a set time. See "Render and post" below. |
video options (mp4/webm/gif only):
| Param | Type | Notes |
|---|---|---|
| video.fps | number | 1–60. Default 30, except gif, which defaults to and is capped at 15. |
| video.scale | 0.5 | 1 | 2 | Size as a multiple of the design's own. Default 1. A scale-up stops at 2160 px on the longest side; gif is never scaled up. Same price at every size. |
| video.quality | "small" | "standard" | "high" | mp4/webm encoder quality. Default "standard". gif ignores it. |
animation options — the template is never changed, and a wrong field or value is a 400 INVALID_ANIMATION listing every problem, before anything is drawn or charged:
| Param | Type | Notes |
|---|---|---|
| animation.pages | Record<page, { duration?, transition? }> | Keyed by page id or page1, page2… duration: seconds (0.5–60, null for automatic). transition: { kind: "fade" | "slide-left" | "slide-right" | "wipe" | "zoom", duration?: 0–3 } into the next page, or null for a cut. A video's length, and so its price, follows the new timing. |
| animation.layers | Record<layer, { … }> | Keyed by variable name or layer id (page2@headline for one page). Fields: enterAnim, exitAnim, animDuration, animEasing, animReverse, showAt, hideAt, ambientAnim, ambientSpeed, ambientStrength, ambientDirection, textAnim, textAnimDuration, highlightAt, highlightDuration, popAt — the values the studio's Animate tab offers. Images show each layer at rest. |
202 with { "renderId": "rnd_…", "status": "queued" } at once, never the finished file. Poll GET /v1/renders/:renderId until status is succeeded or failed, or listen for the render.completed webhook. png, jpeg and pdf are unaffected — they still return the finished render in the same call.curl -X POST https://buildonce.app/v1/templates/tpl_123/render \
-H "Authorization: Bearer fl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"format": "png",
"modifications": { "headline": "50% off today" }
}'{
"success": true,
"data": {
"id": "ren_789",
"renderId": "rnd_456",
"templateId": "tpl_123",
"format": "png",
"url": "https://.../ren_789.png",
"byteSize": 482113,
"width": 1080,
"height": 1080,
"createdAt": "...",
"credits": { "charged": 1, "balanceAfter": 1879 },
"isEmpty": false
}
}A video request instead:
curl -X POST https://buildonce.app/v1/templates/tpl_123/render \
-H "Authorization: Bearer fl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"format": "mp4",
"video": { "fps": 30, "scale": 1, "quality": "standard" }
}'{
"success": true,
"data": {
"renderId": "rnd_456",
"status": "queued"
}
}With allPages: true and format of png or jpeg, data is { "renderId": "rnd_…", "pages": [ /* one of the above per page */ ] } instead of a single object — one request, one file per page.
Keep the renderId: it is what GET /v1/renders/:renderId, rerender and edit sessions take.
idempotencyKey returns the original response with credits.charged of 0. Without one, an identical call is a new request and is charged again — its files are served from storage, not drawn twice.Render and post
Add a publish block and the rendered files are posted to your connected social accounts in the same call: one post per account, its media the render's files (an all-pages render is a carousel). Find account ids with GET /v1/social/accounts; accounts are connected in the dashboard.
| Param | Type | Notes |
|---|---|---|
| publish.accountIds | string[] (required) | Connected account ids (sac_…), 1–10. |
| publish.content | string (required) | The post text. Each platform's own limits apply. |
| publish.scheduledAt | string | ISO 8601 with Z or an offset (2026-10-25T09:00:00+05:00), in the future. Omit to publish at once. A video still rendering when the time comes is posted as soon as it is done. |
| publish.linkUrl | string | A link to attach, where the platform takes one. |
| publish.title / description / tags | string / string / string[] | LinkedIn and YouTube only. |
| publish.platformOptions | object | Keyed by platform: instagram.postType, youtube.privacy / categoryId, pinterest.boardId, x.replyToPostId. |
400 PUBLISH_INVALID listing each account's problems, and nothing is charged. An account that needs reconnecting, or X's monthly limit, does not stop the render: that account's result carries the error.curl -X POST https://buildonce.app/v1/templates/tpl_123/render \
-H "Authorization: Bearer fl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"format": "png",
"modifications": { "headline": "New post is live!" },
"publish": {
"accountIds": ["sac_li1", "sac_x9"],
"content": "Check out our latest design!",
"scheduledAt": "2026-10-25T09:00:00+05:00"
}
}'{
"success": true,
"data": {
"id": "ren_789",
"renderId": "rnd_456",
"url": "https://.../ren_789.png",
"...": "the render, as above",
"publish": {
"results": [
{
"accountId": "sac_li1",
"ok": true,
"post": { "id": "spo_123", "status": "scheduled", "renderId": "rnd_456", "warnings": [] }
},
{
"accountId": "sac_x9",
"ok": false,
"error": { "code": "X_POST_LIMIT_REACHED", "message": "..." }
}
],
"warnings": []
}
}
}A video answers 202 before it is drawn, with "publish": { "status": "pending", "accountIds": [...] }. Its posts are made once the video is done; poll GET /v1/renders/:renderId — its posts lists them. A render that fails posts nothing. An account refused only after the video was done (disconnected meanwhile) is recorded in the activity log as social_post.render_publish_refused.
A render request is posted to each account at most once: a retry with the same idempotencyKey returns the same posts. Change or cancel a scheduled post with the social post routes.
Was this page helpful?