New: AI voiceover + auto-captions - script to captioned video in one API call. Read the announcement

Guides

How to Embed Video Generation in Your SaaS Product

Wire a video API into your SaaS as a customer-facing feature. Multi-tenancy, quota metering, webhook routing, and the async UX — with working code. A 30s render costs $0.05.

Phil Duong

Phil Duong

Founder

How to Embed Video Generation in Your SaaS Product

Your customers keep asking for video. You do not want to become a video company.

That is the whole problem this guide solves. Using a video API for SaaS means your users click a button in your product and get a finished MP4, while you own none of the rendering infrastructure. It is a different job from running renders as an internal batch, and it fails in different ways — mostly around multi-tenancy, metering, and the fact that a render is asynchronous while a button click feels like it should not be.

We will build the whole path: the architecture decisions up front, a server proxy that never leaks your key, quota metering that survives a failed render, webhook routing that finds the right tenant, and the async UX that makes a two-minute render feel deliberate rather than broken.

If you have not chosen a vendor yet, start with the developer's guide to video APIs and come back. This guide assumes you have an API key and a template.

Key Takeaways

  • Embedded video is a multi-tenancy problem first and a rendering problem second
  • Never let a browser hold your render API key — proxy every call through your own backend
  • Meter quota in the same transaction that creates the job, and refund on failure
  • Route webhooks by the jobId you stored, never by a tenant id in the callback URL
  • One shared template with per-tenant variables beats per-tenant templates until a customer needs a structural change
  • At $0.10 per finished 1080p minute, render cost is rarely what limits the feature's margin

Embedding a Video API in SaaS vs Running a Batch Job

An internal batch job renders videos you control, on your schedule, from your data. An embedded feature renders videos your customers control, on their schedule, from their data — which means untrusted input, per-tenant limits, per-tenant branding, and a UI that has to explain a job queue to someone who did not ask for one.

Five things change when the user is on the other side of the render.

ConcernInternal batchEmbedded feature
InputYours, trustedCustomer's, untrusted
FailureYou retry quietlyCustomer sees it; refund the quota
CostA line in your budgetNeeds metering per tenant
LatencyNobody is watchingSomeone is watching
BrandingOne brandOne per tenant

The batch pipeline pattern — queue everything, reconcile later — still applies underneath, and generating 1,000+ personalized videos covers it in depth. What follows is the layer you add on top when a customer is waiting.

Here is the whole path, end to end. Your backend sits in the middle of every arrow; the browser never touches the render API.

Architecture diagram. Request path: the customer's browser posts to your route handler, which authenticates, reserves quota, and calls the render API server-side with your key. Return path: the render API posts a signed webhook to your handler, which verifies the signature, looks the tenant up by job id, archives the file to your bucket, and notifies the user. The browser never holds the API key.Request pathCustomer browserno API keyYour route handlerauth · reserve quotawrite VideoJob rowyour keyRender APIreturns jobIdReturn path (webhook)Render APIsigns with whsec_Your webhook handlerverify · dedupe on deliveryIdjobId → tenantYour bucket + UIarchive · notifyThe API key and the webhook secret live only in the two shaded boxes.

Four Decisions Before You Write Code

Getting these wrong is expensive to reverse, so make them deliberately.

1. Where does the template live? Start with one shared template and per-tenant variables. A logo URL, a brand color, and a font name are variables. Clone to a per-tenant template only when a customer needs a structural change — a different scene count or layer order — not different content in the same layout. Per-tenant templates multiply your maintenance surface by your customer count, and a fix to a shared template ships to everyone at once.

2. What is the unit you sell? Videos, minutes, or "included in the plan." Videos are easiest for customers to reason about and easiest for you to meter. Minutes match your upstream cost more closely but force your customers to think in the vendor's units.

3. Who owns the output file? If the vendor's CDN hosts the MP4, you inherit their retention policy and your customers' videos disappear on their schedule. Copying finished renders to your own bucket costs storage but makes the asset yours. For most products this is the right call.

4. How does the finished video reach the user? Email, in-app notification, or a job list they check. Pick one before you build the UI, because it determines whether you need a notification system.

Step 1: Model the Tenant, Template, and Job

Your database needs one table the video API knows nothing about: the mapping from a render job back to the tenant who asked for it. This is the row that makes webhook routing possible and quota refunds correct.

model Tenant {
  id              String     @id @default(cuid())
  videoCredits    Int        @default(0)
  // Per-tenant branding, injected as template variables at render time.
  videoTemplateId String
  brandColor      String
  logoUrl         String
  jobs            VideoJob[]
}
 
model VideoJob {
  id             String    @id @default(cuid())
  tenantId       String
  userId         String
  // The vendor's job id. Indexed — every webhook looks up by this.
  providerJobId  String    @unique
  status         JobStatus @default(PENDING)
  // What we charged the tenant, so a failure refunds the same amount.
  quotaCharged   Int       @default(1)
  outputUrl      String?
  errorMessage   String?
  createdAt      DateTime  @default(now())
 
  tenant         Tenant    @relation(fields: [tenantId], references: [id])
  @@index([tenantId, createdAt])
}
 
// One row per webhook delivery, for idempotency. `processedAt` is set only
// after the work succeeds — see Step 5 for why the two-column split matters.
model WebhookDelivery {
  id          String    @id
  processedAt DateTime?
  receivedAt  DateTime  @default(now())
}
 
enum JobStatus {
  PENDING
  RENDERING
  COMPLETED
  FAILED
}

Two details matter here. providerJobId is unique and indexed because it is the only key the webhook carries. And quotaCharged is stored per job rather than assumed, so if you change pricing later, in-flight jobs refund what they actually cost.

Step 2: Register the Webhook Once, Not Per Render

Renderly accepts a webhookUrl on an individual render, and for an embedded feature that is the wrong tool. Per-request callbacks are signed with a shared constant rather than a secret only you hold, they carry no delivery id, and they are sent exactly once with no retry. All three of those are load-bearing for the code in Step 5.

Register the endpoint once at startup or by hand, and store the returned secret:

curl -X POST https://renderly.video/api/v1/webhooks \
  -H "Authorization: Bearer $RENDERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/hooks/render",
    "events": ["render.completed", "render.failed"]
  }'

The response carries a whsec_-prefixed signing secret, shown only at creation. Save it as RENDERLY_WEBHOOK_SECRET; there is no way to read it back later. A registered webhook is retried three times with 1s/2s/4s backoff and every delivery carries a stable deliveryId.

Step 3: Proxy Every Call — Never Expose the Key

A render API key in client-side code is a key on the public internet. Anyone can read it out of your bundle or a network tab and spend your credits. There is no client-side configuration that makes this safe.

Every render request goes through a route handler in your own backend. That handler authenticates the session, resolves the tenant, checks quota, and only then talks to the video API with a key that never leaves your server.

// app/api/videos/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { z } from "zod";
 
// Tenant input reaches a third-party renderer that will fetch this URL from
// its own network. `.url()` only checks shape, so it happily accepts
// http://169.254.169.254/... — allowlist the scheme and host explicitly.
const ASSET_HOSTS = new Set(["cdn.example.com", "uploads.example.com"]);
 
const Body = z.object({
  headline: z.string().min(1).max(80),
  productImageUrl: z
    .string()
    .url()
    .refine((raw) => {
      const u = new URL(raw);
      return u.protocol === "https:" && ASSET_HOSTS.has(u.hostname);
    }, "Asset URL must be https and on an allowlisted host"),
});
 
