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 design template, 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

When you set the same batch_id on multiple POST /v1/image-ads calls (server-side), you can later filter the list endpoint by it:

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: batch_id is currently set by the platform on certain workflows; client-side batch_id provisioning is on the roadmap. For now, the safe pattern is to 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.