# Node.js video tasks: submit, poll and save

## Verify the model before you spend

This server-side Node.js example uses the public `minimax-h3` task contract, not MiniMax's upstream API. It creates one video and incurs usage. Confirm the model is present in `GET /v1/media-models`, check its `request_schema` against the **complete** input, and review your [account model pricing](/account/pricing). The [MiniMax H3 guide](/docs/models/minimax-h3) and [model page](/model/minimax/minimax-h3) explain the current contract and calculator.

The example uses 5 seconds, `768P` and `16:9`. These are request selections, not a fixed-pixel, speed or quality guarantee. Another model can use different names, values or combinations. Do not change only the model ID and assume the input remains valid.

## Run or resume one task

Use Node.js 24 or later. Set `API_KEY` in the server environment; never put it in client code or source control. Save the script as `video-task.mjs` and run `node video-task.mjs`. It saves the accepted ID to a local record before polling. Keep that record: an HTTP 202 is acceptance, not the final video.

To resume, set `TASK_ID` to the original ID and run the script again; this skips submission. `OUTPUT_DIR` optionally selects the local download directory. Existing output files are not overwritten. If a download was already completed, use a fresh directory or download only missing files rather than generating again.

```javascript
import { mkdir, open, rm, writeFile } from 'node:fs/promises';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { join } from 'node:path';

const origin = 'https://newrouters.com';
const key = process.env.API_KEY;
if (!key) throw new Error('Set API_KEY on your server');
const headers = { Authorization: `Bearer ${key}` };
const outputDir = process.env.OUTPUT_DIR || 'video-output';
let taskId = process.env.TASK_ID;

if (!taskId) {
  // One submission only. Never automatically retry an uncertain POST.
  const response = await fetch(`${origin}/v1/tasks`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'minimax-h3',
      input: {
        prompt: 'A paper boat floating on calm water, fixed camera.',
        seconds: 5, resolution: '768P', aspect_ratio: '16:9',
      },
    }),
    signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 202) throw new Error(`Submit HTTP ${response.status}; inspect before retrying`);
  const accepted = await response.json();
  if (typeof accepted.id !== 'string' || !/^task_[0-9A-HJKMNP-TV-Z]{26}$/.test(accepted.id)) throw new Error('Missing task ID; investigate before resubmitting');
  taskId = accepted.id;
  console.log(`TASK_ID=${taskId}`);
  await writeFile(`task-${taskId}.json`, JSON.stringify({ id: taskId }), { flag: 'wx', mode: 0o600 });
}
if (!/^task_[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) throw new Error('Invalid TASK_ID');

// Ten minutes is this client's polling budget, not a service completion promise.
const deadline = Date.now() + 10 * 60 * 1000;
let completed;
while (Date.now() < deadline) {
  const response = await fetch(`${origin}/v1/tasks/${encodeURIComponent(taskId)}`, {
    headers, signal: AbortSignal.timeout(Math.max(1, Math.min(15000, deadline - Date.now()))),
  });
  if (!response.ok) throw new Error(`Poll HTTP ${response.status}; resume with TASK_ID=${taskId}`);
  const task = await response.json();
  if (task.status === 'failed') throw new Error(`Task failed: ${task.error?.code || 'unknown'}; inspect ${taskId}`);
  if (task.status === 'succeeded') { completed = task; break; }
  if (!['pending', 'processing'].includes(task.status)) throw new Error(`Unexpected state; inspect ${taskId}`);
  await delay(Math.min(3000, Math.max(0, deadline - Date.now())));
}
if (!completed) throw new Error(`Polling deadline reached; resume with TASK_ID=${taskId}, do not resubmit`);
const assets = completed.result?.assets;
if (!Array.isArray(assets) || !assets.length || assets.some(url => typeof url !== 'string')) {
  throw new Error(`No valid result assets; inspect ${taskId}`);
}
await mkdir(outputDir, { recursive: true });
for (const [index, rawUrl] of assets.entries()) {
  const url = new URL(rawUrl);
  if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Unexpected asset URL');
  // No API authorization header is forwarded to the asset host.
  const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(60000) });
  if (!response.ok || !response.body) throw new Error(`Asset HTTP ${response.status}; retry download, not generation`);
  const type = response.headers.get('content-type')?.split(';')[0];
  const extension = type === 'video/mp4' ? 'mp4' : type === 'video/webm' ? 'webm' : 'bin';
  const path = join(outputDir, `${taskId}-${index}.${extension}`);
  const file = await open(path, 'wx', 0o600); // Never overwrite an existing output.
  let bytes = 0;
  const limit = new Transform({ transform(chunk, _encoding, callback) {
    bytes += chunk.length;
    callback(bytes > 100 * 1024 * 1024 ? new Error('Asset exceeds local 100 MiB limit') : null, chunk);
  } });
  try {
    await pipeline(Readable.fromWeb(response.body), limit, file.createWriteStream());
  } catch (error) {
    await file.close();
    await rm(path, { force: true });
    throw error;
  }
  console.log(`Saved ${path}`);
}
```

## Know what a timeout means

`pending` and `processing` are non-final states; `succeeded` has outputs and `failed` has an error. Polling runs against the same task ID. The 30-second submission timeout, 10-minute polling deadline, 3-second interval and 100 MiB download cap are local example policies, not platform limits or service-level promises.

A timeout or connection loss during POST can leave the outcome unknown. Stop and inspect task history before submitting anything else. If the ID was returned but saving the local record failed, keep the printed ID and resume with `TASK_ID`. A polling deadline does not cancel generation or prove that no charge occurred. Read-only polling errors stop this example; investigate the status and resume the same ID when appropriate.

## Save the output safely

The public response uses `result.assets` as an array of URL strings. Download only after `succeeded`. The script streams each file with a size cap, does not forward API credentials, rejects redirects and removes a partially written file on download failure. Unknown content types get `.bin`; inspect them before use. Generated asset links are not a durable archive: keep files you are authorized to retain.

For a production downloader, restrict asset destinations using your deployment's trusted media hosts and enforce outbound network policy. Do not turn arbitrary user URLs into a server-side download endpoint. A failed download is not a reason to recreate a paid video task.

Review motion, composition and subject consistency before accepting the clip. Reconcile task charges using the [batch budget guide](/docs/media/batch-budget); return to the [media learning path](/docs/media) for input and workflow choices.
