<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Revana Blog]]></title><description><![CDATA[Revana Blog]]></description><link>https://revana.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae981d356f088e6126784f9/c83b92a2-a08c-4eed-afad-8515b735f036.png</url><title>Revana Blog</title><link>https://revana.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:55:15 GMT</lastBuildDate><atom:link href="https://revana.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Add AI video generation to a Next.js app: route handlers, spend caps, polling and webhooks]]></title><description><![CDATA[Next.js routes for AI video: keep the key on the server, cap spend per request, poll or use webhooks, and serve the file.]]></description><link>https://revana.hashnode.dev/nextjs-ai-video-generation-api</link><guid isPermaLink="true">https://revana.hashnode.dev/nextjs-ai-video-generation-api</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Thu, 05 Nov 2026 11:45:39 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I work on <a href="https://revana.si?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-6">Revana</a>, a pay-as-you-go API for image and video models. The code uses its SDK (<code>npm install revana</code>, MIT) and REST API, but the structure applies to any video API you put behind a web app.</p>
</blockquote>
<p>Putting a "Generate video" button in a Next.js app sounds like one <code>fetch</code>. In practice video generation has three properties that shape the whole design:</p>
<ul>
<li><strong>It's slow.</strong> A 5-second clip takes one to several minutes. A route handler that waits for it will hit your platform's function time limit, and a user who refreshes loses the job.</li>
<li><strong>It costs real money per request.</strong> Anyone who finds your API route can call it in a loop. The key must stay on the server, and the server must decide what one request may cost.</li>
<li><strong>It fails sometimes</strong>, and a naive retry starts a second paid job.</li>
</ul>
<p>So the shape is: a route that <strong>starts</strong> a job and returns its id, a route that reports <strong>status</strong>, a route that serves the <strong>file</strong>, and a client that polls (or a webhook that tells you when it's done). This post builds those with the App Router.</p>
<h2>Setup</h2>
<pre><code class="language-bash">npm install revana
</code></pre>
<pre><code class="language-bash"># .env.local
REVANA_API_KEY=sr_live_...        # from revana.si/dashboard; server only, no NEXT_PUBLIC_ prefix
REVANA_MAX_COST_USD=2             # the most one request may cost
</code></pre>
<h2>1. Start a job: <code>app/api/videos/route.ts</code></h2>
<pre><code class="language-ts">import Revana, { RevanaError } from "revana";

const revana = new Revana(); // reads REVANA_API_KEY
const MAX_USD = Number(process.env.REVANA_MAX_COST_USD ?? 2);

// Only what your product offers. Never pass a client-chosen model or input straight through.
const MODELS: Record&lt;string, { resolution: string; durations: number[] }&gt; = {
  "wan-3.0": { resolution: "720p", durations: [5, 10] },
  "kling-4.0": { resolution: "720p", durations: [5, 10] },
  "seedance-2.5": { resolution: "720p", durations: [5] },
};

export async function POST(req: Request) {
  const user = await requireUser(req); // your auth: session check, plus a per-user rate limit
  const { model, prompt, duration, imageUrl } = await req.json();

  const spec = MODELS[model];
  if (!spec || !spec.durations.includes(duration) || typeof prompt !== "string" || prompt.length &gt; 2000) {
    return Response.json({ error: "invalid request" }, { status: 400 });
  }
  const body = {
    model,
    input: {
      prompt, duration, resolution: spec.resolution, aspect_ratio: "9:16", audio: true,
      ...(imageUrl ? { image_url: imageUrl } : {}),
    },
  };

  // 1. Price the exact request (free: nothing is created or charged).
  const q = await fetch("https://api.revana.si/v1/generations/quote", {
    method: "POST",
    headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const quote = await q.json();
  if (!q.ok) return Response.json({ error: quote.error?.message ?? "cannot price this" }, { status: 422 });
  if (quote.quoted_usd &gt; MAX_USD) {
    return Response.json({ error: `costs $${quote.quoted_usd}, over the $${MAX_USD} limit` }, { status: 402 });
  }

  // 2. Create it, capped at the quote, with the client's idempotency key.
  const key = req.headers.get("idempotency-key");
  if (!key || key.length &gt; 100) return Response.json({ error: "Idempotency-Key header required" }, { status: 400 });
  try {
    const gen = await revana.generations.create(
      { ...body, max_cost_usd: quote.quoted_usd },
      { idempotencyKey: `${user.id}:${key}` },
    );
    await saveJob({ id: gen.id, userId: user.id, quotedUsd: quote.quoted_usd }); // your DB
    return Response.json({ id: gen.id, status: gen.status, quoted_usd: gen.quoted_usd }, { status: 202 });
  } catch (e) {
    if (e instanceof RevanaError) return Response.json({ error: e.code, message: e.message }, { status: e.status || 502 });
    throw e;
  }
}
</code></pre>
<p>What each part protects against:</p>
<ul>
<li><strong>Allow-list, not pass-through.</strong> The client picks from your models and durations; the server fills in resolution and everything else. Otherwise someone sends <code>resolution: "1080p", duration: 30</code> and you pay for it.</li>
<li><strong>Quote, then cap at the quote.</strong> <code>POST /v1/generations/quote</code> takes the same body as the create and answers <code>quoted_usd</code> (the normal price) and <code>reserved_usd</code> (the most it could cost if a dearer backup provider delivers). Some inputs cost more than a model's headline rate: on November 5, 2026, a 5-second 720p Seedance 2.5 clip quoted $1.05 from text but $1.25 with a start image. The quote handles that, and <code>max_cost_usd: quoted_usd</code> makes it a hard ceiling. A request that can't run at that price fails with <code>max_cost_exceeded</code> and costs nothing.</li>
<li><strong>The idempotency key comes from the client.</strong> The browser generates one key per click (below) and resends it if the request times out. The same key with the same body returns the original job instead of a second paid one. Prefixing it with the user id stops two users' keys from colliding, since keys are scoped to your Revana account, not to your users.</li>
<li><strong>Save the job with its owner.</strong> The status and file routes below check that the job belongs to the caller. Without that, anyone who guesses or sees a <code>gen_...</code> id can read someone else's video through your key.</li>
</ul>
<p><code>requireUser</code> and <code>saveJob</code> stand in for your own auth and database.</p>
<h2>2. Report status: <code>app/api/videos/[id]/route.ts</code></h2>
<pre><code class="language-ts">import Revana from "revana";

const revana = new Revana();

export async function GET(req: Request, { params }: { params: Promise&lt;{ id: string }&gt; }) {
  const user = await requireUser(req);
  const { id } = await params;
  const job = await findJob(id);
  if (!job || job.userId !== user.id) return Response.json({ error: "not found" }, { status: 404 });

  const g = await revana.generations.get(id);
  return Response.json({
    id: g.id,
    status: g.status, // queued | running | succeeded | failed
    cost_usd: g.cost_usd, // null while running, 0 if it failed
    error: g.error &amp;&amp; { code: g.error.code, message: g.error.message, retryable: g.error.retryable },
  });
}
</code></pre>
<p>The browser gets only what it needs. <code>error.message</code> is written to be shown to the person who asked, and <code>retryable</code> says whether trying the same request later can work (<code>model_busy</code>, <code>timeout</code>) or whether it needs to change (<code>content_policy</code>, <code>invalid_input</code>). A failed generation is charged $0.</p>
<h2>3. Serve the file: <code>app/api/videos/[id]/content/route.ts</code></h2>
<pre><code class="language-ts">export async function GET(req: Request, { params }: { params: Promise&lt;{ id: string }&gt; }) {
  const user = await requireUser(req);
  const { id } = await params;
  const job = await findJob(id);
  if (!job || job.userId !== user.id) return new Response("not found", { status: 404 });

  const res = await fetch(`https://api.revana.si/v1/generations/${encodeURIComponent(id)}/content`, {
    headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}` },
    redirect: "manual",
    cache: "no-store",
  });
  const location = res.headers.get("location");
  if (location) return Response.redirect(location, 302); // a short-lived link to the file
  return new Response(res.body, { status: res.status, headers: { "content-type": res.headers.get("content-type") ?? "video/mp4" } });
}
</code></pre>
<p><code>/content</code> answers with a redirect to a short-lived file link. Handling the redirect yourself (<code>redirect: "manual"</code>) means your <code>Authorization</code> header is never sent to the storage host, and the browser follows the link without ever seeing your key. Result files expire, so if users should keep their videos, copy the file into your own storage (S3, R2, Vercel Blob) when the job succeeds rather than relying on the link later.</p>
<h2>4. The client: start, poll, show</h2>
<pre><code class="language-tsx">"use client";
import { useEffect, useRef, useState } from "react";

export function VideoButton() {
  const [job, setJob] = useState&lt;{ id: string; status: string; error?: { message: string } } | null&gt;(null);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const key = useRef&lt;string | null&gt;(null);

  async function start() {
    setError(null);
    key.current ??= crypto.randomUUID(); // one key per intent; reused if this request is retried
    const res = await fetch("/api/videos", {
      method: "POST",
      headers: { "content-type": "application/json", "idempotency-key": key.current },
      body: JSON.stringify({ model: "wan-3.0", prompt: "a paper boat drifting down a rainy street at night", duration: 5 }),
    });
    const data = await res.json();
    if (!res.ok) return setError(data.message ?? data.error);
    key.current = null; // the next click is a new video
    setJob(data);
  }

  useEffect(() =&gt; {
    if (!job || job.status === "succeeded" || job.status === "failed") return;
    const t = setTimeout(async () =&gt; setJob(await fetch(`/api/videos/${job.id}`).then((r) =&gt; r.json())), 5000);
    return () =&gt; clearTimeout(t);
  }, [job]);

  return (
    &lt;div&gt;
      &lt;button onClick={start} disabled={!!job &amp;&amp; !["succeeded", "failed"].includes(job.status)}&gt;Generate video&lt;/button&gt;
      {error &amp;&amp; &lt;p&gt;{error}&lt;/p&gt;}
      {job &amp;&amp; job.status !== "succeeded" &amp;&amp; job.status !== "failed" &amp;&amp; &lt;p&gt;Rendering… ({job.status})&lt;/p&gt;}
      {job?.status === "failed" &amp;&amp; &lt;p&gt;{job.error?.message} You were not charged.&lt;/p&gt;}
      {job?.status === "succeeded" &amp;&amp; &lt;video src={`/api/videos/${job.id}/content`} controls autoPlay muted playsInline /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<p>Polling every 5 seconds is plenty for a job that takes minutes, and it stays well clear of the API's per-second burst guard. Because the job id is in your database, you can also list a user's past and in-progress videos after a refresh instead of keeping state only in React.</p>
<h2>5. Optional: a webhook instead of polling the API</h2>
<p>If your app is deployed on public HTTPS, Revana's queue endpoint can call you when a job finishes. It takes the model input directly (no <code>{model, input}</code> wrapper), with <code>max_cost_usd</code> alongside it, and the callback URL in <code>fal_webhook</code>:</p>
<pre><code class="language-ts">const callback = encodeURIComponent("https://your-app.example/api/revana/webhook");
const res = await fetch(`https://api.revana.si/v1/queue/revana/${model}?fal_webhook=${callback}`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.REVANA_API_KEY}`,
    "content-type": "application/json",
    "idempotency-key": `${user.id}:${key}`,
  },
  body: JSON.stringify({ ...body.input, max_cost_usd: quote.quoted_usd }),
});
const { request_id } = await res.json(); // 202; store it with the user and the model
</code></pre>
<p>The handler, <code>app/api/revana/webhook/route.ts</code>:</p>
<pre><code class="language-ts">export async function POST(req: Request) {
  const { request_id } = await req.json();
  const job = await findJobByRequestId(request_id);
  if (!job || job.done) return new Response(null, { status: 200 }); // unknown or already handled

  // Callbacks are unsigned: confirm with your own key before trusting them.
  const s = await fetch(
    `https://api.revana.si/v1/queue/revana/${job.model}/requests/${encodeURIComponent(request_id)}/status`,
    { headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}` }, cache: "no-store" },
  ).then((r) =&gt; r.json());
  if (s.status !== "COMPLETED") return new Response(null, { status: 200 });

  await markJobDone(request_id, { failed: Boolean(s.error), error: s.error }); // then notify the user
  return new Response(null, { status: 200 });
}
</code></pre>
<p>The rules from <a href="https://revana.si/queue.md?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-6">the queue docs</a> that this follows:</p>
<ul>
<li><strong>Unsigned callbacks.</strong> Treat the POST as "something may have changed" and check the status with your key.</li>
<li><strong>At-least-once delivery.</strong> The same <code>request_id</code> can arrive more than once, so the handler deduplicates on it. Answer 2xx quickly; failed deliveries are retried for up to 24 hours.</li>
<li><strong>Public HTTPS on port 443, no redirects.</strong> A <code>localhost</code> dev server can't receive it, so keep the polling path for development.</li>
</ul>
<p>A finished queue job's file is at <code>GET /v1/queue/revana/{model}/requests/{request_id}/content</code>, with your key, and the same manual-redirect handling as above.</p>
<h2>Costs, for planning</h2>
<p>Quotes for a 5-second, 720p, 9:16 clip with audio from text, on November 5, 2026: Wan 3.0 $0.35, Kling 4.0 $0.68, Seedance 2.5 $1.05, and Veo 3.1 Fast $0.60 for 6 seconds (it takes 4, 6 or 8). Re-quote in code rather than hard-coding these. Prices for every model are at <a href="https://revana.si/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-6">revana.si/pricing</a>, and <code>GET https://api.revana.si/v1/models</code> lists each model's options and price without a key.</p>
<p>If you want to try the whole flow before spending anything, <code>minimax-h3-free</code> is a free model in the same catalog (5 to 15 s, sound always on). Its quote comes back $0.00. The account needs one top-up first (the smallest pack is $10), but free jobs never spend it.</p>
<h2>Checklist</h2>
<ul>
<li>Key in a server-only env var; the browser never sees it.</li>
<li>Allow-list models and options on the server.</li>
<li>Quote the exact request; cap <code>max_cost_usd</code> at the quote (or at <code>reserved_usd</code> if you'd rather allow backups).</li>
<li>One idempotency key per user intent, generated by the client, namespaced by user.</li>
<li>Store job ids with their owner, and check ownership on every read.</li>
<li>Don't wait in the request: poll at a few seconds, or use the webhook and verify it.</li>
<li>Copy finished files to your own storage if users should keep them.</li>
</ul>
<p>The SDK source and more examples: <a href="https://github.com/Aribk7/revana-js">revana-js</a>, and <a href="https://github.com/Aribk7/revana-skills/tree/main/examples/nextjs">revana-skills/examples/nextjs</a> for a minimal version of these routes. API reference: <a href="https://revana.si/docs?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-6">revana.si/docs</a>.</p>
<p><em>Quotes in this post are from Revana's live API on November 5, 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[Turn a folder of product photos into videos with an image-to-video API (Python and Node)]]></title><description><![CDATA[Upload product photos, quote every clip, confirm, then generate videos in parallel and re-run safely. Python and Node.]]></description><link>https://revana.hashnode.dev/bulk-image-to-video-product-photos-api</link><guid isPermaLink="true">https://revana.hashnode.dev/bulk-image-to-video-product-photos-api</guid><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Thu, 05 Nov 2026 11:44:51 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I work on <a href="https://revana.si?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-5">Revana</a>, a pay-as-you-go API for image and video models. The scripts use its SDK (<code>pip install revana</code> / <code>npm install revana</code>, both MIT and open source), but the structure is what any batch job against a paid generation API needs.</p>
</blockquote>
<p>A common job for an image-to-video API: you have a folder of product shots (<code>shoe-red.jpg</code>, <code>shoe-blue.jpg</code>, ... 40 of them) and you want a short clip of each for ads or a product page. The first version is always a <code>for</code> loop around one API call. It works, and then:</p>
<ul>
<li>you only find out what the batch cost when the invoice arrives,</li>
<li>the script crashes on photo 27, you re-run it, and photos 1 to 26 are generated (and billed) again,</li>
<li>one call at a time takes an hour.</li>
</ul>
<p>This post builds a script that fixes those: it uploads the photos, <strong>quotes every clip and prints the total before spending anything</strong>, asks you to confirm, generates several in parallel, and can be re-run any number of times without paying for the same clip twice.</p>
<h2>The models and what one clip costs</h2>
<p>All three models below take a start frame (<code>image_url</code>) and keep it as the first frame of the clip. Quotes are for a 5-second, 720p, 9:16 clip with audio, with a real image as the first frame, from Revana's quote endpoint on November 5, 2026:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Durations</th>
<th align="right">Quote for 5 s</th>
<th align="right">40 photos</th>
</tr>
</thead>
<tbody><tr>
<td><code>seedance-2.5</code></td>
<td>4 to 30 s</td>
<td align="right">$1.25</td>
<td align="right">$50.00</td>
</tr>
<tr>
<td><code>kling-4.0</code></td>
<td>3 to 30 s</td>
<td align="right">$0.68</td>
<td align="right">$27.20</td>
</tr>
<tr>
<td><code>wan-3.0</code></td>
<td>2 to 30 s</td>
<td align="right">$0.35</td>
<td align="right">$14.00</td>
</tr>
</tbody></table>
<p>Look at Seedance 2.5. Its listed 720p rate is $0.21 a second, but with a first frame the quote is $0.25 a second. If you multiply the rate card by the number of photos, you'll be 16% low on that model. That's the main reason the script quotes the exact request instead of computing prices itself.</p>
<p>Which model is "best" for your products is a judgment call I'm not going to make for you; run three or four photos through each and compare. The script makes that cheap to do.</p>
<h2>Setup</h2>
<pre><code class="language-bash">pip install revana httpx
export REVANA_API_KEY=sr_live_...   # from revana.si/dashboard
</code></pre>
<p>The SDK covers uploads, creating, waiting and downloading. It has no quote helper yet, so the script calls <code>POST /v1/generations/quote</code> with <code>httpx</code> directly. Quoting is free: nothing is created, held or charged.</p>
<h2>The script</h2>
<pre><code class="language-python"># photos_to_videos.py: python photos_to_videos.py ./photos --model kling-4.0
import argparse, hashlib, json, os, sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import httpx
from revana import Revana, GenerationFailedError, RevanaError, WaitTimeoutError

API = "https://api.revana.si"
PROMPT = "slow camera orbit around the product, soft studio light, clean background"

p = argparse.ArgumentParser()
p.add_argument("folder", type=Path)
p.add_argument("--model", default="wan-3.0")
p.add_argument("--duration", type=int, default=5)
p.add_argument("--resolution", default="720p")
p.add_argument("--aspect", default="9:16")
p.add_argument("--budget", type=float, default=20.0, help="stop if the batch quotes above this")
p.add_argument("--workers", type=int, default=4)
p.add_argument("--yes", action="store_true", help="don't ask before spending")
args = p.parse_args()

out = args.folder / "videos"
out.mkdir(exist_ok=True)
state_file = out / "state.json"   # upload links and job ids survive a crash
state = json.loads(state_file.read_text()) if state_file.exists() else {}

def save():
    state_file.write_text(json.dumps(state, indent=2))

revana = Revana()
http = httpx.Client(base_url=API, timeout=60,
                    headers={"Authorization": f"Bearer {os.environ['REVANA_API_KEY']}"})

photos = sorted(f for f in args.folder.iterdir() if f.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"})

def request_for(photo: Path) -&gt; dict:
    s = state.setdefault(photo.name, {})
    if "url" not in s:  # upload once; the link works for 14 days
        s["url"] = revana.uploads.upload(str(photo))["url"]
        save()
    return {
        "model": args.model,
        "input": {"prompt": PROMPT, "image_url": s["url"], "duration": args.duration,
                  "resolution": args.resolution, "aspect_ratio": args.aspect, "audio": True},
    }

# 1. Upload and quote everything first.
jobs = []
for photo in photos:
    if (out / f"{photo.stem}.mp4").exists():
        continue  # done on an earlier run
    body = request_for(photo)
    q = http.post("/v1/generations/quote", json=body)
    if q.status_code != 200:
        sys.exit(f"{photo.name}: {q.json()['error']['message']}")
    jobs.append((photo, body, q.json()["quoted_usd"]))

total = round(sum(usd for _, _, usd in jobs), 2)
print(f"{len(jobs)} clips on {args.model}: ${total:.2f}")
if total &gt; args.budget:
    sys.exit(f"over the ${args.budget:.2f} budget; raise --budget or pick a cheaper model")
if jobs and not args.yes and input("Generate? [y/N] ").strip().lower() != "y":
    sys.exit("nothing spent")

# 2. Generate in parallel, each with a key that is stable across re-runs.
def run(job):
    photo, body, usd = job
    digest = hashlib.sha256(photo.read_bytes()).hexdigest()[:16]
    key = f"photos/{photo.name}/{digest}/{args.model}/{args.duration}s-{args.resolution}-{args.aspect}"
    try:
        video = revana.generate(**body, max_cost_usd=usd, idempotency_key=key, timeout=1800)
    except GenerationFailedError as e:
        return f"{photo.name}: failed ({e.code}, retryable={e.retryable}), charged $0"
    except WaitTimeoutError:
        return f"{photo.name}: still running after 30 min; re-run later to pick it up (same key, no new charge)"
    except RevanaError as e:
        return f"{photo.name}: refused ({e.status} {e.code}): {e.message}"
    (out / f"{photo.stem}.mp4").write_bytes(revana.generations.download(video["id"]))
    state[photo.name].update(id=video["id"], cost_usd=video["cost_usd"])
    return f"{photo.name}: ${video['cost_usd']:.2f}"

with ThreadPoolExecutor(max_workers=args.workers) as pool:
    for line in pool.map(run, jobs):
        print(line)
save()
spent = sum(s.get("cost_usd") or 0 for s in state.values())
print(f"total charged for this folder so far: ${spent:.2f}")
</code></pre>
<p>Run it on a few photos first. With 12 photos on Wan 3.0 at $0.35 a clip, the first lines look like this:</p>
<pre><code>$ python photos_to_videos.py ./photos --model wan-3.0 --budget 5
12 clips on wan-3.0: $4.20
Generate? [y/N]
</code></pre>
<h2>Why each piece is there</h2>
<p><strong>Quote, then confirm.</strong> The quote takes the exact body you'll send, image included, so the total is what the batch will normally cost. Each clip is then sent with <code>max_cost_usd</code> equal to its own quote. If the first provider fails and only a dearer backup is left, that clip fails (and costs $0) instead of quietly costing more. If you'd rather have the clip, pass the quote's <code>reserved_usd</code> (the most a backup could charge) or a looser cap. You're only ever charged for the route that delivers.</p>
<p><strong>Upload once, keep the link.</strong> References must be public <code>https://</code> URLs, so a local file goes through <code>POST /v1/uploads</code> first. <code>revana.uploads.upload()</code> does both steps: it gets a presigned link, PUTs the bytes, and returns a <code>url</code> that works for 14 days. Uploads are free (images up to 30 MB). The script saves that URL in <code>state.json</code>, and that matters for the next point.</p>
<p><strong>An idempotency key per clip, not per run.</strong> The key is built from the photo's name, a hash of its bytes, the model and the settings. Re-run the script after a crash and every clip that was already submitted sends the same key with the same body, so the API returns the original generation instead of starting a new paid one. That only works if the body is byte-for-byte the same, which is why the upload URL is cached: re-uploading would produce a new URL, a different body, and a <code>409 idempotency_conflict</code> instead of a replay. Replace a photo with a new version and its hash changes, so it gets a new key and a new clip, which is what you want.</p>
<p><strong>Skip what's done.</strong> Finished clips are on disk as <code>videos/&lt;name&gt;.mp4</code>, and the loop skips them before quoting, so a re-run after a crash only quotes and generates what's missing.</p>
<p><strong>Parallel, but not unlimited.</strong> Four workers is a reasonable start. If the account has too many generations running, the create call answers <code>429 concurrency_limit</code>. The SDK retries 429 and 5xx responses with backoff (honoring <code>Retry-After</code>), and because every create carries an idempotency key, those retries can't double-submit.</p>
<p><strong>Failures are free and labelled.</strong> A failed generation has <code>cost_usd: 0</code> and an <code>error.code</code>. <code>retryable</code> tells you whether the same request might work later (<code>model_busy</code>, <code>timeout</code>) or needs to change (<code>content_policy</code>, <code>invalid_input</code>). The script prints it and moves on, and a re-run picks it up again.</p>
<h2>The same thing in Node</h2>
<p>The Node SDK has the same calls (<code>uploads.upload</code>, <code>generate</code>, <code>generations.download</code>). The core of the loop:</p>
<pre><code class="language-ts">// photos-to-videos.mjs: node photos-to-videos.mjs ./photos
import Revana, { GenerationFailedError } from "revana";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, parse } from "node:path";

const dir = process.argv[2];
const model = "wan-3.0";
const revana = new Revana(); // reads REVANA_API_KEY
const auth = { authorization: `Bearer ${process.env.REVANA_API_KEY}`, "content-type": "application/json" };

const jobs = [];
for (const name of (await readdir(dir)).filter((f) =&gt; /\.(jpe?g|png|webp)$/i.test(f))) {
  const bytes = await readFile(join(dir, name));
  const type = /\.png$/i.test(name) ? "image/png" : /\.webp$/i.test(name) ? "image/webp" : "image/jpeg";
  const { url } = await revana.uploads.upload(bytes, type, name); // cache this, as in the Python version
  const body = {
    model,
    input: { prompt: "slow camera orbit around the product, soft studio light", image_url: url,
             duration: 5, resolution: "720p", aspect_ratio: "9:16", audio: true },
  };
  const q = await fetch("https://api.revana.si/v1/generations/quote", { method: "POST", headers: auth, body: JSON.stringify(body) }).then((r) =&gt; r.json());
  if (!q.quoted_usd) throw new Error(`${name}: ${q.error?.message}`);
  const key = `photos/${name}/${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}/${model}/5s-720p-9:16`;
  jobs.push({ name, body: { ...body, max_cost_usd: q.quoted_usd }, key, usd: q.quoted_usd });
}

console.log(`${jobs.length} clips: $${jobs.reduce((s, j) =&gt; s + j.usd, 0).toFixed(2)}`);
// ...confirm here before spending...

const results = await Promise.allSettled(jobs.map(async (j) =&gt; {
  const video = await revana.generate(j.body, { idempotencyKey: j.key });
  await writeFile(join(dir, `${parse(j.name).name}.mp4`), Buffer.from(await revana.generations.download(video.id)));
  return `${j.name}: $${video.cost_usd}`;
}));
for (const r of results) console.log(r.status === "fulfilled" ? r.value : r.reason instanceof GenerationFailedError ? `failed, $0: ${r.reason.message}` : r.reason);
</code></pre>
<p><code>Promise.allSettled</code> over every job starts them all at once. For more than a handful of photos, add a small concurrency limit (a pool of four, like the Python version) so you don't spend the run waiting on <code>429 concurrency_limit</code> retries.</p>
<h2>Variations</h2>
<ul>
<li><strong>First and last frame.</strong> Seedance 2.5, Kling 4.0, Wan 3.0 and Veo 3.1 also take <code>end_image_url</code> (it needs <code>image_url</code>). Two shots of the same product from different angles give you a controlled transition.</li>
<li><strong>Reference images instead of a start frame.</strong> Seedance 2.5 takes up to 30 <code>image_urls</code> and Kling 4.0 and Wan 3.0 up to 10, for "this product, in a new scene" rather than "animate this exact photo". On Seedance, <code>image_url</code> and reference media can't be combined in one request.</li>
<li><strong>A free dry run.</strong> <code>minimax-h3-free</code> takes a start frame too (5 to 15 s, sound always on) and quotes $0.00. The account needs one top-up first (the smallest pack is $10), but free jobs never spend it. Use it to test the whole pipeline before you point it at a paid model.</li>
</ul>
<p>Prices for every model are at <a href="https://revana.si/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-5">revana.si/pricing</a>, and each model's options are in <code>GET https://api.revana.si/v1/models</code> (no key needed). The SDKs: <a href="https://github.com/Aribk7/revana-python">revana-python</a> and <a href="https://github.com/Aribk7/revana-js">revana-js</a>. API reference: <a href="https://revana.si/docs?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-5">revana.si/docs</a>.</p>
<p><em>Quotes in this post are from Revana's live API on November 5, 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[Generate AI videos in n8n with plain HTTP Request nodes: quote, create, wait, download]]></title><description><![CDATA[An n8n workflow that quotes an AI video job first, caps the cost, retries without double charges, and waits by polling or webhook.]]></description><link>https://revana.hashnode.dev/n8n-ai-video-generation-http-request</link><guid isPermaLink="true">https://revana.hashnode.dev/n8n-ai-video-generation-http-request</guid><category><![CDATA[n8n]]></category><category><![CDATA[automation]]></category><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Thu, 05 Nov 2026 11:42:10 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I work on <a href="https://revana.si?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-4">Revana</a>, a pay-as-you-go API for image and video models. The workflow below calls its REST API, but the shape (price first, cap the spend, one idempotency key per job, wait without hammering the API) is what you want around any paid generation API in n8n.</p>
</blockquote>
<p>Most "AI video in n8n" tutorials stop at one HTTP Request node that fires a prompt at a model. That works once in a demo. In a real workflow it breaks in three ways:</p>
<ol>
<li><strong>You find out the price afterwards.</strong> Someone changes the resolution from 720p to 1080p in a Set node and the next 200 runs cost 2.5x.</li>
<li><strong>n8n's "Retry On Fail" can double-charge you.</strong> If the create request times out after the provider accepted it, a retry starts a second paid video.</li>
<li><strong>Video takes minutes.</strong> A single request can't wait that long, so you need a wait loop or a callback.</li>
</ol>
<p>This post builds a workflow out of stock n8n nodes (HTTP Request, IF, Wait, Stop and Error) that handles all three. No community node is required, though there is one at the end if you prefer it.</p>
<h2>What you're building</h2>
<pre><code>Manual Trigger
  -&gt; Settings (Set)
  -&gt; Quote (HTTP Request, free)
  -&gt; Within budget? (IF) --no--&gt; Stop and Error
  -&gt; Create generation (HTTP Request, Idempotency-Key)
  -&gt; Wait 20 s
  -&gt; Get generation (HTTP Request)
  -&gt; Succeeded? (IF) --yes--&gt; Download video (HTTP Request, file)
                     --no---&gt; Failed? (IF) --yes--&gt; Stop and Error
                                           --no---&gt; back to Wait
</code></pre>
<p>Every Revana model goes through the same two endpoints, so the workflow is the same for Seedance 2.5, Kling 4.0, Wan 3.0, Veo 3.1 Fast and the rest. Only the <code>model</code> value and its options change.</p>
<h2>1. The credential</h2>
<p>Create an API key at <a href="https://revana.si/dashboard?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-4">revana.si/dashboard</a>. In n8n, go to <strong>Credentials &gt; Add credential &gt; Header Auth</strong>:</p>
<ul>
<li>Name: <code>Authorization</code></li>
<li>Value: <code>Bearer sr_live_...</code></li>
</ul>
<p>Call it <code>Revana API</code>. On each HTTP Request node below, set <strong>Authentication</strong> to <em>Generic Credential Type</em>, <strong>Generic Auth Type</strong> to <em>Header Auth</em>, and pick that credential.</p>
<h2>2. Settings</h2>
<p>A <strong>Set</strong> node holds everything you'd want to change per run:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><code>model</code></td>
<td><code>seedance-2.5</code></td>
</tr>
<tr>
<td><code>prompt</code></td>
<td><code>a paper boat drifting down a rainy street at night, neon reflections, rain sounds</code></td>
</tr>
<tr>
<td><code>resolution</code></td>
<td><code>720p</code></td>
</tr>
<tr>
<td><code>duration</code></td>
<td><code>5</code></td>
</tr>
<tr>
<td><code>aspect_ratio</code></td>
<td><code>9:16</code></td>
</tr>
<tr>
<td><code>audio</code></td>
<td><code>true</code></td>
</tr>
<tr>
<td><code>budget_usd</code></td>
<td><code>2</code></td>
</tr>
</tbody></table>
<p>Valid values differ by model. <code>GET https://api.revana.si/v1/models</code> (no key needed) lists each model's resolutions, durations and aspect ratios, for example Seedance 2.5 takes 4 to 30 seconds and Kling 4.0 takes 3 to 30. A value a model doesn't offer is refused with <code>422 invalid_input</code> before anything is charged, so a typo costs nothing.</p>
<h2>3. Quote before you spend</h2>
<p><code>POST /v1/generations/quote</code> takes exactly the body you're about to send and answers what it will cost. It's free: nothing is created, held or charged.</p>
<p><strong>Quote</strong> node (HTTP Request):</p>
<ul>
<li>Method: <code>POST</code></li>
<li>URL: <code>https://api.revana.si/v1/generations/quote</code></li>
<li>Send Body: on, Body Content Type: JSON, Specify Body: <em>Using JSON</em></li>
<li>JSON:</li>
</ul>
<pre><code>={{ JSON.stringify({
  model: $json.model,
  input: {
    prompt: $json.prompt,
    resolution: $json.resolution,
    duration: $json.duration,
    aspect_ratio: $json.aspect_ratio,
    audio: $json.audio
  }
}) }}
</code></pre>
<p>The answer has two numbers worth knowing:</p>
<ul>
<li><code>quoted_usd</code>: the price on the first route tried. This is what a successful job normally costs.</li>
<li><code>reserved_usd</code>: the most it could cost if the first provider fails and a dearer backup delivers instead. That amount is held from your balance while the job runs.</li>
</ul>
<p>With the settings above (Seedance 2.5, 720p, 5 s, 9:16, audio), the quote on November 5, 2026 was <strong>$1.05</strong> ($0.21 per second), with up to $4.05 reserved if every backup were allowed. The same settings on Kling 4.0 quoted $0.68, on Wan 3.0 $0.35, and Veo 3.1 Fast at 6 seconds (it takes 4, 6 or 8) $0.60. Re-quote rather than copying these: prices change, and some inputs cost more than the model's headline rate. Seedance 2.5 with a first frame quoted $0.25 a second instead of $0.21.</p>
<p><strong>Within budget?</strong> (IF node), Number condition:</p>
<pre><code>{{ $json.quoted_usd }}   is less than or equal to   {{ $('Settings').item.json.budget_usd }}
</code></pre>
<p>The false branch goes to a <strong>Stop and Error</strong> node with a message like <code>Costs ${{ $json.quoted_usd }}, over the budget</code>.</p>
<h2>4. Create the job, safely</h2>
<p><strong>Create generation</strong> (HTTP Request):</p>
<ul>
<li>Method: <code>POST</code>, URL: <code>https://api.revana.si/v1/generations</code></li>
<li>Send Headers: on, add <code>Idempotency-Key</code> = <code>n8n-{{ $execution.id }}</code></li>
<li>Body: the same JSON as the quote, plus a cap:</li>
</ul>
<pre><code>={{ JSON.stringify({
  model: $('Settings').item.json.model,
  input: {
    prompt: $('Settings').item.json.prompt,
    resolution: $('Settings').item.json.resolution,
    duration: $('Settings').item.json.duration,
    aspect_ratio: $('Settings').item.json.aspect_ratio,
    audio: $('Settings').item.json.audio
  },
  max_cost_usd: $json.quoted_usd
}) }}
</code></pre>
<p>Two things make this node safe to retry:</p>
<ul>
<li><strong>The idempotency key is tied to the execution, not the HTTP call.</strong> Turn on <em>Retry On Fail</em> in the node's Settings tab. Each retry sends the same key with the same body, so if the first attempt reached the API, the retry gets the original job back (<code>200</code>) instead of starting a second one. Sending the same key with a <em>different</em> body returns <code>409 idempotency_conflict</code>, which is what you want if someone edits the node between attempts. If you loop over many items in one execution, make the key per item, for example <code>n8n-{{ $execution.id }}-{{ $itemIndex }}</code>.</li>
<li><strong><code>max_cost_usd</code> is a hard ceiling.</strong> Setting it to <code>quoted_usd</code> means a dearer backup provider is skipped and the job fails instead of costing more. If you'd rather get the video, set it to <code>reserved_usd</code> (or your budget) and let backups run; you're charged only for the route that delivers.</li>
</ul>
<p>The response is <code>202</code> with a generation object: <code>id</code>, <code>status</code> (<code>queued</code>), <code>quoted_usd</code>, <code>reserved_usd</code>, and <code>cost_usd</code> (<code>null</code> until it finishes).</p>
<h2>5. Wait without hammering the API</h2>
<p>A 5-second 720p clip usually takes one to a few minutes, sometimes longer. Polling every second wastes executions and can hit the per-second burst guard (<code>429 rate_limited</code>). A loop with a Wait node is enough:</p>
<ul>
<li><strong>Wait</strong>: After Time Interval, 20 seconds.</li>
<li><strong>Get generation</strong> (HTTP Request): <code>GET https://api.revana.si/v1/generations/{{ $('Create generation').item.json.id }}</code></li>
<li><strong>Succeeded?</strong> (IF): <code>{{ $json.status }}</code> equals <code>succeeded</code> → download.</li>
<li><strong>Failed?</strong> (IF): <code>{{ $json.status }}</code> equals <code>failed</code> → Stop and Error with <code>{{ $json.error.code }}: {{ $json.error.message }}</code>. Otherwise connect the false branch back to <strong>Wait</strong>.</li>
</ul>
<p>Two details:</p>
<ul>
<li>A failed generation is charged <code>$0</code> (<code>cost_usd: 0</code>). <code>error.retryable</code> tells you whether sending the same request later can work (<code>model_busy</code>, <code>timeout</code>, <code>all_routes_failed</code>) or whether the request itself has to change (<code>content_policy</code>, <code>invalid_input</code>, <code>max_cost_exceeded</code>).</li>
<li>Put a ceiling on the loop so a stuck job can't run forever. In the <strong>Failed?</strong> node, add an OR condition <code>{{ $runIndex }}</code> is greater than <code>60</code> (about 20 minutes at 20 s per lap). <code>$runIndex</code> counts how many times that node has run in this execution.</li>
</ul>
<h2>6. Download the file</h2>
<p><strong>Download video</strong> (HTTP Request):</p>
<ul>
<li><code>GET https://api.revana.si/v1/generations/{{ $json.id }}/content</code></li>
<li>Options &gt; Response &gt; Response Format: <strong>File</strong></li>
</ul>
<p>The API redirects to a short-lived file link, and n8n follows it. The video arrives as binary data, ready for a Google Drive, S3, Slack or Write Binary File node. Result files expire, so store them; don't keep the link.</p>
<h2>Alternative: a webhook instead of the polling loop</h2>
<p>If your n8n is reachable on public HTTPS (n8n Cloud, or self-hosted behind a domain), you can drop the loop. Revana's queue endpoint accepts a completion webhook, and n8n's Wait node can pause until a URL is called.</p>
<ol>
<li>Change <strong>Wait</strong> to <em>Resume: On Webhook Call</em>, HTTP Method <code>POST</code>, and set <em>Limit Wait Time</em> (for example 1 hour).</li>
<li>Change <strong>Create generation</strong> to the queue endpoint. It takes the model input directly, without the <code>{model, input}</code> wrapper, with <code>max_cost_usd</code> alongside, and the callback in the <code>fal_webhook</code> query parameter:</li>
</ol>
<pre><code>POST https://api.revana.si/v1/queue/revana/{{ $('Settings').item.json.model }}?fal_webhook={{ encodeURIComponent($execution.resumeUrl) }}
Idempotency-Key: n8n-{{ $execution.id }}

{ "prompt": "...", "resolution": "720p", "duration": 5, "aspect_ratio": "9:16", "audio": true, "max_cost_usd": 1.05 }
</code></pre>
<ol start="3">
<li>After the Wait node, don't trust the callback body. Revana's callbacks are unsigned, so fetch the result yourself with your key: <code>GET https://api.revana.si/v1/queue/revana/{model}/requests/{{ $('Create generation').item.json.request_id }}/status</code>, and when it's <code>COMPLETED</code> without an <code>error</code>, download <code>.../requests/{request_id}/content</code> with Response Format: File.</li>
</ol>
<p>The callback URL must be public HTTPS on port 443 with no redirects, so this won't work against <code>localhost:5678</code>. For local development, keep the polling loop.</p>
<h2>Testing for $0</h2>
<p><code>minimax-h3-free</code> is a free model in the same catalog (5 to 15 seconds, 480p/720p/768p, sound always on). Set <code>model</code> to it in <strong>Settings</strong>, and the quote comes back <code>$0.00</code>. The account needs one top-up first (the smallest pack is $10, an anti-abuse rule), but free jobs never spend it. It's a good way to run the whole workflow end to end before you point it at a paid model.</p>
<h2>If you'd rather not build it</h2>
<ul>
<li><strong>Importable workflows.</strong> <a href="https://github.com/Aribk7/revana-skills/tree/main/n8n">revana-skills/n8n</a> has two ready-made JSON workflows (a text/image-to-video generator and a product-photo-to-UGC-ad flow) built the same way. Import with <em>Workflows &gt; Import from File</em>, or <code>n8n import:workflow --input=seedance-2.5-video-generator.json</code>.</li>
<li><strong>A community node.</strong> <code>n8n-nodes-revana</code> (Settings &gt; Community Nodes &gt; Install, if your instance allows community nodes) wraps this into <em>Generate Media</em>, <em>Get Generation</em>, <em>List Models</em> and <em>Chat</em> operations, with a max-cost field and an optional wait for the result.</li>
</ul>
<h2>The same three calls work anywhere</h2>
<p>Nothing here is n8n-specific. Make's HTTP module, Zapier's Webhooks by Zapier, Pipedream or a cron job can run the same sequence: <code>POST /v1/generations/quote</code>, <code>POST /v1/generations</code> with an <code>Idempotency-Key</code> and <code>max_cost_usd</code>, then <code>GET /v1/generations/{id}</code> on an interval and <code>GET /v1/generations/{id}/content</code> at the end. The full API is in <a href="https://revana.si/docs?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-4">the docs</a> and <a href="https://revana.si/openapi.json?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-4">openapi.json</a>, and every model and price is at <a href="https://revana.si/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-4">revana.si/pricing</a>.</p>
<p><em>Prices and quotes in this post are from Revana's live API on November 5, 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[Building a video-gen pipeline that doesn't fall over: fallback across providers]]></title><description><![CDATA[How to build an AI video pipeline that survives capacity errors, timeouts and refusals: routing, fallback across models, idempotency keys, cost caps.]]></description><link>https://revana.hashnode.dev/video-generation-pipeline-fallback</link><guid isPermaLink="true">https://revana.hashnode.dev/video-generation-pipeline-fallback</guid><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><category><![CDATA[api]]></category><category><![CDATA[Python]]></category><category><![CDATA[Reliability]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Thu, 05 Nov 2026 11:09:45 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I work on <a href="https://revana.si/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-3">Revana</a>, a router for image and video models. The code below targets its API (spec: <a href="https://revana.si/openapi.json">https://revana.si/openapi.json</a>), but the failure modes and the fixes apply to any generation provider.</p>
</blockquote>
<p>A demo calls a video model once and shows the result. A production pipeline calls it thousands of times, and then you see what the demo hid. Generation endpoints fail far more often than ordinary APIs, in more ways, and every failure mode has a cost:</p>
<ul>
<li><strong>Capacity:</strong> the provider is at capacity (<code>429</code>/<code>503</code>) for minutes at a time, usually right when you launch a campaign.</li>
<li><strong>Slow jobs:</strong> a 10-second 1080p clip can take anywhere from 40 seconds to 15 minutes, so your HTTP client times out long before the job does.</li>
<li><strong>Refusals:</strong> the model refuses the prompt (content policy) or returns text instead of a video.</li>
<li><strong>Network:</strong> a connection drops after you've submitted, and you don't know whether the job exists.</li>
<li><strong>Billing:</strong> some providers charge for failed or timed-out jobs.</li>
</ul>
<p>Any one of these is easy to handle. The trouble is that a naive fix for one usually makes another worse. Retrying blindly turns a dropped connection into two paid videos, and falling back to another model turns a $1 clip into a $4 one. This post builds a small pipeline that handles all five, in about 120 lines of Python.</p>
<h2>The request shape</h2>
<p>Every model goes through the same endpoint:</p>
<pre><code class="language-http">POST https://api.revana.si/v1/generations
Authorization: Bearer sr_live_...
Idempotency-Key: campaign-42/shot-07/seedance-2.5/a0
Content-Type: application/json

{
  "model": "seedance-2.5",
  "input": {
    "prompt": "close-up of hands pouring cold brew over ice, morning light",
    "resolution": "720p", "duration": 8, "aspect_ratio": "9:16", "audio": true,
    "image_url": "https://example.com/start-frame.png"
  },
  "max_cost_usd": 2.00
}
</code></pre>
<p>The response is <code>202</code> with a generation object (<code>id</code>, <code>status</code>, <code>quoted_usd</code>, <code>reserved_usd</code>, <code>cost_usd</code>, <code>error</code>). You poll <code>GET /v1/generations/{id}</code> until <code>status</code> is <code>succeeded</code> or <code>failed</code>, then download <code>GET /v1/generations/{id}/content</code>.</p>
<p>Two fields do most of the safety work:</p>
<ul>
<li><strong><code>max_cost_usd</code></strong> is a hard ceiling per generation. If the only available route costs more, the request fails with <code>max_cost_exceeded</code> and nothing is charged.</li>
<li><strong><code>Idempotency-Key</code></strong>: sending the same key with the same body returns the original generation (<code>200</code>) instead of starting a new one. The same key with a <em>different</em> body is <code>409 idempotency_conflict</code>.</li>
</ul>
<h2>Layer 1: routing inside one model</h2>
<p>The same model is often sold by several upstream providers. Seedance 2.5, for example, is available from ByteDance directly and from a handful of resellers. A router can try the cheapest healthy route first and move to the next one if it's down, without the caller ever seeing the failure.</p>
<p>Revana does this server-side, and the generation object tells you what it may cost you:</p>
<ul>
<li><code>quoted_usd</code> is the price on the first route tried.</li>
<li><code>reserved_usd</code> is the most it can cost: the dearest backup route allowed under your <code>max_cost_usd</code>. That amount is held from your balance while the job runs.</li>
<li><code>cost_usd</code> is what you were actually charged: the price of the route that delivered, <code>0</code> if it failed, <code>null</code> while it's running.</li>
</ul>
<p>If you want "this model, this price, or nothing", set <code>max_cost_usd</code> equal to the quote and dearer backups are skipped. If you'd rather get the clip, leave headroom. To check the quote before submitting, read the live price list (no key needed):</p>
<pre><code class="language-python">import requests

MODELS = {m["id"]: m for m in requests.get("https://api.revana.si/v1/models", timeout=30).json()["data"]}

def quote(model: str, inp: dict) -&gt; float:
    """Price of one generation from the live list: per_second x duration, or per_image."""
    price = MODELS[model]["price"]
    usd = price["usd"]
    if isinstance(usd, dict):
        res = inp.get("resolution")
        key = f"{res}+audio" if inp.get("audio") and f"{res}+audio" in usd else res
        usd = usd[key]
    return round(usd * inp.get("duration", 1), 4) if price["unit"] == "per_second" else usd
</code></pre>
<p>As of 2026-11-05, <code>quote("seedance-2.5", {"resolution": "720p", "duration": 8})</code> returns <code>1.68</code> ($0.21/s x 8 s).</p>
<p>There's also <code>max_wait_s</code>. Routes expected to take longer than that are skipped, and once that time has passed, no new route is tried. That's useful for interactive features where a clip that arrives in 20 minutes is worthless.</p>
<h2>Layer 2: fallback across models</h2>
<p>Route-level fallback keeps <em>one model</em> up. Sometimes the model itself is the problem: it refuses a prompt the others accept, or every route is saturated. At that point you want a ranked list of acceptable models, each with its own price cap:</p>
<pre><code class="language-python">FALLBACK = [
    # (model, input overrides, max_cost_usd)
    ("seedance-2.5", {}, 1.68),
    ("kling-3.0",    {"resolution": "1080p"}, 1.36),
    ("wan-3.0",      {"resolution": "720p"}, 0.56),
]
</code></pre>
<p>Two rules make this safe:</p>
<ol>
<li><strong>Only fall back on errors that another model could fix.</strong> <code>model_busy</code>, <code>model_unavailable</code>, <code>timeout</code>, <code>all_routes_failed</code> and <code>model_declined</code> are good reasons to try the next model. <code>invalid_input</code> (a bad reference URL) and <code>max_cost_exceeded</code> will fail everywhere, so stop and fix the request. Treat <code>content_policy</code> as a product decision: sometimes another model accepts the prompt, but often you <em>want</em> the refusal to stand.</li>
<li><strong>Each fallback is a new intent, so it gets a new idempotency key.</strong> The body changes (different model), so reusing the key would be a <code>409</code> anyway.</li>
</ol>
<h2>Layer 3: idempotency keys that mean something</h2>
<p>The most expensive bug in generation pipelines is the double submit. A worker submits a job, the response is lost (a timeout, a deploy, an OOM kill), the queue redelivers the message, and the worker submits again. Now you've paid twice.</p>
<p>The fix is to derive the key from <strong>what you're trying to make</strong>, not from the HTTP call:</p>
<pre><code>{job_id}/{shot_id}/{model}/a{attempt}
</code></pre>
<ul>
<li>After a crash, a redelivered message rebuilds the same key, so the resubmit returns the existing generation and you resume polling it.</li>
<li><code>attempt</code> increases <strong>only after a generation has reached <code>failed</code></strong>. Failed generations are never charged, so a fresh attempt can't double-bill. Never mint a new key while the previous attempt's status is unknown. Resubmit with the old key and let the server tell you.</li>
<li>Persist the generation <code>id</code> next to the key as soon as you get it, so a restart can skip straight to polling.</li>
</ul>
<p>The HTTP layer retries with the <strong>same</strong> key: <code>429</code> (honour <code>Retry-After</code>), <code>503 busy</code>/<code>model_unavailable</code>, <code>500</code>, and connection errors. All of those are safe to replay because of the key.</p>
<h2>Putting it together</h2>
<pre><code class="language-python">import os, time, requests

API = "https://api.revana.si/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['REVANA_API_KEY']}"

RETRY_HTTP = {429, 500, 502, 503, 504}
NEXT_MODEL = {"model_busy", "model_unavailable", "timeout", "all_routes_failed", "model_declined"}

def submit(model, inp, max_cost, key, tries=6):
    body = {"model": model, "input": inp, "max_cost_usd": max_cost}
    for i in range(tries):
        try:
            r = S.post(f"{API}/generations", json=body, headers={"Idempotency-Key": key}, timeout=60)
        except requests.ConnectionError:
            time.sleep(2 ** i); continue                      # same key: safe to replay
        if r.status_code in (200, 202):
            return r.json()
        if r.status_code in RETRY_HTTP:
            time.sleep(float(r.headers.get("Retry-After", 2 ** i))); continue
        err = r.json()["error"]                               # 4xx: retrying won't help
        raise RuntimeError(f"{r.status_code} {err['code']}: {err['message']}")
    raise RuntimeError("gave up submitting")

def wait(gen_id, deadline_s=1800):
    t0, delay = time.time(), 5
    while time.time() - t0 &lt; deadline_s:
        r = S.get(f"{API}/generations/{gen_id}", timeout=30)
        if r.status_code == 200:
            g = r.json()
            if g["status"] in ("succeeded", "failed"):
                return g
        time.sleep(delay); delay = min(delay * 1.5, 30)       # back off on long jobs
    raise TimeoutError(gen_id)

def download(gen_id, path):
    with S.get(f"{API}/generations/{gen_id}/content", stream=True, timeout=300) as r:   # follows the 302
        r.raise_for_status()
        with open(path, "wb") as f:
            for chunk in r.iter_content(1 &lt;&lt; 20):
                f.write(chunk)

def make_shot(job_id, shot_id, base_input, fallback, out_path, max_attempts=2):
    for model, overrides, max_cost in fallback:
        inp = {**base_input, **overrides}
        for attempt in range(max_attempts):
            key = f"{job_id}/{shot_id}/{model}/a{attempt}"
            g = wait(submit(model, inp, max_cost, key)["id"])
            if g["status"] == "succeeded":
                download(g["id"], out_path)
                return {"model": model, "id": g["id"], "cost_usd": g["cost_usd"]}
            err = g["error"]
            print(f"{key}: {err['code']} (retryable={err['retryable']}) {err['message']}")
            if err["code"] not in NEXT_MODEL:
                raise RuntimeError(err["message"])            # fix the request, don't burn fallbacks
            if not err["retryable"]:
                break                                         # this model won't do it: next model
            # retryable on this model: one more attempt with a new key (the failed one cost $0)
    raise RuntimeError(f"{shot_id}: every model in the fallback list failed")

if __name__ == "__main__":
    shot = {"prompt": "close-up of hands pouring cold brew over ice, morning light, 9:16 vertical",
            "resolution": "720p", "duration": 8, "aspect_ratio": "9:16", "audio": True}
    print(make_shot("campaign-42", "shot-07", shot, FALLBACK, "shot-07.mp4"))
</code></pre>
<p>(<code>FALLBACK</code> is the list from Layer 2. Paste it above <code>__main__</code>.)</p>
<p>Notes on the choices:</p>
<ul>
<li><strong>Back off on polling.</strong> Generation takes minutes, and polling every second just burns rate limit. Start at 5 s and cap at 30 s.</li>
<li><strong>4xx on submit is final.</strong> <code>402 insufficient_credits</code>, <code>409 idempotency_conflict</code> and <code>422 invalid_input</code> all need a human or a code change, not a loop.</li>
<li><strong><code>cost_usd</code> goes in your ledger, not <code>quoted_usd</code>.</strong> If a backup route delivered, the charge can differ from the first quote, though never above <code>max_cost_usd</code>.</li>
<li><strong>Concurrency.</strong> <code>429 concurrency_limit</code> means too many jobs are in flight. Run the shots through a bounded worker pool (8-16 is plenty) instead of firing 100 at once.</li>
</ul>
<h2>Only paying for what succeeds</h2>
<p>The billing model shapes the retry strategy. If a provider charges for failed or timed-out generations, every retry is a gamble, and you end up writing elaborate pre-validation to avoid them. When failures cost $0, as with Revana and ByteDance's own API (which documents charging only successful videos), retrying a <code>model_busy</code> is free, and the only real cost control you need is <code>max_cost_usd</code> per attempt.</p>
<p>The worst case is easy to bound: <code>attempts x max_cost_usd</code>, but only for attempts that succeed, and <code>make_shot</code> stops at the first success. For the fallback list above, a shot costs at most $1.68 (the highest cap in the list), even if all three models are tried.</p>
<p>Before a big batch, check that the account can cover it. <code>GET /v1/balance</code> returns <code>available_usd</code> (what new jobs can reserve) and <code>held_usd</code> (what running jobs are holding):</p>
<pre><code class="language-python">bal = S.get(f"{API}/balance", timeout=30).json()
need = 100 * FALLBACK[0][2]          # 100 shots at the first model's cap
assert bal["available_usd"] &gt;= need, f"top up: {bal['available_usd']} &lt; {need}"
</code></pre>
<h2>Checklist</h2>
<ul>
<li><input disabled type="checkbox" /> Price from the live list, cap every request with <code>max_cost_usd</code>.</li>
<li><input disabled type="checkbox" /> Idempotency key = job + shot + model + attempt. Persist the generation id as soon as you have it.</li>
<li><input disabled type="checkbox" /> Replay transport errors, <code>429</code>, <code>5xx</code> with the <em>same</em> key. Honour <code>Retry-After</code>.</li>
<li><input disabled type="checkbox" /> New attempt key only after a confirmed <code>failed</code>.</li>
<li><input disabled type="checkbox" /> Fall back to the next model on capacity/timeout errors, not on <code>invalid_input</code>.</li>
<li><input disabled type="checkbox" /> Poll with backoff, download from <code>/content</code>, record <code>cost_usd</code>.</li>
<li><input disabled type="checkbox" /> Bound concurrency, and check <code>available_usd</code> before a batch.</li>
</ul>
<p>The OpenAPI spec with every error code is at <a href="https://revana.si/openapi.json">https://revana.si/openapi.json</a>, and live model prices are at <code>GET https://api.revana.si/v1/models</code>. Prices in this post are as of 2026-11-05.</p>
<hr />
<p><em>Try it: <a href="https://revana.si/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-3">revana.si</a> has one API key for 200+ video and image models, with live prices on <a href="https://revana.si/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-3">revana.si/pricing</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[What 100 AI videos actually cost across APIs (Seedance 2.5, Veo 3.1, Kling)]]></title><description><![CDATA[Cost of 100 eight-second AI videos on Revana, fal, Replicate, Kie.ai and the model makers' own APIs. Prices checked 2026-11-05.]]></description><link>https://revana.hashnode.dev/ai-video-api-cost-100-videos</link><guid isPermaLink="true">https://revana.hashnode.dev/ai-video-api-cost-100-videos</guid><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><category><![CDATA[api]]></category><category><![CDATA[pricing]]></category><category><![CDATA[Seedance]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Thu, 05 Nov 2026 11:08:21 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I work on <a href="https://revana.si/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-1">Revana</a>, one of the APIs compared here. Every number below was read off the sellers' own pages and Revana's live price list on 2026-11-05, and where someone else is cheaper the table says so. Prices in this space change monthly. Re-check them before you commit a budget.</p>
</blockquote>
<p>Most "cheapest video API" posts compare one 5-second clip. That hides the things that matter once you ship: per-second rates at the resolution you actually use, whether sound costs extra, and minimums or per-clip pricing that distort short clips.</p>
<p>So here is a more boring and more useful question: <strong>what does it cost to make 100 eight-second videos</strong> (800 billed seconds) on each API, for the models people actually ask for: Seedance 2.5, Veo 3.1, Kling 3.0, plus Wan 3.0 and one image model, Nano Banana Pro, for reference.</p>
<h2>Method</h2>
<ul>
<li><strong>Clip:</strong> 8 seconds, 16:9, with generated audio unless noted. Eight seconds because it is Veo 3.1's maximum and a common ad/social length.</li>
<li><strong>Sellers:</strong> the model maker's own API where one exists (ByteDance's BytePlus ModelArk, Google Vertex AI / Gemini API), plus fal, Replicate, Kie.ai and Revana.</li>
<li><strong>Prices:</strong> list pay-as-you-go prices, no volume deals, no top-up bonuses, no card fees. All are USD, <strong>as of 2026-11-05</strong>.</li>
<li><strong>Token-priced sellers</strong> (ByteDance, fal for Seedance) are converted with their published formula: <code>tokens = width x height x 24 x seconds / 1024</code>. At 720p 16:9 that is 21,600 tokens per second.</li>
<li><strong>Per-clip sellers</strong> (Kie.ai's Veo 3.1) are divided by the clip length (8 s).</li>
</ul>
<p>You can check Revana's numbers yourself without a key:</p>
<pre><code class="language-bash">curl -s https://api.revana.si/v1/models \
  | jq '.data[] | select(.id=="seedance-2.5" or .id=="veo-3.1" or .id=="kling-3.0") | {id, price}'
</code></pre>
<h2>The totals: 100 videos x 8 seconds</h2>
<p>Prices as of 2026-11-05. Cheapest per row in <strong>bold</strong>. A dash means the seller doesn't offer that model or setting.</p>
<table>
<thead>
<tr>
<th>Model and setting</th>
<th align="right">Revana</th>
<th align="right">Maker's API</th>
<th align="right">fal</th>
<th align="right">Replicate</th>
<th align="right">Kie.ai</th>
</tr>
</thead>
<tbody><tr>
<td>Seedance 2.5, 720p, audio</td>
<td align="right"><strong>$168.00</strong></td>
<td align="right">$184.88</td>
<td align="right">$378.40</td>
<td align="right">$184.96</td>
<td align="right">$252.00</td>
</tr>
<tr>
<td>Seedance 2.5, 1080p, audio</td>
<td align="right"><strong>$416.00</strong></td>
<td align="right">$454.88</td>
<td align="right">$931.20</td>
<td align="right">–</td>
<td align="right">$632.00</td>
</tr>
<tr>
<td>Veo 3.1, 1080p, audio</td>
<td align="right">$320.00</td>
<td align="right">$320.00</td>
<td align="right">$320.00</td>
<td align="right">$320.00</td>
<td align="right"><strong>$127.50</strong></td>
</tr>
<tr>
<td>Veo 3.1 Fast, 1080p, audio</td>
<td align="right">$88.00</td>
<td align="right">$96.00</td>
<td align="right">$120.00</td>
<td align="right">$120.00</td>
<td align="right"><strong>$32.50</strong></td>
</tr>
<tr>
<td>Kling 3.0 Pro (1080p), audio</td>
<td align="right">$136.00</td>
<td align="right">not checked</td>
<td align="right">$134.40</td>
<td align="right">$268.80</td>
<td align="right"><strong>$120.80</strong></td>
</tr>
<tr>
<td>Wan 3.0, 1080p</td>
<td align="right"><strong>$112.00</strong></td>
<td align="right">not checked</td>
<td align="right">$160.00</td>
<td align="right">–</td>
<td align="right">$128.00</td>
</tr>
<tr>
<td>Nano Banana Pro, 100 images at 1K/2K</td>
<td align="right"><strong>$4.00</strong></td>
<td align="right">$13.40</td>
<td align="right">$15.00</td>
<td align="right">$15.00</td>
<td align="right">$9.00</td>
</tr>
<tr>
<td>Nano Banana Pro, 100 images at 4K</td>
<td align="right"><strong>$8.00</strong></td>
<td align="right">$24.00</td>
<td align="right">$30.00</td>
<td align="right">$30.00</td>
<td align="right">$12.00</td>
</tr>
</tbody></table>
<p>The per-second rates behind those totals (as of 2026-11-05):</p>
<table>
<thead>
<tr>
<th>Model and setting</th>
<th align="right">Revana</th>
<th align="right">Maker's API</th>
<th align="right">fal</th>
<th align="right">Replicate</th>
<th align="right">Kie.ai</th>
</tr>
</thead>
<tbody><tr>
<td>Seedance 2.5, 720p</td>
<td align="right">$0.21/s</td>
<td align="right">$0.2311/s</td>
<td align="right">$0.473/s</td>
<td align="right">$0.2312/s</td>
<td align="right">$0.315/s</td>
</tr>
<tr>
<td>Seedance 2.5, 1080p</td>
<td align="right">$0.52/s</td>
<td align="right">$0.5686/s</td>
<td align="right">~$1.164/s</td>
<td align="right">–</td>
<td align="right">$0.79/s</td>
</tr>
<tr>
<td>Veo 3.1, 1080p + audio</td>
<td align="right">$0.40/s</td>
<td align="right">$0.40/s</td>
<td align="right">$0.40/s</td>
<td align="right">$0.40/s</td>
<td align="right">$1.275 per 8 s clip</td>
</tr>
<tr>
<td>Veo 3.1 Fast, 1080p + audio</td>
<td align="right">$0.11/s</td>
<td align="right">$0.12/s</td>
<td align="right">$0.15/s</td>
<td align="right">$0.15/s</td>
<td align="right">$0.325 per 8 s clip</td>
</tr>
<tr>
<td>Kling 3.0 Pro + audio</td>
<td align="right">$0.17/s</td>
<td align="right">–</td>
<td align="right">$0.168/s</td>
<td align="right">$0.336/s</td>
<td align="right">$0.151/s</td>
</tr>
<tr>
<td>Wan 3.0, 1080p</td>
<td align="right">$0.14/s</td>
<td align="right">–</td>
<td align="right">$0.20/s</td>
<td align="right">–</td>
<td align="right">$0.16/s</td>
</tr>
<tr>
<td>Nano Banana Pro, 1K/2K</td>
<td align="right">$0.04</td>
<td align="right">$0.134</td>
<td align="right">$0.15</td>
<td align="right">$0.15</td>
<td align="right">$0.09</td>
</tr>
</tbody></table>
<h2>What the table says, model by model</h2>
<h3>Seedance 2.5: Revana is lowest at both resolutions</h3>
<p>At <strong>720p</strong>, ByteDance itself (BytePlus ModelArk, $10.70 per million tokens) works out to $0.2311/s for a 16:9 clip, and Replicate passes that rate through almost exactly. Revana is about 9% below them at $0.21/s. Kie.ai is $0.315/s and fal is the outlier at roughly $0.47/s.</p>
<p>At <strong>1080p</strong> ByteDance charges a higher token rate ($11.70/M), which works out to about $0.569/s. Revana is $0.52/s. fal now sells 1080p too, at a higher token rate that it puts at about $1.164/s, and Replicate doesn't list 1080p for Seedance 2.5 at all. If your output is 1080p, that difference is $38.88 per 100 clips against ByteDance, $216 against Kie.ai and $515.20 against fal.</p>
<p>Two Seedance details that change the bill more than the headline rate does:</p>
<ol>
<li><strong>Aspect ratio matters on token pricing.</strong> fal and ByteDance bill by frame area, so a 21:9 clip costs more per second than a 9:16 one at "the same" resolution. Per-second sellers (Revana, Kie.ai) charge the same for any aspect ratio.</li>
<li><strong>Video inputs are billed separately.</strong> If you pass a reference clip, most sellers bill the input clip's seconds too, at a different rate. Kie.ai lists $0.085/$0.19/$0.475 per second with video input. Revana lists $0.06/$0.15/$0.34 at 480p/720p/1080p. Replicate's video-input tier is much higher ($0.97/s at 720p).</li>
</ol>
<h3>Veo 3.1: Kie.ai is much cheaper, and I can't explain how</h3>
<p>Google's Vertex AI list price for Veo 3.1 with audio is $0.40/s at 720p and 1080p. fal, Replicate and Revana all sell it at exactly that. <strong>Kie.ai sells an 8-second 1080p Veo 3.1 clip for 255 credits (about $1.275)</strong>, which is roughly $0.16/s, or 40% of Google's own price. Their Veo 3.1 Fast is $0.325 per 8-second 1080p clip against Google's $0.96.</p>
<p>If Veo is all you need and you can live with Kie.ai's per-clip packaging, it's the cheapest on this list by a wide margin today. Revana is only slightly below list on Fast ($0.11/s vs Google's $0.12/s at 1080p).</p>
<h3>Kling 3.0: Kie.ai first, then fal and Revana tied</h3>
<p>Kie.ai is cheapest ($0.151/s Pro with audio). fal ($0.168/s) and Revana ($0.17/s) are effectively tied. Replicate is double ($0.336/s for pro-audio). Note that Replicate charges $0.168/s for Kling 3.0 Standard <em>without</em> audio, so check which variant a page is quoting before comparing.</p>
<h3>Wan 3.0: within a few cents</h3>
<p>Revana $0.14/s, Kie.ai $0.16/s, fal $0.20/s at 1080p. Over 800 seconds that is $112 vs $128 vs $160. Kie.ai's page notes that reference-video seconds are billed on top of output seconds, and that applies everywhere.</p>
<h3>Nano Banana Pro: the biggest spread on the list</h3>
<p>For stills (which most video pipelines need for first frames), the spread is wide. Revana is $0.04 per 1K/2K image, Kie.ai $0.09, Google's own Gemini API $0.134, fal and Replicate $0.15. At 4K it's $0.08 / $0.12 / $0.24 / $0.30 / $0.30. If you make storyboards or start frames by the hundred, this line matters more than any video line above.</p>
<h2>Costs the rate card doesn't show</h2>
<p><strong>Failed generations.</strong> Video models refuse prompts, time out, and hit capacity. ByteDance's docs say it charges only for successfully generated videos. Revana does the same: a failed generation's <code>cost_usd</code> is 0. Before you multiply your expected volume by a rate, check each seller's policy on failures, plus your own retry rate. A 10% failure rate on a seller that bills failures is a 10% surcharge.</p>
<p><strong>Minimums and packaging.</strong> Per-clip pricing (Kie.ai's Veo) is great for 8-second clips and wasteful if you only need 4. Some sellers also set a per-generation minimum, so read the fine print before you plan a batch of 4-second clips.</p>
<p><strong>Credits vs dollars.</strong> Kie.ai sells credits at roughly $0.005 each, with a 10% bonus on larger top-ups. Revana uses 100 credits = $1. Convert everything to dollars before comparing.</p>
<p><strong>Prices move.</strong> Several of these pages say prices are "in beta" or "subject to change". Treat the table as a snapshot of 2026-11-05, not a contract.</p>
<h2>How to estimate a batch in code before you spend anything</h2>
<p>Whichever API you use, compute the cost from the live price list and set a hard cap per job. With Revana that's one unauthenticated call and some arithmetic:</p>
<pre><code class="language-python">import json, urllib.request

models = {m["id"]: m for m in json.load(urllib.request.urlopen("https://api.revana.si/v1/models"))["data"]}

def clip_cost(model_id, resolution, seconds, audio=False):
    price = models[model_id]["price"]["usd"]
    key = f"{resolution}+audio" if audio and f"{resolution}+audio" in price else resolution
    return round(price[key] * seconds, 4)

for model_id, res, audio in [("seedance-2.5", "1080p", True), ("veo-3.1", "1080p", True), ("kling-3.0", "1080p", True)]:
    one = clip_cost(model_id, res, 8, audio)
    print(f"{model_id:14} {res} 8s: ${one:.2f} per clip, ${one * 100:,.2f} per 100")
</code></pre>
<p>Then pass that per-clip figure as <code>max_cost_usd</code> on each request so a single job can never cost more than you planned:</p>
<pre><code class="language-bash">curl -s https://api.revana.si/v1/generations \
  -H "Authorization: Bearer $REVANA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-11-05-clip-001" \
  -d '{
        "model": "seedance-2.5",
        "input": {"prompt": "a paper boat drifting down a rain-soaked street, low angle",
                  "resolution": "1080p", "duration": 8, "aspect_ratio": "16:9", "audio": true},
        "max_cost_usd": 4.16
      }'
</code></pre>
<h2>Takeaways</h2>
<ul>
<li><strong>Seedance 2.5:</strong> Revana is the lowest list price I found at both 720p ($0.21/s) and 1080p ($0.52/s); ByteDance and Replicate are next at 720p.</li>
<li><strong>Veo 3.1 and Veo 3.1 Fast:</strong> Kie.ai is far below everyone, Google included.</li>
<li><strong>Kling 3.0:</strong> Kie.ai is a little cheaper, with fal and Revana close behind. Replicate's Kling is the most expensive.</li>
<li><strong>Wan 3.0:</strong> Revana is a little cheaper than Kie.ai at 1080p; fal is the most expensive.</li>
<li><strong>Nano Banana Pro images:</strong> Revana at $0.04 is less than half of the next seller.</li>
<li>No single API wins every row. If you use several models, the real decision is whether one key and one request shape for all of them is worth a few percent on some rows. That's the trade Revana is built around: one <code>POST /v1/generations</code> for 200+ models, charging only for successful generations.</li>
</ul>
<p><em>All prices as of 2026-11-05, from: api.revana.si/v1/models; kie.ai/seedance-2-5, /veo-3-1, /kling-3-0, /wan3.0-video, /nano-banana-pro; fal.ai model pages; replicate.com model pages; docs.byteplus.com ModelArk pricing; cloud.google.com Vertex AI pricing; ai.google.dev Gemini API pricing.</em></p>
<hr />
<p><em>Try it: <a href="https://revana.si/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-1">revana.si</a> has one API key for 200+ video and image models, with live prices on <a href="https://revana.si/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=crosspost-1">revana.si/pricing</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[One API for Seedance, Veo and Kling: generating AI video with curl]]></title><description><![CDATA[Generate AI video with Seedance, Veo, Kling and a free MiniMax H3 model through one API key: one request shape, cheapest route first, failures free.]]></description><link>https://revana.hashnode.dev/one-api-for-seedance-veo-and-kling-generating-ai-video-with-curl</link><guid isPermaLink="true">https://revana.hashnode.dev/one-api-for-seedance-veo-and-kling-generating-ai-video-with-curl</guid><category><![CDATA[api]]></category><category><![CDATA[AI]]></category><category><![CDATA[video]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[generative ai]]></category><dc:creator><![CDATA[Revana]]></dc:creator><pubDate>Tue, 03 Nov 2026 06:43:56 GMT</pubDate><content:encoded><![CDATA[<p>If you build anything that generates video, you end up with a drawer full of API keys: one for Seedance, one for Veo, one for Kling, each with its own request shape, its own polling flow and its own bill. I built <a href="https://revana.si/?ref=hashnode">Revana</a> to collapse that into a single endpoint. This post shows what that looks like in practice, with <code>curl</code>.</p>
<h2>One request shape for every model</h2>
<p>Every model, video or image, runs on the same call: <code>POST https://api.revana.si/v1/generations</code> with a <code>model</code> id and an <code>input</code> object. Switching from Seedance to Veo or Kling means changing one string.</p>
<p>Here is a full round trip with the free MiniMax H3 model (video with sound, no credit needed), so you can try it without spending anything:</p>
<pre><code class="language-bash">KEY=$(uuidgen)   # one Idempotency-Key per job

# 1. Submit the job (returns 202 with a generation id)
curl https://api.revana.si/v1/generations \
  -H "Authorization: Bearer $REVANA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{
    "model": "minimax-h3-free",
    "input": {
      "prompt": "A street drummer plays on upturned buckets on a busy corner at night, passers-by slowing to watch.",
      "resolution": "768p",
      "aspect_ratio": "9:16",
      "duration": 8
    },
    "max_cost_usd": 0.01
  }'

# 2. Poll until status is "succeeded" or "failed"
curl https://api.revana.si/v1/generations/gen_... \
  -H "Authorization: Bearer $REVANA_API_KEY"

# 3. Download the file
curl -L https://api.revana.si/v1/generations/gen_.../content \
  -H "Authorization: Bearer $REVANA_API_KEY" -o clip.mp4
</code></pre>
<p>To run the same prompt on another model, change <code>"model"</code> to <code>seedance-2.5</code>, <code>veo-3.1-fast</code> or <code>kling-3.0</code> (and raise <code>max_cost_usd</code>). The full list, with each model's options and per-second prices, is at <code>GET /v1/models</code> or <a href="https://revana.si/models">revana.si/models</a>.</p>
<h2>Know the price before you generate</h2>
<p><code>POST /v1/generations/quote</code> takes the same body and answers what the job will cost (<code>quoted_usd</code>) and the most it can cost (<code>reserved_usd</code>). It is free and creates nothing. Pass the quote as <code>max_cost_usd</code> on the real call and the job will never cost more than that.</p>
<h2>Cheapest route first, and failures are free</h2>
<p>Most models are sold by several providers at different prices. For each request Revana tries the cheapest healthy route first; if it errors, times out or is full, the next route takes over, so you don't write that retry logic yourself. Only the route that succeeds is charged. A failed generation costs $0.</p>
<p>Resending a request with the same <code>Idempotency-Key</code> after a timeout or a 5xx returns the same generation, so a flaky network never makes you pay twice.</p>
<h2>Other ways in</h2>
<ul>
<li>An MCP server, so Claude or ChatGPT can quote and generate videos for you: <a href="https://revana.si/mcp">revana.si/mcp</a></li>
<li>An OpenAI-compatible chat completions endpoint for language models</li>
<li>A machine-readable summary for agents: <a href="https://revana.si/llms.txt">revana.si/llms.txt</a></li>
</ul>
<p>Billing is prepaid credits: no subscription and no monthly minimum. Docs are at <a href="https://revana.si/docs">revana.si/docs</a>. If you try it, I'd like to hear what breaks: <a href="mailto:hello@revana.si">hello@revana.si</a>.</p>
]]></content:encoded></item></channel></rss>