fal.ai Webhooks: Payload Format, Retries and Signature Verification
fal.ai sends a webhook when a queued request finishes. You submit the job to queue.fal.run with a fal_webhook URL, and fal.ai POSTs a JSON body with request_id, status and the model output in payload, signed with ED25519. This guide shows the real payloads, the retry rules and a dependency-free Node.js verifier, checked against fal.ai's webhook docs in September 2026.
Key takeaways
- fal.ai only sends webhooks for queue requests: add
fal_webhook=<url>tohttps://queue.fal.run/<model-id>, or passwebhook_url/webhookUrlin the SDKs. - Every delivery has
request_id,status("OK"or"ERROR") andpayload; on success,payloadis the model's output, so image URLs sit atpayload.images[].urland video URLs atpayload.video.url. - Signatures are ED25519, not HMAC: verify
X-Fal-Webhook-Signatureagainst the keys athttps://rest.fal.ai/.well-known/jwks.jsonand reject timestamps older than 5 minutes. - fal.ai retries non-2xx responses up to 31 times until the result expires (about 1 hour, about 6 minutes for results of 10 KB or more), so dedupe on
request_id. - To see the exact payload your model returns, point
fal_webhookat a Hooklistener endpoint, then replay the captured delivery to your local handler as often as you need, or let Claude Code or Codex wait for it through the Hooklistener MCP server.
How do fal.ai webhooks work?
A fal.ai webhook is a single POST to your URL when a queued model request reaches a final state. Instead of polling the queue status endpoint for a slow image, video or audio generation, you hand fal.ai a URL and it pushes the result when the model is done. Only the queue API (queue.fal.run, or submit in the SDKs) supports webhooks; the synchronous fal.run call returns the result in the HTTP response instead.
How do I set a webhook URL on a fal.ai request?
Pass your URL as the fal_webhook query parameter on the queue endpoint. The immediate response only contains the request IDs; the result arrives on your webhook.
curl --request POST \
--url 'https://queue.fal.run/fal-ai/flux/dev?fal_webhook=https://url.to.your.app/api/fal/webhook' \
--header "Authorization: Key $FAL_KEY" \
--header 'Content-Type: application/json' \
--data '{"prompt": "Photo of a cute dog"}'
# Response: the request is queued, the result arrives later on your webhook
# {"request_id": "024ca5b1-...", "gateway_request_id": "024ca5b1-..."}The official clients take the same URL as an option:
import fal_client
handler = fal_client.submit(
"fal-ai/flux/dev",
arguments={"prompt": "Photo of a cute dog"},
webhook_url="https://url.to.your.app/api/fal/webhook",
)
print(f"Request submitted: {handler.request_id}")import { fal } from "@fal-ai/client";
const { request_id } = await fal.queue.submit("fal-ai/flux/dev", {
input: { prompt: "Photo of a cute dog" },
webhookUrl: "https://url.to.your.app/api/fal/webhook",
});
console.log(`Request submitted: ${request_id}`);What does the fal.ai webhook payload look like?
The body is always a JSON object with four top-level fields, and payload is exactly what the model's output schema defines. That is why an image model and a video model put their file URLs in different places.
| Field | Meaning |
|---|---|
request_id | The ID returned when you submitted the job. Use it to match and dedupe deliveries. |
gateway_request_id | Gateway-level ID, usually equal to request_id. |
status | "OK" or "ERROR". There is no in-progress webhook; you only hear about the final state. |
payload | The model output on success, the error details on failure, or null if the output could not be serialized. |
error / payload_error | Present only on failures: a request error message, or a note that the output was not valid JSON. |
Completed image request
{
"request_id": "123e4567-e89b-12d3-a456-426614174000",
"gateway_request_id": "123e4567-e89b-12d3-a456-426614174000",
"status": "OK",
"payload": {
"images": [
{
"url": "https://url.to/image.png",
"content_type": "image/png",
"file_name": "image.png",
"file_size": 1824075,
"width": 1024,
"height": 1024
}
],
"seed": 196619188014358660
}
}Completed video request
Video models return a single file object, so the URL is at payload.video.url. The envelope is identical; only the inner shape changes. Check the model's API page on fal.ai for its exact output schema, or capture one real delivery and read it.
{
"request_id": "5b1d7e2a-9c4f-4a8e-b0d3-2f6e8a1c9d47",
"gateway_request_id": "5b1d7e2a-9c4f-4a8e-b0d3-2f6e8a1c9d47",
"status": "OK",
"payload": {
"video": {
"url": "https://url.to/output.mp4",
"content_type": "video/mp4",
"file_name": "output.mp4",
"file_size": 4718592
}
}
}Failed request
If the model call fails, status is "ERROR", error carries a message and payload holds the details.
{
"request_id": "123e4567-e89b-12d3-a456-426614174000",
"gateway_request_id": "123e4567-e89b-12d3-a456-426614174000",
"status": "ERROR",
"error": "Invalid status code: 422",
"payload": {
"detail": [
{
"loc": ["body", "prompt"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
}Output that is not valid JSON
If fal.ai cannot serialize the output, the delivery still says "OK", but payload is null and payload_error explains why. Handle this case explicitly, or your handler will crash on payload.images.
Does fal.ai retry failed webhooks?
Yes: anything other than a 2xx response is retried with increasing backoff, up to 31 times, until the result expires. The rules, per fal.ai's docs:
- The first delivery times out after 15 seconds; retries time out after 120 seconds.
- Results expire about 1 hour after completion, or about 6 minutes for results of 10 KB or more. After that, retries stop.
- 4xx, 5xx, timeouts and network errors all count as failures.
- 3xx redirects are not followed and count as a permanent failure, so register the final URL (watch trailing slashes and http-to-https redirects).
Because the same result can arrive more than once, make the handler idempotent on request_id, and download the output files to your own storage when you process the webhook rather than keeping fal.ai's URLs forever.
How do I verify a fal.ai webhook signature?
Verify the ED25519 signature in X-Fal-Webhook-Signature against fal.ai's public JWKS; there is no shared secret to configure. Each delivery carries four headers:
X-Fal-Webhook-Request-Id: the request IDX-Fal-Webhook-User-Id: your fal.ai user IDX-Fal-Webhook-Timestamp: Unix epoch secondsX-Fal-Webhook-Signature: hex-encoded ED25519 signature
The signed message is the request ID, user ID, timestamp and the hex SHA-256 of the raw body, joined with newlines. Fetch keys from https://rest.fal.ai/.well-known/jwks.json (older examples point at rest.alpha.fal.ai; use the current host) and cache them for up to 24 hours. Node's built-in crypto handles ED25519 JWKs directly, so you don't need libsodium:
import crypto from "node:crypto";
const JWKS_URL = "https://rest.fal.ai/.well-known/jwks.json";
const JWKS_TTL_MS = 24 * 60 * 60 * 1000; // fal.ai allows caching for up to 24 h
let jwksCache = { keys: [], fetchedAt: 0 };
async function getPublicKeys() {
if (Date.now() - jwksCache.fetchedAt > JWKS_TTL_MS) {
const res = await fetch(JWKS_URL);
const { keys } = await res.json();
jwksCache = {
keys: keys.map((jwk) => crypto.createPublicKey({ key: jwk, format: "jwk" })),
fetchedAt: Date.now(),
};
}
return jwksCache.keys;
}
// rawBody must be the exact bytes fal.ai sent (a Buffer), not re-serialized JSON
export async function verifyFalWebhook(headers, rawBody) {
const requestId = headers["x-fal-webhook-request-id"];
const userId = headers["x-fal-webhook-user-id"];
const timestamp = headers["x-fal-webhook-timestamp"];
const signatureHex = headers["x-fal-webhook-signature"];
if (!requestId || !userId || !timestamp || !signatureHex) return false;
// 1. Reject deliveries more than 5 minutes off
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
// 2. Rebuild the signed message
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const message = Buffer.from([requestId, userId, timestamp, bodyHash].join("\n"), "utf8");
const signature = Buffer.from(signatureHex, "hex");
// 3. Any current fal.ai key may have signed it
const keys = await getPublicKeys();
return keys.some((key) => crypto.verify(null, message, key, signature));
}Wire it into a route that keeps the raw body, acknowledges fast and handles every status:
import express from "express";
import { verifyFalWebhook } from "./verify-fal.js";
const app = express();
app.post("/api/fal/webhook", express.raw({ type: "application/json" }), async (req, res) => {
if (!(await verifyFalWebhook(req.headers, req.body))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // acknowledge fast; fal.ai's first attempt times out at 15 s
if (await alreadyProcessed(event.request_id)) return; // retries reuse request_id
if (event.status === "OK" && event.payload) {
const urls = [
...(event.payload.images ?? []).map((img) => img.url),
event.payload.video?.url,
].filter(Boolean);
await saveResult(event.request_id, urls); // copy files to your storage now
} else {
await markFailed(event.request_id, event.error ?? event.payload_error);
}
});The 5-minute timestamp window also rejects a replayed delivery once it is older than that. That is the point in production, but it matters when you replay a captured request in development: re-trigger the job, or bypass only the timestamp check behind a dev-only flag.
Test fal.ai webhooks with Hooklistener
The quickest way to learn a model's real output shape is to capture one delivery. Hooklistener gives you a public HTTPS URL that records every header and body byte fal.ai sends.
- Create an endpoint in the Hooklistener dashboard and use its URL as
fal_webhook. Run one request and read the captured JSON, including the fourX-Fal-Webhook-*headers. - Forward to localhost with the CLI:
hooklistener login, thenhooklistener tunnel --port 3000, so live deliveries hit your handler while you debug. - Replay a captured success or error delivery to your handler as many times as you need, instead of paying for another generation.
- Let your coding agent do it. Connect the Hooklistener MCP server (
claude mcp add --transport http hooklistener https://app.hooklistener.com/api/mcp). Claude Code or Codex can callcreate_endpoint, submit the fal.ai job, callwait_for_request, which waits for the matching webhook (as a task the agent follows, or blocking up to 60 s withblocking: true), write the handler from the real payload, andreplay_requestit.
FAQ
What does the fal.ai webhook payload look like when a request completes?
fal.ai POSTs a JSON object with request_id, gateway_request_id, status and payload. On success status is "OK" and payload is the model's output, for example payload.images[0].url for image models or payload.video.url for video models. On failure status is "ERROR", error holds a message such as "Invalid status code: 422", and payload holds the error details.
How do I add a webhook to a fal.ai request?
Submit the request to the queue and pass your URL: the fal_webhook query parameter on https://queue.fal.run/<model-id>, webhook_url in fal_client.submit() for Python, or webhookUrl in fal.queue.submit() for JavaScript. The synchronous fal.run endpoint does not send webhooks.
How do I verify a fal.ai webhook signature?
Read the X-Fal-Webhook-Request-Id, X-Fal-Webhook-User-Id, X-Fal-Webhook-Timestamp and X-Fal-Webhook-Signature headers, reject timestamps more than 300 seconds off, join request ID, user ID, timestamp and the hex SHA-256 of the raw body with newlines, then check the hex ED25519 signature against the public keys at https://rest.fal.ai/.well-known/jwks.json.
Does fal.ai retry failed webhooks?
Yes. Any non-2xx response, timeout or network error is retried with increasing backoff, up to 31 retries, until the result expires about 1 hour after completion (about 6 minutes for results of 10 KB or more). The first attempt times out after 15 seconds, retries after 120 seconds, and 3xx redirects are treated as permanent failures.
How can I see the exact fal.ai webhook payload for my model?
Point fal_webhook at a Hooklistener debug endpoint, run one request, and open the captured delivery to see the full headers and JSON body. From an AI coding agent, the Hooklistener MCP tools create_endpoint and wait_for_request do the same without leaving the editor.