Deno fetch: 25 s Wan 3.0 clip costs $3.125, 20-minute deadline

Deno script: submit a 25-second Wan 3.0 clip to Sume at 720p ($3.125), poll with backoff, stop at a 20-minute deadline. Run with deno run -A.

5 min readSume
All posts

To call a 25-second Wan 3.0 video from Deno, POST to https://api.sume.com/v1/videos with model wan-3.0, duration 25 and resolution 720p, expect a 202 with a polling_url, and poll that URL until the status is completed. At 720p the list price is $0.125 per output second, so 25 seconds is 25 x 0.125 = $3.125. The script below adds the two things most copied examples leave out: a hard deadline for the whole wait and a poll delay that grows instead of hammering the status route.

No SDK is needed. Deno ships fetch, AbortSignal and top-level await, so the file is one script. Save it as clip.ts, export SUME_API_KEY, and run deno run -A clip.ts.

What the request asks for

wan-3.0 accepts 2 to 30 seconds, so 25 is inside its range. The Idempotency-Key header makes a retried submit return the original job instead of a second paid one; the key in the sample is a fixed string, so change it for each new clip you want to pay for. The table lists each field and the number behind it.

Request fields and the price arithmetic for one wan-3.0 clip (Sume docs and price list, read 2026-10-09)
FieldValue in the sampleWhy
modelwan-3.0Bare catalog id; Sume ids have no org prefix
duration25wan-3.0 accepts 2-30 s
resolution720pList price $0.125 per second
aspect_ratio16:9Subset reported by GET /v1/videos/models
Cost25 x 0.125 = $3.125Per-second price is linear inside the valid range
Same clip at 1080p25 x 0.25 = $6.251080p is $0.25 per second

A poll loop with a deadline

Two timeouts do different jobs. AbortSignal.timeout(20 * 60_000) is the deadline for the whole wait; the script checks it with throwIfAborted() before each poll, so a job that sits in a queue for longer than 20 minutes ends your script with a clear error instead of an endless loop. The per-request signal (15 seconds) only protects one GET from a hung connection.

The delay starts at 2 seconds and is multiplied by 1.5 after every poll, capped at 30 seconds. The waits are 2, 3, 4.5, 6.75, 10.1, 15.2, 22.8 and then 30 seconds, so the first eight polls take about 94 seconds and the loop settles at one poll per 30 seconds. Over a full 20-minute deadline that is roughly 44 polls. The Sume docs' own examples poll every 30 seconds, and short clips usually finish faster than that, which is why the loop opens with short waits.

const auth = { Authorization: `Bearer ${Deno.env.get("SUME_API_KEY")}` };
const sub = await fetch("https://api.sume.com/v1/videos", {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": "wan-kettle-25s-001" },
  body: JSON.stringify({
    model: "wan-3.0",
    prompt: "Slow orbit around a ceramic kettle on a wooden table",
    duration: 25,
    resolution: "720p",
    aspect_ratio: "16:9",
  }),
});
if (sub.status !== 202) throw new Error(`submit ${sub.status}: ${await sub.text()}`);
const job = await sub.json();
const deadline = AbortSignal.timeout(20 * 60_000);
let wait = 2_000;
while (true) {
  await new Promise((r) => setTimeout(r, wait));
  deadline.throwIfAborted();
  const res = await fetch(job.polling_url, { headers: auth, signal: AbortSignal.timeout(15_000) });
  const s = await res.json();
  if (s.status === "completed") {
    console.log(job.id, s.unsigned_urls[0], s.usage?.cost);
    break;
  }
  if (s.status === "failed" || s.status === "cancelled") throw new Error(JSON.stringify(s.error));
  wait = Math.min(wait * 1.5, 30_000);
}

The script

The loop reads status from the poll response. completed carries unsigned_urls and usage.cost; failed and cancelled carry or imply an error, and the sample throws on both. Any other status keeps the loop going.

What can go wrong

A 202 is the only success the sample accepts. A 402 means the balance does not cover the reserved amount, a 429 can be rate_limited or queue_full, and a 400 means the body was rejected; the error envelope carries a request_id you can quote to support. Poll requests are reads, which have their own per-minute budget on each key, so the loop cannot starve your submits.

On a Free workspace only six paid jobs can be accepted at once (one processing, five queued), so a second 25-second submit while the first one runs is accepted as queued rather than started. Treat queued as a normal status, not a failure.

  • Keep the deadline longer than the longest job you expect, not equal to it.
  • Log job.id before anything else so a crash after submit can be recovered with GET /v1/videos/{id}.
  • Use usage.cost from the completed response for your books, not your own multiplication.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume