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
1// app/api/fal/webhook/route.ts2import { headers } from "next/headers";3import crypto from "node:crypto";45export const runtime = "nodejs";67export 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 });1112 const payload = JSON.parse(body);13 await db.query(14 `UPDATE generations15 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 );1920 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

HMAC SHA256 the raw body, compare to the header.
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
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});56await 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/sec11});
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.

1CREATE UNIQUE INDEX ON webhook_events (delivery_id);23BEGIN;4INSERT INTO webhook_events (delivery_id, request_id, status)5VALUES ($1, $2, $3)6ON CONFLICT DO NOTHING7RETURNING 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
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.