Static Ads Lab
Guides

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 render

5. 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 render

Saves 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:

  1. Upload the new image to the ad with POST /v1/image-ads/:id/assets (multipart field file; JPEG, PNG or WebP, about 4 MB at most).
  2. Set the returned data.url as the paint's sal.url.
  3. PATCH the 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/versions

Get a specific version:

GET /v1/image-ads/:id/versions/:versionId

Versions 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_supported before patching an older ad; some ads from before the current pipeline cannot be edited.
  • current_json_tree is null until the original generation completes. Wait for status: "completed" before reading or patching.
  • Don't PATCH while the resource is processing; 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 returns 400 INVALID_IMAGE_URL, with one details entry 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" and render_error (IMAGE_UNAVAILABLE or RENDER_FAILED), and keeps the previous image_url. Fix the tree and save again.
  • Read image_url only once rendered_tree_revision equals tree_revision; until then it is the previous render.
  • Ads created before schema v2 have a tree in the retired format. PATCH rejects it with 400 VALIDATION_ERROR; generate the ad again to edit it.