Node 22 script: create a Sume bulk queue and poll it to the end

Dependency-free Node 22 ESM script: POST a bulk queue from items.json, back off the poll, survive 429 and 503, and exit non-zero when any item failed.

5 min readSume
All posts

Node 22 has fetch, AbortSignal.timeout and top-level await, which is enough to drive a Sume bulk queue with no packages. The script reads an items.json array, sends it to POST /v1/formats/{handle}/{slug}/bulk-runs, then polls the status_url from the receipt until status is completed. It exits with code 1 when counts.failed or counts.canceled is above zero, so a shell, a cron wrapper or CI can tell a finished-but-broken batch from a clean one.

Save it as bulk.mjs. The .mjs extension makes Node treat it as an ES module, which top-level await needs.

The script

Set SUME_API_KEY (a key with formats:write and formats:read), SUME_FORMAT as handle/slug, and BATCH_KEY as the idempotency key for this batch.

import { readFileSync } from "node:fs";

const BASE = process.env.SUME_BASE ?? "https://api.sume.com/v1";
const headers = { Authorization: `Bearer ${process.env.SUME_API_KEY}`, "Content-Type": "application/json" };
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

const items = JSON.parse(readFileSync("items.json", "utf8"));
const create = await fetch(`${BASE}/formats/${process.env.SUME_FORMAT}/bulk-runs`, {
  method: "POST",
  signal: AbortSignal.timeout(30_000),
  headers: { ...headers, "Idempotency-Key": process.env.BATCH_KEY },
  body: JSON.stringify({ concurrency: 4, items }),
});
if (create.status !== 202) throw new Error(`create ${create.status}: ${await create.text()}`);
let queue = (await create.json()).data;
console.log("queue", queue.id);

for (let gap = 15; queue.status !== "completed"; gap = Math.min(gap * 2, 60)) {
  await sleep(gap);
  const res = await fetch(queue.status_url, { headers, signal: AbortSignal.timeout(30_000) });
  if (res.status === 429 || res.status === 503) {
    await sleep(Number(res.headers.get("retry-after") ?? 30));
    continue;
  }
  if (!res.ok) throw new Error(`poll ${res.status}: ${await res.text()}`);
  queue = (await res.json()).data;
}
console.log(queue.counts);
process.exitCode = queue.counts.failed + queue.counts.canceled > 0 ? 1 : 0;

Choices in it that come from the docs

The create check is status !== 202, not res.ok. A fresh queue and a replay with the same key both answer 202; the queue object has no idempotency_hit field, so there is no way to tell them apart from the status code. The BATCH_KEY you pass is therefore the thing that decides whether a second run of this script makes a second queue.

The gap between polls doubles to a ceiling of 60 seconds. Video runs take minutes, and a poll every second only uses read budget. A 429 or 503 while polling is transient: the queue keeps working on the server, so the loop waits (using retry-after when it is present) and polls again instead of throwing.

Any other non-2xx on a poll throws. A 404 format_run_queue_not_found means the id is wrong or the queue belongs to another owner, and retrying will not change that. The same goes for 403 insufficient_scope when the key lacks formats:read.

Exit codes of the script and what they mean (read 2026-10-07)
ExitMeaningNext step
0Queue completed with failed: 0 and canceled: 0Read each item's run_id for media
1Queue completed with failures or cancellationsFor each failed item, read the run receipt at GET /v1/format-runs/{run_id}
1 (uncaught throw)Create returned something other than 202, or a poll hit a non-transient errorRead the printed status and error body; a 400 carries details.index

Using the result

The queue's items array is in the same order as the file you sent. Item 0 is element 0 of items.json. The script prints only counts; if you want a per-item report, add a line after the loop that maps queue.items to { index, status, run_id }. The error on a failed item is only format_run_failed or format_run_canceled. The reason lives on the run receipt.

Each item is a complete single-run body, so a file can mix SKUs with different instruction text, different input objects, or shared attachments by asset id. Items are validated before the queue exists: one bad item means a 400 with details.index and nothing started, which is why a small dry run of five rows is cheap insurance before the full file.

Keep the queue id that the script prints. The API has no list-queues endpoint, and the id is also the only way to resume polling if the process dies. If you lose it, sending the same create call with the same key and the same body returns the original queue.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume