Static Ads Lab
Guides

Batch generation

Generate many image ads in parallel and batch-poll their status efficiently.

You'll often want to generate many ads at once — one per audience, one per variant, one per reference ad, etc. This guide shows the right way to fire them in parallel and check their status without hammering the API.

Pattern

flowchart LR
  Inputs[List of input combos] --> POSTs[POST /v1/image-ads in parallel]
  POSTs --> Wait[Wait, then batch-poll]
  Wait --> Group[GET /v1/image-ads?ids=...]
  Group --> Done{All done?}
  Done -->|No| Wait
  Done -->|Yes| Out[Return completed ads]

Sketch

const SAL_BASE = "https://api.staticadslab.com";
const headers = {
  "X-API-Key": process.env.SAL_API_KEY,
  "Content-Type": "application/json",
};

async function generateBatch(inputs) {
  const submissions = await Promise.all(
    inputs.map((input) =>
      fetch(`${SAL_BASE}/v1/image-ads`, {
        method: "POST",
        headers,
        body: JSON.stringify(input),
      }).then((r) => r.json()),
    ),
  );

  const adIds = submissions.map((s) => s.data.id);

  return waitForBatch(adIds);
}

async function waitForBatch(adIds, { intervalMs = 4000, timeoutMs = 600_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  const completed = new Map();

  while (completed.size < adIds.length && Date.now() < deadline) {
    const pending = adIds.filter((id) => !completed.has(id));
    const url = `${SAL_BASE}/v1/image-ads?ids=${pending.join(",")}`;
    const r = await fetch(url, { headers });
    const { data } = await r.json();
    for (const ad of data) {
      if (ad.status === "completed" || ad.status === "failed") {
        completed.set(ad.id, ad);
      }
    }
    if (completed.size < adIds.length) {
      await new Promise((res) => setTimeout(res, intervalMs));
    }
  }

  if (completed.size < adIds.length) {
    throw new Error(`Timed out — ${adIds.length - completed.size} still processing`);
  }

  return adIds.map((id) => completed.get(id));
}

Throttling

Don't fire 1,000 POSTs at once. The API will rate-limit you. Throttle to a sensible parallelism (e.g., 8 concurrent submissions):

import pLimit from "p-limit";

const limit = pLimit(8);
const submissions = await Promise.all(
  inputs.map((input) =>
    limit(() =>
      fetch(`${SAL_BASE}/v1/image-ads`, {
        method: "POST",
        headers,
        body: JSON.stringify(input),
      }).then((r) => r.json()),
    ),
  ),
);

Idempotency

If your batch may be retried (cron, webhook handler, etc.), use stable idempotency keys per input:

const idempotencyKey = `batch:${batchId}:${inputIndex}`;

Same key + same body → cached response on retry. No duplicate wallet charges.

Track via batch_id

The platform may set batch_id on image ad rows for certain workflows. You can filter the list endpoint when present:

GET /v1/image-ads?batch_id=batch_2026_04_30_run_42

This is independent of ids= and is useful for retroactive batch reads.

Note: Client-supplied batch_id on POST /v1/image-ads is on the roadmap. For now, track ad IDs in your own queue/DB and pass them to ?ids=.

Pitfalls

  • Don't poll each ad individually — batch poll. Polling each costs N requests instead of 1.
  • Don't fire all submissions in parallel without throttling — you'll get 429.
  • Hard-cap your wait loop with a timeout — never wait forever.
  • Wallet may run out mid-batch. Catch 402 INSUFFICIENT_BALANCE and surface it cleanly.