Static Ads Lab
Guides

Async jobs (polling and SSE)

How to track design template and image ad generation — polling, batch polling, and Server-Sent Events.

POST /v1/image-ads and POST /v1/design-templates are asynchronous. They return 202 Accepted with the resource in processing and the pipeline runs in the background. This guide covers the three ways to know when it's done.

Status lifecycle

stateDiagram-v2
  [*] --> processing: POST returns 202
  processing --> completed: outputs ready
  processing --> failed: error populated
  completed --> [*]
  failed --> [*]

There are no intermediate states. The progress field updates during processing with pipeline-stage info; that's a UX hint, not a state machine.

You're billed only on completed. Failed jobs do not deduct from your wallet.

Polling

The default. Periodically GET the resource until status changes.

async function waitForCompletion(imageAdId, apiKey) {
  while (true) {
    const r = await fetch(
      `https://api.staticadslab.com/v1/image-ads/${imageAdId}`,
      { headers: { "X-API-Key": apiKey } },
    );
    const { data } = await r.json();
    if (data.status === "completed") return data;
    if (data.status === "failed") throw new Error(data.error?.message ?? "failed");
    await new Promise((res) => setTimeout(res, 4000));
  }
}
import time
import requests

def wait_for_completion(image_ad_id: str, api_key: str) -> dict:
    while True:
        r = requests.get(
            f"https://api.staticadslab.com/v1/image-ads/{image_ad_id}",
            headers={"X-API-Key": api_key},
        )
        data = r.json()["data"]
        if data["status"] == "completed":
            return data
        if data["status"] == "failed":
            raise Exception(data["error"]["message"])
        time.sleep(4)
while true; do
  RESULT=$(curl -s https://api.staticadslab.com/v1/image-ads/ia_YOUR_ID \
    -H "X-API-Key: YOUR_API_KEY")
  STATUS=$(echo "$RESULT" | jq -r '.data.status')
  echo "Status: $STATUS"
  [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ] && break
  sleep 4
done
echo "$RESULT" | jq '.data.image_url'

Polling tips

  • 4 seconds is a good interval for image ads (generation runs ~30–60 seconds).
  • 2 seconds works for design templates (faster pipeline).
  • Don't poll from the browser. Poll server-side and push updates to clients over your own channel.
  • Always cap retries. Add a timeout (e.g. 5 minutes) and throw if exceeded — never poll forever.

Batch polling

When you have many ads in flight, ask for all their statuses in one request:

const ids = ["ia_a", "ia_b", "ia_c"].join(",");
const r = await fetch(`https://api.staticadslab.com/v1/image-ads?ids=${ids}`, {
  headers: { "X-API-Key": process.env.SAL_API_KEY },
});
const { data } = await r.json();
const stillProcessing = data.filter((d) => d.status === "processing");

This is the right pattern for batch generation. See Batch generation.

Server-Sent Events

For low-latency progress, subscribe to an SSE stream by adding Accept: text/event-stream to the GET request.

SSE is available for image ads:

GET /v1/image-ads/:id
Accept: text/event-stream

Event types

EventWhen it firesData
startedStream connected, job is queued{ "id": "..." }
progressPipeline advances a step{ "step": "...", "message": "...", "percentage": 0.5 }
completeJob finished successfully{ "id": "..." }
errorJob failed{ "code": "...", "message": "..." }
heartbeatEvery 15 seconds to keep connection aliveEmpty

Subscribe to progress

const imageAdId = "ia_YOUR_IMAGE_AD_ID";

const response = await fetch(
  `https://api.staticadslab.com/v1/image-ads/${imageAdId}`,
  {
    headers: {
      "X-API-Key": process.env.SAL_API_KEY,
      Accept: "text/event-stream",
    },
  },
);

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

readLoop: while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  let sep;
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
    const block = buffer.slice(0, sep);
    buffer = buffer.slice(sep + 2);

    let eventName = "message";
    let dataStr = "";
    for (const line of block.split("\n")) {
      if (line.startsWith("event:")) eventName = line.slice(6).trim();
      else if (line.startsWith("data:")) dataStr += line.slice(5).trimStart();
    }

    if (eventName === "heartbeat") continue;
    const data = dataStr ? JSON.parse(dataStr) : null;
    console.log(eventName, data);
    if (eventName === "complete" || eventName === "error") break readLoop;
  }
}
import json
import sseclient
import requests

response = requests.get(
    f"https://api.staticadslab.com/v1/image-ads/{image_ad_id}",
    headers={
        "X-API-Key": api_key,
        "Accept": "text/event-stream",
    },
    stream=True,
)

client = sseclient.SSEClient(response)
for event in client.events():
    if event.event == "progress":
        print(json.loads(event.data)["message"])
    elif event.event == "complete":
        break
    elif event.event == "error":
        raise RuntimeError(event.data)

After complete (or once the connection drops), do one final GET to read the image_url.

Choosing a pattern

Use thisWhen
PollingOne or a few ads, simple control flow, retries are fine
Batch pollingTens or hundreds of ads in parallel
SSECustomer-facing app where progress UI matters; one ad at a time

Pitfalls

  • Don't poll faster than every 2 seconds — you'll hit the rate limit.
  • Reading image_url while status === "processing" returns null. Wait for completed.
  • SSE connections can drop on flaky networks. Always do a final GET after the stream closes to confirm state.
  • A PATCH on an editable image ad sends the resource back to processing. Treat patches as new async jobs.