DEV Community

genkiln
genkiln

Posted on

Async video generation in Node.js with the Veo 3.1 Fast API

Most text-to-video tutorials end at the first successful response. Production starts after that. A video job runs for minutes, the create call is long gone, and every retry is a chance to create and pay for a second task.

This guide builds a small Node.js client around those facts. The example model is Veo 3.1 Fast, a Google model that returns 4, 6 or 8-second clips with synchronized audio (model documentation). The patterns apply to any queue-backed generation API: create, wait, verify, retry, reconcile cost.

What an async video task looks like

A text-to-video call is two resources: a task you create, and a task you read.

POST /v1/tasks takes a model id, an input object, an optional callback_url, and optional metadata. It answers with 202 Accepted straight away. The body is the task: an id, status, the cost_credits charged, and timestamps. Generation happens behind that response.

For Veo 3.1 Fast the input is small:

  • prompt: up to 5,000 characters, including motion and sound.
  • image_urls: a public HTTPS first frame, and optionally a last frame.
  • duration: 4, 6 or 8 seconds.
  • resolution: 720p or 1080p.
  • aspect_ratio: 16:9, 9:16 or auto.

Unknown fields return 400 invalid_request with the offending field in error.param. That strictness is useful: a typo in resolution fails at submit instead of producing the wrong clip 20 minutes later.

A task moves through queued, processing, succeeded, failed or canceled. Only the last three are final, and a final status never changes. On succeeded, output holds CDN URLs. On failed, error.code explains why and refunded is true.

Cost is fixed when the task is created. Veo prices per video, not per second: 390 credits at 720p and 425 at 1080p, where 1 credit is US$0.001. A 4-second and an 8-second clip cost the same at the same resolution. If you are paying per video, generate the longest duration you can actually use. Duration and resolution are separate fields, but not every combination is valid; check the model page or its JSON Schema before a large batch.

curl -X POST https://genkiln.com/v1/tasks \
  -H "Authorization: Bearer $GENKILN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: video-job-8f2c1a" \
  -d '{
    "model": "veo-3.1-fast",
    "input": {
      "prompt": "A red fox trotting through fresh snow at dawn, low tracking shot",
      "duration": 8,
      "resolution": "720p",
      "aspect_ratio": "16:9"
    },
    "metadata": { "job_id": "8f2c1a" }
  }'

# 202 Accepted
# {"id":"task_...","status":"queued","cost_credits":390,...}
Enter fullscreen mode Exit fullscreen mode

Make creation idempotent before adding retries

A timeout is worse than a 500. The server accepted the task, the response never reached you, and your code believes the request failed.

Idempotency-Key closes that gap. Send a value tied to your logical job, not to the HTTP attempt. Reusing the same key within 24 hours returns the original task, with Idempotent-Replayed: true, instead of creating and charging another one.

Two details decide whether it works.

First, the key must be stable across process restarts. Generate it from your own job id, store it next to the job, and reuse it from the retry worker. A fresh crypto.randomUUID() per attempt defeats the whole mechanism.

Second, the request body must be equivalent on retry. The comparison covers model, input, callback_url and metadata exactly as sent. If your metadata includes attempt: 2 or a freshly signed callback token, the same key returns 409 idempotency_conflict. Freeze the body when the job is first queued, then retry that frozen body unchanged.

Keep metadata small: 16 keys and 2 KB of JSON, with the whole body under 64 KB. It is echoed on the task and in webhooks, so { job_id } is usually enough.

// create-task.mjs — Node 20+
const API = "https://genkiln.com/v1";

export async function createVideoTask({
  jobId,
  prompt,
  resolution = "720p",
  callbackUrl, // omit for polling-only jobs; no URL means no webhook
}) {
  const body = {
    model: "veo-3.1-fast",
    input: { prompt, duration: 8, resolution, aspect_ratio: "16:9" },
    ...(callbackUrl ? { callback_url: callbackUrl } : {}),
    metadata: { job_id: jobId },
  };

  const res = await fetch(`${API}/tasks`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.GENKILN_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `video-job-${jobId}`, // stable across retries
    },
    body: JSON.stringify(body), // same bytes on every attempt
    signal: AbortSignal.timeout(15_000),
  });

  if (!res.ok) {
    const err = await res.json();
    throw Object.assign(new Error(err.message ?? `HTTP ${res.status}`), {
      status: res.status,
      code: err.code,
      requestId: err.request_id,
      retryAfter: res.headers.get("retry-after"),
    });
  }

  return res.json(); // 202: { id, status: "queued", cost_credits, ... }
}
Enter fullscreen mode Exit fullscreen mode

The 15-second timeout covers the HTTP request only. It does not cancel the task, and it should not: creation is designed to return before generation starts.

Polling: pick an interval and a deadline

Polling is the right choice for a local script, a low-volume tool, or a reconciliation job that checks tasks whose webhooks never arrived.

The API docs recommend polling every 3 to 10 seconds for images and every 10 to 30 seconds for video. A one-second loop mostly burns your query limit: reads count against 20 requests per second per API key, shared with every other read your service makes.

The bigger trap is the client deadline. AbortSignal.timeout() aborts the fetch; it does not stop the remote job (MDN notes on AbortSignal.timeout()). If your worker gives up after five minutes, the task can still finish 30 minutes later. You paid at creation, and nothing refunds a task that succeeded after you stopped looking.

So set the client deadline longer than the server's ceiling. Video tasks have a 45-minute server-side limit, counted from created_at; after that the task fails with timeout and is refunded. A 50-minute client deadline plus one final read covers the server's own cutoff.

// poll-task.mjs
const API = "https://genkiln.com/v1";
const FINAL = new Set(["succeeded", "failed", "canceled"]);

export async function pollTask(
  taskId,
  { intervalMs = 15_000, deadlineMs = 50 * 60_000 } = {},
) {
  const startedAt = Date.now();

  while (true) {
    const res = await fetch(`${API}/tasks/${taskId}`, {
      headers: { Authorization: `Bearer ${process.env.GENKILN_API_KEY}` },
      signal: AbortSignal.timeout(15_000),
    });
    if (!res.ok) throw new Error(`poll failed: HTTP ${res.status}`);

    const task = await res.json();
    if (FINAL.has(task.status)) return task;

    if (Date.now() - startedAt > deadlineMs) {
      const err = new Error(`stopped waiting for ${taskId}; task may still finish`);
      err.taskId = taskId;
      throw err;
    }

    await new Promise((r) => setTimeout(r, intervalMs + Math.random() * 5_000));
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep the task id when you give up. A later reconciliation pass can read it once and record the real outcome; without the id, the charge becomes an unexplained line in your billing report.

Also, do not poll inside the HTTP handler that created the task. A request that holds a connection for 40 minutes will hit a load balancer timeout, and your user gets a 504 while the task succeeds. Create in the request, wait in a worker.

Webhooks: verify, enqueue, respond fast

For real volume, pass callback_url at creation and let the API call you. Each task sends at most one event: task.succeeded, task.failed or task.canceled. The body is the same task object that GET /v1/tasks/{id} returns.

Delivery is at-least-once. Return any 2xx within 10 seconds. A non-2xx response, a timeout, a connection error or a redirect counts as a failure, and the API retries after 1 minute, 5 minutes, 30 minutes and 2 hours, five attempts in total. Deduplicate on the Genkiln-Delivery header, or on task id plus status.

Verify the signature before parsing anything:

  1. Read the raw request body as bytes.
  2. Split Genkiln-Signature on , into t and v1.
  3. Compute HMAC-SHA256 with your whsec_... secret over t + "." + rawBody.
  4. Compare in constant time, and reject if t is more than five minutes from now.
// webhook.mjs
import express from "express";
import crypto from "node:crypto";

const app = express();
const secret = process.env.GENKILN_WEBHOOK_SECRET; // whsec_...

app.post(
  "/hooks/genkiln",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const parts = Object.fromEntries(
      (req.get("Genkiln-Signature") ?? "").split(",").map((p) => p.split("=")),
    );
    const timestamp = Number(parts.t);
    const raw = req.body; // Buffer, never parsed

    const expected = crypto
      .createHmac("sha256", secret) // include the whsec_ prefix
      .update(`${parts.t}.`)
      .update(raw)
      .digest("hex");

    const given = Buffer.from(parts.v1 ?? "", "utf8");
    const mine = Buffer.from(expected, "utf8");
    const fresh = Math.abs(Date.now() / 1000 - timestamp) < 300;

    if (!fresh || given.length !== mine.length || !crypto.timingSafeEqual(given, mine)) {
      return res.sendStatus(401);
    }

    const task = JSON.parse(raw.toString("utf8"));
    enqueueOnce(req.get("Genkiln-Delivery"), task); // idempotent insert, then queue
    res.sendStatus(204); // under 10 seconds
  },
);

async function enqueueOnce(deliveryId, task) {
  // INSERT ... ON CONFLICT (delivery_id) DO NOTHING, then push to your job queue
}
Enter fullscreen mode Exit fullscreen mode

Use crypto.timingSafeEqual for the comparison (Node crypto docs); a plain === leaks timing information.

The usual bug sits in the middleware order. express.json() parses and re-serializes the body, which changes the bytes, so the HMAC no longer matches. Register the webhook route with express.raw({ type: "application/json" }) before the JSON parser, or mount it on a separate app.

Then do the slow work after the response: download the MP4, copy it to your bucket, update the database, enqueue a thumbnail job. Output URLs are public and unguessable, but they are deleted 30 days after completion (expires_at), so the webhook is your best moment to take custody of the file.

callback_url must be a public HTTPS URL on port 443, with no credentials, at most 2,048 characters, and it is checked again before every delivery. localhost will not work; use a tunnel during development.

Retry only what can be retried

A retry policy needs a decision per status code, not one try/catch around everything.

Response Retry? Charge
400 invalid_request No No task created
401 invalid_api_key No No task created
402 insufficient_credits After top-up No task created
403 content_rejected No No task created
409 idempotency_conflict No; reuse the original body Original task stands
422 model_unavailable Later No task created
429 rate_limited Yes, after Retry-After No task created
500 internal_error Yes, with backoff Unknown; reconcile
503 service_busy Yes, after Retry-After Refunded; error.task included
Network error, no response Yes, same idempotency key Unknown; reconcile

The 500 row is why the idempotency key matters even for POST. You cannot tell from a dropped connection whether a task was created. Retrying with the same key returns the first task if it exists, or creates one if it does not. When you already have the id, GET /v1/tasks/{id} settles the state without another create.

429 has two causes: request rate, or 20 tasks already queued or processing on the account. Creation allows 5 requests per second per key; the 20-request figure above applies to reads. The concurrency response returns Retry-After: 10 and will not clear until some tasks finish, so a tight retry loop just adds load. Cap your own concurrency below 20 and queue the rest.

// retry.mjs
const RETRYABLE = new Set([429, 500, 503]);

export async function withRetry(fn, { attempts = 4, baseMs = 500 } = {}) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const isLast = attempt === attempts - 1;
      const retryable = err.status === undefined || RETRYABLE.has(err.status);
      if (isLast || !retryable) throw err;

      const retryAfter = err.retryAfter == null ? NaN : Number(err.retryAfter);
      const delay = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : baseMs * 2 ** attempt + Math.random() * 250;

      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Jitter matters when several workers retry together. Without it, a 429 for 20 workers becomes a synchronized retry every second. Retry-After wins over your backoff when present.

Check the cost before the batch, not after

cost_credits in the create response is the authoritative number for that task. Compute a batch estimate from it rather than from a price table in your head:

  • 40 clips at 720p: 40 × 390 = 15,600 credits = US$15.60.
  • 40 clips at 1080p: 40 × 425 = 17,000 credits = US$17.00.

GET /v1/balance tells you whether the batch fits. If the balance is too low, creation returns 402 insufficient_credits and no task exists, so a partially submitted batch is easy to detect: the failed job never got an id.

Refunds are automatic for failed, timed-out and canceled tasks, and the task object carries refunded: true. That makes reconciliation simple: sum cost_credits for tasks that did not end in succeeded, and check that each has a refund. Do not assume a failed request means no charge: a 503 can return a failed task in error.task that was charged and refunded, and you still want it in your records.

Track cost per usable asset, not per request. A batch where 10% of prompts are rejected by content screening costs nothing for the rejections, but it does cost you a retry cycle and a delay. Screening prompts before submission is cheaper than discovering the problem in the queue.

Finally, storage is outside the credit price. If you generate 500 clips a month and keep them, plan the bucket and the download step; the 30-day expiry will eventually delete anything you did not copy.

A short checklist

  • Key every create with a stable job id; freeze model, input, callback and metadata.
  • Poll every 10 to 30 seconds for video; set the client deadline above 45 minutes.
  • Prefer webhooks above a handful of concurrent jobs; verify raw bytes, then enqueue.
  • Deduplicate deliveries on Genkiln-Delivery.
  • Retry 429, 500 and 503 only, with Retry-After and jitter.
  • Record cost_credits and refunded per task; compare the batch total with your balance.

The transport layer is the same for every model behind an async API. What changes is the input schema and the price, so keep those in configuration and keep the job lifecycle in one place.

If you want a concrete endpoint to test against, genkiln exposes the Veo 3.1 Fast model with the fields used above, plus a JSON Schema per model and a public price list. It is an independent API service, not a Google product. The same client works with any queue-backed model; swap the model id and the input object, and keep the idempotency, polling and webhook code unchanged.

Top comments (1)

Collapse
 
aiapikey profile image
于侃 •

The verify-before-retry step is where I would push harder: it has a race. Between your failed create and the status check, the retry can already be in flight, and nothing in the response tells you both belong to the same intent.

The pattern that closes it is a client-side idempotency key sent with the create call and persisted before the request goes out. If the provider has no such header, generate a request id, write it to your own ledger as pending, and dedupe on it locally - then a retry is a lookup, not a second purchase.

The reconciliation invariant worth asserting: the sum of cost_credits across every task id you recorded for a window must equal the invoice. A gap means a task exists that you never logged.