export async function POST(req: Request) {
  const session = await auth();
  if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
 
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid input" }, { status: 400 });
  }
 
  const tenant = await prisma.tenant.findUniqueOrThrow({
    where: { id: session.user.tenantId },
  });
 
  // Meter first — see Step 4.
  const job = await reserveQuotaAndCreateJob(tenant.id, session.user.id);
  if (!job) {
    return NextResponse.json({ error: "Video quota exhausted" }, { status: 402 });
  }
 
  const res = await fetch("https://renderly.video/api/v1/renders", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RENDERLY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      projectId: tenant.videoTemplateId,
      replacements: {
        headline: parsed.data.headline,
        product_image: parsed.data.productImageUrl,
        brand_color: tenant.brandColor,
        logo: tenant.logoUrl,
      },
      // No webhookUrl here — the endpoint is registered once (Step 2), so
      // deliveries are signed with our own secret, retried, and carry a
      // deliveryId. A per-render webhookUrl gets none of that.
    }),
  });
 
  if (!res.ok) {
    await releaseQuota(job.id);
    return NextResponse.json({ error: "Render failed to queue" }, { status: 502 });
  }
 
  const { data } = await res.json();
  await prisma.videoJob.update({
    where: { id: job.id },
    data: { providerJobId: data.jobId, status: "RENDERING" },
  });
 
  // Return OUR job id. The vendor's id is an implementation detail.
  return NextResponse.json({ jobId: job.id, status: "RENDERING" }, { status: 202 });
}

Note the response: 202 Accepted with your own job id. Leaking the provider's job id invites customers to build against it, which welds you to that vendor.

Validating headline with a length cap is not paranoia. Text that overflows its layer does not throw — it renders badly, and you will not see it until a customer complains about a broken video.

Step 4: Meter Before You Render

Charge quota in the same transaction that creates the job row. Metering on the completion webhook is the common mistake: a dropped delivery becomes a video you gave away, and dropped deliveries are normal.

async function reserveQuotaAndCreateJob(tenantId: string, userId: string) {
  return prisma.$transaction(async (tx) => {
    // Conditional update — the WHERE clause is the lock.
    const updated = await tx.tenant.updateMany({
      where: { id: tenantId, videoCredits: { gte: 1 } },
      data: { videoCredits: { decrement: 1 } },
    });
    if (updated.count === 0) return null;   // out of quota
 
    return tx.videoJob.create({
      data: {
        tenantId,
        userId,
        providerJobId: `pending_${crypto.randomUUID()}`,
        quotaCharged: 1,
        status: "PENDING",
      },
    });
  });
}

The where clause carrying videoCredits: { gte: 1 } is what makes this safe under concurrency. Two simultaneous requests from the same tenant with one credit left cannot both succeed — the second updateMany matches zero rows.

Refund on every failure path: a non-2xx from the render API, a render.failed webhook, and any job still pending past your reconciliation threshold.

Step 5: Route the Webhook Back to the Tenant

The webhook payload carries a job id, not a tenant. You resolve the tenant from the row you wrote in Step 1. Do not put a tenant id in the callback URL and read it back — that is user-influenceable input standing in for authorization.

// app/api/webhooks/render/route.ts
import crypto from "crypto";
import { prisma } from "@/lib/db";
 
export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer());   // raw bytes, not parsed JSON
 
  const expected = crypto
    .createHmac("sha256", process.env.RENDERLY_WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");                                    // hex, no "sha256=" prefix
 
  const sig = req.headers.get("x-renderly-signature") ?? "";
  const valid =
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!valid) return new Response("Bad signature", { status: 401 });
 
  // Reject stale deliveries. Header is epoch SECONDS.
  const sentAt = Number(req.headers.get("x-renderly-timestamp"));
  if (!sentAt || Math.abs(Date.now() / 1000 - sentAt) > 300) {
    return new Response("Stale", { status: 400 });
  }
 
  const event = JSON.parse(raw.toString("utf8"));
  const { jobId, outputUrl, errorMessage } = event.data;
 
  // Idempotency: deliveryId is stable across retries. Claim it with a single
  // atomic insert — findUnique-then-create is a race two concurrent retries
  // both win. Registered webhooks always carry a deliveryId; if it is absent
  // you are on a per-render callback and should fix that first (Step 2).
  if (event.deliveryId) {
    const { count } = await prisma.webhookDelivery.createMany({
      data: [{ id: event.deliveryId }],
      skipDuplicates: true,
    });
    // Already claimed AND already finished — genuine duplicate, drop it.
    if (count === 0) {
      const prior = await prisma.webhookDelivery.findUnique({
        where: { id: event.deliveryId },
      });
      if (prior?.processedAt) return new Response(null, { status: 204 });
      // Claimed but never finished: a previous attempt died mid-flight.
      // Fall through and redo the work.
    }
  }
 
  const job = await prisma.videoJob.findUnique({ where: { providerJobId: jobId } });
  if (!job) return new Response(null, { status: 204 });   // not ours; ack anyway
 
  if (event.event === "render.completed") {
    await prisma.videoJob.update({
      where: { id: job.id },
      data: { status: "COMPLETED", outputUrl },
    });
    await enqueueArchiveToOwnBucket(job.id, outputUrl);   // ack fast, copy later
  } else {
    await prisma.$transaction([
      prisma.videoJob.update({
        where: { id: job.id },
        data: { status: "FAILED", errorMessage },
      }),
      prisma.tenant.update({
        where: { id: job.tenantId },
        data: { videoCredits: { increment: job.quotaCharged } },
      }),
    ]);
  }
 
  if (event.deliveryId) {
    await prisma.webhookDelivery.update({
      where: { id: event.deliveryId },
      data: { processedAt: new Date() },
    });
  }
 
  return new Response(null, { status: 204 });
}

