H3 Max Recast in TypeScript: submit, poll, read the result

A runnable Node script for Recast on Sume: submit with an Idempotency-Key, poll status honoring next_poll_after_seconds, then fetch the result.

5 min readSume
All posts

The loop in short

Recast is a long job: fal prices it per second of source video, so a 30-second clip is not an instant answer. Sume's Jobs and results page says the 30-second sync wait is a budget for the HTTP request, not a limit on the job, and that anything that can outlast it should be submitted async and polled.

The loop is three steps. Submit with mode: async and an Idempotency-Key. Poll GET /v1/jobs/{id}/status until terminal is true, waiting next_poll_after_seconds when it is present. Read GET /v1/jobs/{id}/result when result_ready is true.

The script

Run it with Node 18 or newer through a TypeScript runner such as tsx. It uses the built-in fetch, wraps everything in main() so nothing needs top-level await, and exits when the key is missing. Replace the two URLs and set duration to the source length rounded up.

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

async function main(): Promise<void> {
  if (!key) throw new Error("Set SUME_API_KEY first");
  const submit = await fetch(`${base}/v1/video-router/generate`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": "recast-ts-001" },
    body: JSON.stringify({
      model: "h3-max-recast",
      video_url: "https://example.com/source.mp4",
      reference_image_urls: ["https://example.com/new-host.jpg"],
      resolution: "768p",
      duration: 12,
      mode: "async",
    }),
  });
  const sent = await submit.json();
  if (!submit.ok) throw new Error(JSON.stringify(sent));
  const id: string = sent.request_id ?? sent.job?.id;
  for (;;) {
    const s = await (await fetch(`${base}/v1/jobs/${id}/status`, { headers })).json();
    if (s.terminal) {
      if (!s.result_ready) throw new Error(`Job ended: ${s.sume_status}`);
      break;
    }
    await sleep(s.next_poll_after_seconds ?? 10);
  }
  const result = await fetch(`${base}/v1/jobs/${id}/result`, { headers });
  console.log(JSON.stringify(await result.json(), null, 2));
}

main().catch((e) => { console.error(e); process.exit(1); });

What each part protects

  • The Idempotency-Key makes a retried submit return the original job instead of billing a second one.
  • The terminal check stops the loop on completed, failed or canceled; reading the result of a job that did not complete returns 409 job_not_completed.
  • A client-side timeout does not cancel the job. It keeps running and billing, so store the id before you start polling.
  • duration must be the real source length, rounded up and between 5 and 30. fal's page, read 2026-10-03, lists $0.30 per second at 768p and $0.45 at 1080p, so the number drives the price.

Running it

Install tsx or use any TypeScript runner that supports Node's built-in fetch, set SUME_API_KEY in the shell, replace the two example URLs with public HTTPS files, and run the file. The first thing to check is the submit response: a 400 means a field was refused, for example a photo URL that is not public or a duration outside 5 to 30, and a 402 means the balance cannot cover the reservation.

Once the job is accepted the id is all you need. If your process dies, start a new one, read GET /v1/jobs/{id}/status, and carry on from there. Do not submit again, because a second submit is a second paid swap unless you reuse the same Idempotency-Key.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume