Blog

Integration3 min read

Vercel Webhooks for fal Queue Completion

A ten-line Next.js route that turns async video jobs into completed DB writes. Includes replay safety.


Why a webhook beats polling

You submit a Kling v3 Pro multi prompt clip. 15 seconds at $0.14 per second, $2.10 per render. The job takes four to six minutes. Your serverless function times out at ten seconds. Two options: poll from the browser (bad, burns function invocations) or accept a webhook when fal finishes (correct).

A webhook on Vercel is a single route handler. Fal calls it once with the final payload. You write to your database.

The ten line handler

TYPESCRIPT
1// app/api/fal/webhook/route.ts
2import { headers } from "next/headers";
3import crypto from "node:crypto";
4
5export const runtime = "nodejs";
6
7export async function POST(req: Request) {
8 const body = await req.text();
9 const sig = (await headers()).get("x-fal-signature") ?? "";
10 if (!verify(body, sig)) return new Response("bad sig", { status: 401 });
11
12 const payload = JSON.parse(body);
13 await db.query(
14 `UPDATE generations
15 SET status = $1, video_url = $2, finished_at = now()
16 WHERE request_id = $3`,
17 [payload.status, payload.payload?.video?.url ?? null, payload.request_id],
18 );
19
20 return new Response("ok");
21}

Read the body as text first (we need raw bytes for the signature), verify, parse, update one row, return 200.

Signature check

Signature verification against x-fal-signature
Signature verification against x-fal-signature

HMAC SHA256 the raw body, compare to the header.

TYPESCRIPT
1function verify(rawBody: string, signature: string): boolean {
2 const secret = process.env.FAL_WEBHOOK_SECRET!;
3 const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
4 try {
5 return crypto.timingSafeEqual(
6 Buffer.from(expected, "hex"),
7 Buffer.from(signature, "hex"),
8 );
9 } catch {
10 return false;
11 }
12}

Never parse the body as JSON before computing the signature. Never use === to compare, use timingSafeEqual.

Submit with a webhook URL

TYPESCRIPT
1const { request_id } = await fal.queue.submit("fal-ai/kling/v3-pro/text-to-video", {
2 input: { prompt: "street vendor frying dumplings, steam rising", duration: 10 },
3 webhookUrl: `${process.env.APP_URL}/api/fal/webhook`,
4});
5
6await db.insert("generations", {
7 request_id,
8 status: "IN_QUEUE",
9 endpoint: "fal-ai/kling/v3-pro/text-to-video",
10 estimated_cents: 10 * 14, // $0.14/sec
11});

Save the request id immediately. That row is what the webhook updates.

Replay safety

Webhooks retry. If your handler errors out, fal re-delivers. Your update must be idempotent.

Idempotent writes keyed by request id
Idempotent writes keyed by request id
SQL
1CREATE UNIQUE INDEX ON webhook_events (delivery_id);
2
3BEGIN;
4INSERT INTO webhook_events (delivery_id, request_id, status)
5VALUES ($1, $2, $3)
6ON CONFLICT DO NOTHING
7RETURNING 1;
8UPDATE generations SET status = $3, video_url = $4 WHERE request_id = $2;
9COMMIT;

The ON CONFLICT DO NOTHING plus the RETURNING 1 lets your handler detect replays and short circuit.

When the job failed

TYPESCRIPT
1if (payload.status === "COMPLETED") {
2 await db.query(
3 "UPDATE generations SET status='COMPLETED', video_url=$1 WHERE request_id=$2",
4 [payload.payload.video.url, payload.request_id],
5 );
6} else if (payload.status === "FAILED") {
7 await db.query(
8 "UPDATE generations SET status='FAILED', error=$1 WHERE request_id=$2",
9 [payload.payload?.detail ?? "unknown", payload.request_id],
10 );
11}

Do not retry the generation automatically inside the webhook. That is how you double bill yourself.

Testing locally

Two options. Use Vercel preview URLs: deploy a branch, configure the webhook to hit the preview. Or use vercel dev plus a tunnel (ngrok http 3000) and point the webhook at the tunnel. Never develop webhooks against a production endpoint.

That is the pattern: verify, parse, idempotent update, return 200. Ten lines, and your backend stops pretending it is a web worker.