Edit a JSON tree and re-render
Mutate an image ad's JSON tree and PATCH it back. The tree saves immediately; the new image renders in the background.
Every image ad returns a JSON tree alongside the PNG. You can mutate the tree and PATCH the ad. The API validates the tree, saves it, and returns right away; the new image is rendered in the background at 2×. Poll the ad until rendered_tree_revision equals tree_revision, then read the new image_url.
When to use this
- Building a custom ad editor.
- Tweaking copy or images after the AI's first pass.
- Producing many variants of one base ad with small differences.
For a targeted change (a color, a size, a line of copy), a design edit is simpler: send an instruction or a short script instead of the whole tree.
End-to-end flow
sequenceDiagram
participant App as Your app
participant API as Static Ads Lab API
App->>API: POST /v1/image-ads
API-->>App: 202 { id, status: processing }
Note right of API: pipeline runs
App->>API: GET /v1/image-ads/:id (poll)
API-->>App: completed { image_url, current_json_tree }
App->>App: mutate tree
App->>API: PATCH /v1/image-ads/:id { current_json_tree, base_revision }
Note right of API: validate, save
API-->>App: 200 { tree_revision, render_status: pending }
Note right of API: render in the background
App->>API: GET /v1/image-ads/:id/render-status (poll)
API-->>App: rendered_tree_revision == tree_revision { new image_url }Step by step
1. Generate the ad
const r = await fetch("https://api.staticadslab.com/v1/image-ads", {
method: "POST",
headers: {
"X-API-Key": process.env.SAL_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference_ad_url: "https://cdn.example.com/reference-ad.png",
brand_id: "brand_...",
product_id: "prod_...",
audience_id: "aud_...",
}),
});
const { data: created } = await r.json();
const ad = await waitForCompletion(created.id);2. Locate the node you want to change
JSON trees vary by generated ad. The tree is a document, { schemaVersion: 2, colorProfile: "SRGB", root }; walk from root to find a node by some heuristic: characters for text, name for images.
function findTextNodeByCharacters(root, query) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (node.type === "TEXT" && node.characters.includes(query)) return node;
for (const child of node.children ?? []) stack.push(child);
}
return null;
}
const tree = ad.current_json_tree;
if (tree?.schemaVersion !== 2) {
throw new Error("This ad uses the retired JSON tree format. Generate it again to edit it.");
}
const headline = findTextNodeByCharacters(tree.root, "Limited time");3. Change the text and its styles together
Styles live in styledTextSegments, which must cover every character. When you replace the text, rebuild the segments for the new length. This keeps the first segment's style (font, size, color) for the whole string:
const [style] = headline.styledTextSegments;
headline.characters = "Black Friday: 40% off";
headline.styledTextSegments = [{ ...style, start: 0, end: headline.characters.length }];To change the font, set fontName on the segments to a family and style we ship, for example { family: "Montserrat", style: "Extra Bold" }. See Supported fonts. Any other font renders in Inter at the closest style.
4. PATCH the full tree back
Always send the entire updated document. The API does not accept diffs. Send base_revision (the tree_revision you edited) so a save on top of someone else's newer change is rejected instead of overwriting it.
const response = await fetch(`https://api.staticadslab.com/v1/image-ads/${ad.id}`, {
method: "PATCH",
headers: {
"X-API-Key": process.env.SAL_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ current_json_tree: tree, base_revision: ad.tree_revision }),
});
const body = await response.json();
if (response.status === 409) {
// Someone else saved (or Figma synced) first. Fetch the ad, reapply your
// change to its tree, and send body.error.details.current_revision.
throw new Error(`Stale tree: the ad is at revision ${body.error.details.current_revision}`);
}
if (!response.ok) {
// Nothing was saved. Each details entry names one path to fix, e.g.
// current_json_tree.root.children[1].styledTextSegments[0].fontName
for (const detail of body.error.details ?? []) {
console.error(`${detail.field}: ${detail.message}`);
}
throw new Error(`${body.error.code}: ${body.error.message}`);
}
const saved = body.data; // render_status: "pending", image_url is still the previous render5. Wait for the new image
The tree is saved as soon as PATCH returns. The image is re-rendered in the background, usually within a few seconds. Poll (every 2 seconds, with a timeout) GET /v1/image-ads/:id/render-status until rendered_tree_revision reaches the revision you saved:
async function waitForRender(adId, revision, { timeoutMs = 120_000, intervalMs = 2_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const r = await fetch(`https://api.staticadslab.com/v1/image-ads/${adId}/render-status`, {
headers: { "X-API-Key": process.env.SAL_API_KEY },
});
const { data: ad } = await r.json(); // no tree: id, revisions, render_status, image_url
// >= because a newer save may have replaced yours; its render covers your edit too.
if (ad.rendered_tree_revision >= revision) return ad;
if (ad.render_status === "failed") {
// Your edits are saved and the previous image is kept. The next save renders again.
throw new Error(`${ad.render_error.code}: ${ad.render_error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Image for revision ${revision} was not ready after ${timeoutMs} ms`);
}
const rendered = await waitForRender(saved.id, saved.tree_revision);
console.log(rendered.image_url); // the new renderSaves made while a render is running are coalesced: only the newest tree is rendered. The ad stays completed throughout. If a render ever stalls, reading the ad restarts it.
Image swaps
Images are IMAGE paints in a node's fills, and the image URL is the paint's sal.url. It must be an absolute http(s) URL; anything else (a blob: or data: URL, a bare name) returns 400 INVALID_IMAGE_URL naming the paint path. To swap an image:
- Upload the new image to the ad with
POST /v1/image-ads/:id/assets(multipart fieldfile; JPEG, PNG or WebP, about 4 MB at most). - Set the returned
data.urlas the paint'ssal.url. PATCHthe tree.
function findImagePaint(root, nodeName) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
const paint = node.fills?.find((fill) => fill.type === "IMAGE");
if (paint && node.name === nodeName) return paint;
for (const child of node.children ?? []) stack.push(child);
}
return null;
}
const form = new FormData();
form.append("file", new Blob([imageBytes], { type: "image/jpeg" }), "lifestyle.jpg");
const upload = await fetch(`https://api.staticadslab.com/v1/image-ads/${ad.id}/assets`, {
method: "POST",
headers: { "X-API-Key": process.env.SAL_API_KEY },
body: form,
});
const { data: uploaded } = await upload.json(); // { url, width, height }
const photo = findImagePaint(tree.root, "Product photo");
photo.sal = { url: uploaded.url, width: uploaded.width, height: uploaded.height };Uploads are content-addressed, so uploading the same image twice returns the same URL. Hosting platforms reject request bodies above about 4.5 MB: resize or compress larger images before uploading. Don't embed base64 image data in the JSON. If an image cannot be downloaded at render time, the render fails with render_error.code: "IMAGE_UNAVAILABLE" naming the paint path; your saved tree and the previous image are kept.
Versioning
Saves are kept as quiet checkpoints: a PATCH keeps the previous tree as a version when the newest version is at least 10 minutes old (or there is none), so a burst of autosaves produces one version, not hundreds. List them:
GET /v1/image-ads/:id/versionsGet a specific version:
GET /v1/image-ads/:id/versions/:versionIdVersions are immutable. Use them as an audit log of changes, or to roll back by re-applying an older version's tree via PATCH. Versions saved before schema v2 hold trees in the retired format, which PATCH does not accept.
Pitfalls
- Check
editor_supportedbefore patching an older ad; some ads from before the current pipeline cannot be edited. current_json_treeisnulluntil the original generation completes. Wait forstatus: "completed"before reading or patching.- Don't
PATCHwhile the resource isprocessing; wait for the original generation to complete first (422 IMAGE_AD_NOT_COMPLETED). - An invalid tree (unknown keys, missing required fields, segments that don't cover the text) returns
400 VALIDATION_ERROR, and an image paint without an absolute http(s) URL returns400 INVALID_IMAGE_URL, with onedetailsentry per path. Nothing is saved in either case. A font we don't ship is not an error: it renders in Inter. - A saved tree that fails to render does not undo the save: the ad shows
render_status: "failed"andrender_error(IMAGE_UNAVAILABLEorRENDER_FAILED), and keeps the previousimage_url. Fix the tree and save again. - Read
image_urlonly oncerendered_tree_revisionequalstree_revision; until then it is the previous render. - Ads created before schema v2 have a tree in the retired format.
PATCHrejects it with400 VALIDATION_ERROR; generate the ad again to edit it.
Related
Edit ads with instructions or scripts
Change a finished ad's layers in one request, with a plain-English instruction for our AI Design Agent or a design script your own agent writes. Atomic, synchronous, and re-rendered in the background.
Figma sync
Round-trip an image ad through Figma using the Static Ads Lab plugin.