# Batch GPT Image 2 requests without losing track of tasks

By SeedRouter · Published 2026-09-21 · Updated 2026-09-21

GPT Image 2 batch generation on SeedRouter means submitting ordinary image requests and tracking each returned task ID. This guide uses a small client-side script, not a separate Batch API or a batch-discount product. Each prompt is its own task; the script saves progress so you can check those tasks again after an interruption.

There are two separate jobs: generating an image and downloading it. A failed download does not require a new generation. A polling deadline does not mean the image task failed. Keeping those distinctions in the script saves both work and confusion.

## What do you need before starting?

Use Node.js 22 or later, a server-side `SEEDROUTER_API_KEY` environment variable, and enough account balance for your test set. Start with two prompts. Check the [current pricing](https://seedrouter.ai/models/gpt-image-2#pricing) before increasing the number of images.

Save the following code as `images.mjs` in a private working directory. It creates `image-tasks.json` there and downloads PNG files into `images/`. Keep the state file: it is what connects your local labels to accepted tasks. Do not add secrets or customer-sensitive prompts to a public repository.

The examples request one image per prompt. Use the `n` parameter when you want several outputs for the same prompt; do not confuse that with submitting different prompts. SeedRouter documents the accepted range and other constraints in the [model reference](https://seedrouter.ai/docs/gpt-image-2#parameters).

## Submit once, then save the task IDs

The script writes an `unconfirmed` entry before sending a request. If the process stops before it saves the response, the next run will not silently submit that item again. Inspect the task in your account before deciding what to do with an unconfirmed entry.

```javascript
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';

const mode = process.argv[2];
if (!['submit', 'poll'].includes(mode)) {
  throw new Error('Use: node images.mjs submit | poll');
}
const key = process.env.SEEDROUTER_API_KEY;
if (!key) throw new Error('Set SEEDROUTER_API_KEY on your server.');
const base = 'https://api.seedrouter.ai/v1';
const headers = { Authorization: `Bearer ${key}` };
const file = 'image-tasks.json';
let state;
try {
  state = JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
  if (error.code !== 'ENOENT') throw error;
  state = {};
}
const save = async () => {
  await writeFile(`${file}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
  await rename(`${file}.tmp`, file);
};
const pause = () => new Promise((resolve) => setTimeout(resolve, 3000));
const jobs = [
  ['blue-mug', 'A cobalt-blue ceramic mug on a pale gray tabletop.'],
  ['green-bowl', 'A green ceramic bowl on a pale gray tabletop.'],
];

if (mode === 'submit') {
  for (const [name, prompt] of jobs) {
    if (state[name]) continue;
    state[name] = { status: 'unconfirmed' };
    await save();
    try {
      const response = await fetch(`${base}/images/generations`, {
        method: 'POST',
        headers: { ...headers, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: 'gpt-image-2', prompt,
          size: '1024x1024', quality: 'low', output_format: 'png', n: 1,
        }),
        signal: AbortSignal.timeout(60000),
      });
      const task = await response.json();
      if (typeof task.id === 'string' && task.id) {
        state[name] = { id: task.id, status: 'processing' };
        await save();
      }
      if (!response.ok || !state[name].id) {
        throw new Error('Submission needs review.');
      }
    } catch {
      console.error(`${name}: stopped; inspect the saved state before continuing.`);
      process.exitCode = 1;
      break;
    }
  }
} else {
  await mkdir('images', { recursive: true });
  const deadline = Date.now() + 600000;
  do {
    for (const [name, entry] of Object.entries(state)) {
      if (!entry.id || entry.status === 'failed' || entry.downloaded) continue;
      try {
        const response = await fetch(`${base}/tasks/${encodeURIComponent(entry.id)}`, {
          headers, signal: AbortSignal.timeout(30000),
        });
        if (!response.ok) throw new Error('Task check failed.');
        const task = await response.json();
        if (!['processing', 'completed', 'failed'].includes(task.status)) {
          throw new Error('Unexpected task status.');
        }
        entry.status = task.status;
        await save();
        if (task.status !== 'completed') continue;
        const images = task.output?.data;
        if (!Array.isArray(images) || !images.length) {
          throw new Error('Completed task has no image URLs.');
        }
        for (const [index, image] of images.entries()) {
          const url = new URL(image.url);
          if (url.protocol !== 'https:') throw new Error('Expected an HTTPS image URL.');
          // The download is a separate request: never attach the API key.
          const result = await fetch(url, { signal: AbortSignal.timeout(60000) });
          if (!result.ok || !result.headers.get('content-type')?.startsWith('image/png')) {
            throw new Error('PNG download failed.');
          }
          await writeFile(`images/${name}-${index}.png`, Buffer.from(await result.arrayBuffer()));
        }
        entry.downloaded = true;
        await save();
      } catch {
        console.error(`${name}: check or download incomplete; task ID retained.`);
      }
    }
    if (!Object.values(state).some((entry) => entry.id && entry.status !== 'failed' && !entry.downloaded)) break;
    await pause();
  } while (Date.now() < deadline);
}
console.log(JSON.stringify(state, null, 2));
```

Run `node images.mjs submit`, then `node images.mjs poll`. Run only one copy of the script at a time, and do not rename labels in the state file. This is a small local example, not a multi-process storage system. For a service with several application instances, use durable storage with exclusive ownership of each submission instead of sharing this JSON file.

## Resume polling without starting another generation

Run `node images.mjs poll` again after a local deadline or a network interruption. It reads saved task IDs and makes GET requests to check them. It does not send another generation request. A completed task with an unfinished download is checked again and its PNG files are downloaded to the same deterministic filenames.

The ten-minute deadline belongs to this example script. It is not a promised completion time or a server-side cancellation. A single in-progress network request can finish after that loop deadline. The [task lifecycle reference](https://seedrouter.ai/docs/api/tasks) defines the actual states: `processing`, `completed`, and `failed`.

An `unconfirmed` item needs manual review because the script has no saved task ID. Do not delete that entry and rerun submission merely to see whether it works the second time. First check your account's task history. If you recover its ID, put that ID in the saved entry and use polling; otherwise resolve the uncertain submission before choosing to generate again.

## How much concurrency should you use?

Begin with sequential submission and a small test set, as this example does. Each accepted task can continue while the script submits the next one, but requests are not fired in an unbounded burst. There is no universal safe concurrency number implied by the code.

For a larger application, make the maximum number of unfinished tasks an explicit setting. Stop admitting new work when that limit is reached, and continue checking already accepted tasks. Handle account limits through the documented [error guidance](https://seedrouter.ai/docs/api/errors), not by blindly repeating POST requests.

Keep a separate record of results that need a creative revision. A completed image you dislike is not a failed API task. It is a new editorial decision and, if you submit another request, a new generation to include in the budget.

## Check the set before calling it finished

Compare your input labels with the state file and downloaded files. Every label should have an explanation: downloaded, still processing, failed, or awaiting submission review. Counting files alone can conceal an unfinished task or a download that stopped halfway through.

Open the images as well. The script checks HTTP status and PNG content type, but it cannot decide whether a mug is the requested color or whether a label is legible. Review the visual brief separately from transport success.

If you change `output_format`, update the expected content type and filename extension together. Renaming a JPEG download to `.png` does not convert it. The example deliberately fixes PNG so that this extra format-handling problem does not obscure task recovery.

## Frequently asked questions

### Is this the OpenAI Batch API?

No. It submits ordinary SeedRouter image tasks from a script. It does not use a Batch API endpoint or imply special batch pricing.

### Can I resume only the downloads?

Yes. Run the polling command with the saved state. It retrieves completed task output again and downloads missing results without creating another image task.

### Does a failed task stop all the other tasks?

The polling loop records that task as failed and continues checking the others. Review the failed item separately. A task that fails is not charged; a local timeout alone does not establish failure.

## Keep the task record with the images

Reliable GPT Image 2 batch generation depends on keeping the link between each prompt and its task ID. Preserve that record, resume existing tasks, and retry downloads separately from generation. Once a small set behaves as expected, increase the workload within a budget you can monitor using the [pricing guide](https://seedrouter.ai/blog/gpt-image-2-pricing).