We shipped three of these four bugs ourselves before we wrote them down. They are load-bearing:

  • Raw bytes for the HMAC. Parsing JSON and re-serializing changes key order and whitespace, and every signature check fails. req.arrayBuffer(), not req.json().
  • Hex with no prefix. The signature is a bare hex digest. Comparing against sha256=... never matches.
  • Epoch seconds, not milliseconds. Comparing a seconds header against Date.now() yields a skew of roughly 56,000 years and rejects everything.
  • Acknowledge fast. Copying a 40 MB MP4 inside the handler blows the delivery timeout and triggers a retry you did not need.

Our webhook setup guide covers signature verification and replay protection in more depth, and the webhooks reference has the full payload contract. The raw-body requirement is not Renderly-specific — Stripe documents the same constraint for the same reason.

Reconcile, because the retry window is short

A registered Renderly webhook is retried three times with 1s/2s/4s backoff — a window of about seven seconds. A per-render webhookUrl callback is not retried at all. Either way, a deploy that takes your endpoint down for a minute loses those events permanently, so the sweep below is not optional:

// Every 5 minutes. PENDING must be included: if the render POST threw rather
// than returning non-2xx, releaseQuota never ran and the row still holds a
// `pending_` sentinel with no provider job behind it.
const cutoff = new Date(Date.now() - 10 * 60_000);
const stuck = await prisma.videoJob.findMany({
  where: { status: { in: ["PENDING", "RENDERING"] }, createdAt: { lt: cutoff } },
  take: 100,
});
 
for (const job of stuck) {
  const res = await fetch(`https://renderly.video/api/v1/renders/${job.providerJobId}`, {
    headers: { Authorization: `Bearer ${process.env.RENDERLY_API_KEY}` },
  });
  const { data } = await res.json();
  if (data.status === "COMPLETED" || data.status === "FAILED") {
    await applyTerminalState(job, data);   // same logic as the webhook path
  }
}

Write that terminal-state logic once and call it from both paths. Two copies drift.

Step 6: The Async UX

Renders take seconds to minutes. A blocking spinner is the wrong pattern — it makes a working system look broken and traps the user on one screen.

Show a job row with an explicit pending state and let the user leave. Three rules cover it:

  1. Optimistic row on submit. The job appears in the list the moment the POST returns 202, with a pending state and a timestamp. The user has evidence their click worked.
  2. Poll your own API, not the vendor's. A GET /api/videos/:id that reads your own table is cheap and does not consume vendor rate limit. Two-second intervals with backoff to ten is plenty.
  3. Tell them when it lands. In-app toast if they are still on the page, email if they left. This is the moment the feature feels finished.

Give failures a real message and a retry button. "Render failed" with no recourse converts a recoverable hiccup into a support ticket. If you refunded the quota — and Step 5 does — say so, because the customer's first question is whether it cost them anything.

What a Video API Costs When You Resell It in SaaS

At $0.10 per finished 1080p minute on a Renderly Creator plan, a 30-second render costs about $0.05. On the Business plan at $99 for 1,500 credits, the same render is about $0.033.

Volume / monthRendersPlanRender cost
Light300 × 30sCreator ($29)~$15 of a $29 allowance
Growing2,000 × 30sBusiness ($99)~$66 of a $99 allowance
Heavy10,000 × 30sBusiness + overage~$379 ($99 plan + 3,500 credits at $0.08)

Adding narration and captions changes the math more than resolution does. On published rates, text-to-speech runs 0.5 credits per 1,000 characters and caption transcription 0.25 credits per started audio minute — so a 30-second narrated, captioned clip is roughly 0.5 + 0.5 + 0.25 = 1.25 credits, about $0.12 on Creator. Still small, but 2.5x the silent version. Meter it separately if you offer it as an upgrade.

The practical conclusion: render cost is rarely what limits this feature's margin. Storage for finished videos and the support load from confused users both tend to cost more. Price the feature on the value of the output, not on a markup over compute.

Failure Modes Worth Testing Before Launch

  • Two clicks, one credit. Fire two concurrent requests from a tenant with one credit left. Exactly one should get a 402.
  • Two clicks, one video. A tenant with plenty of quota double-clicks. Without an Idempotency-Key header on POST /api/videos, unique-indexed on VideoJob, that is two renders and two charges.
  • A webhook that never arrives. Block your webhook endpoint and confirm the reconciliation sweep still completes the job.
  • A duplicate delivery. Replay the same signed payload. The second call must be a no-op, not a second archive copy.
  • Oversized text. Submit a headline at your maximum length and look at the rendered frame. Overflow does not error.
  • A dead asset URL. Point product_image at a 404 and confirm the failure refunds quota and shows a usable message.
  • A tenant deleted mid-render. The webhook still arrives. It should not throw.

Run all seven against a staging tenant before you expose the button. Most of them fail silently in production — you only learn about them from a customer.

Shipping It

An embedded video feature is four moving parts: a proxy that holds the key, a quota reservation that survives failure, a registered webhook that routes back to the right tenant, and a job list that lets the user walk away. Everything else is your template.

Start narrow. One shared template, one variable your customers actually care about, a hard daily render cap while you learn the traffic shape. The multi-tenancy work above is what turns that into a feature you can sell rather than a demo.

Renderly gives every account 5 free credits to build against — enough for roughly ten 30-second renders — and the API reference covers the render, status, and webhook endpoints used here. If you are still choosing a vendor, the pricing comparison works through what a finished minute costs on each one.

Frequently Asked Questions

How do I stop customers from seeing my video API key?

Never let the browser talk to the video API. Put a route handler in your own backend that authenticates the session, checks the tenant's quota, and calls the render API server-side with a key held in your environment. The client only ever sees your own job id.

Should each customer get their own template?

Start with one shared template and per-tenant variables. Clone to a per-tenant template only when a customer needs a structural change — a different scene order or layer count — rather than different content in the same layout. Per-tenant templates multiply your maintenance surface by your customer count.

How do I bill customers for videos they generate?

Meter before you render, not after. Decrement the tenant's quota inside the same database transaction that creates your job row, then refund it if the render fails. Charging on the completion webhook means a dropped delivery is a video you gave away.

How much margin is there in reselling video rendering?

At $0.10 per finished 1080p minute on a Renderly Creator plan, a 30-second render costs about $0.05, or roughly $0.12 once you add narration and captions. Because the unit cost is that low, the render is rarely what limits the feature's margin — storage for finished videos and the support load from confused users both tend to cost more.

How do I route a render webhook back to the right customer?

Store the mapping yourself. When you create the render, write a row keyed by the returned jobId with the tenant id attached. The webhook gives you jobId; you look up the tenant. Never trust a tenant id passed back in the callback URL — it is user-influenced input.

What should the UI do while a video renders?

Show a job row with an explicit pending state and let the user navigate away. Renders take seconds to minutes, so a blocking spinner is the wrong pattern. Poll your own API for job status, or push over SSE or websockets, and notify when the webhook lands.