Image & Video webhooks

Receive the result of a generation instead of polling for it.

How-to guide · Assumes you have completed the Image & Video quickstart.

Overview

Video generations can take several minutes, which makes polling expensive to hold open. Opt a generation into webhook delivery and ElevenLabs sends a flows_generation event to your endpoint once the generation reaches completed or failed.

The event payload is the terminal response of the corresponding GET endpoint, so a handler that already understands the polling response needs no separate parsing path.

Before you begin

Webhook delivery uses the webhooks your workspace has subscribed to generation events. Setting one up takes two steps: create the webhook, then subscribe it to the event.

1

Create a webhook

Go to Developers > Webhooks and create a webhook with a publicly reachable HTTPS callback URL. Keep the signing secret it returns; you need it to verify incoming events.

2

Subscribe it to generation events

Under Select events to listen to, tick Image & Video API generation completed. A webhook that exists but is not subscribed to this event is never called.

You can do the same through the API by passing the flows event to Update workspace webhook:

1{
2 "events": ["flows"]
3}

Creating and subscribing webhooks requires the Webhooks Manage permission, or workspace admin. A single event accepts up to 10 webhooks; beyond that the request fails with too_many_webhooks.

A generation that requests webhook delivery when no webhook is subscribed to generation events is rejected, so a result is never generated with nowhere to deliver it.

Request webhook delivery

Add a webhook object to the create request. Use {"type": "all"} to deliver to every webhook subscribed to generation events, which keeps the request stable as webhooks are added or replaced.

1from elevenlabs import VideoGenerationRequest_Veo31FastGenerate001, WebhookTarget_All
2
3generation = elevenlabs.flows.video.create(
4 request=VideoGenerationRequest_Veo31FastGenerate001(
5 prompt="A corgi rides a tiny surfboard across a sunlit wave at golden hour, cinematic",
6 duration_secs=8,
7 webhook=WebhookTarget_All(),
8 )
9)

To target specific webhooks instead, set the webhook field to a list of IDs. Each ID must be one of the workspace’s webhooks subscribed to generation events.

1{
2 "webhook": {
3 "type": "ids",
4 "ids": ["Q8mVr2LpXcT4nB6yJdKw"]
5 }
6}

The create request validates the target before starting the generation and returns an error when delivery would not be possible:

Error statusCause
no_webhooks_configuredDelivery to all webhooks was requested, but the workspace has none.
invalid_webhook_idA listed webhook is not subscribed to generation events, or no longer exists.
webhook_disabledA targeted webhook is disabled, manually or automatically after failures.

Webhook payload

A completed generation delivers the output URL and MIME type:

1{
2 "type": "flows_generation",
3 "event_timestamp": 1739721600,
4 "data": {
5 "id": "JWr5N6X9ZTqf8jD2LmQb",
6 "status": "completed",
7 "content_url": "https://storage.googleapis.com/generations/JWr5N6X9ZTqf8jD2LmQb",
8 "content_mime_type": "video/mp4"
9 }
10}

A failed generation delivers the failure category and message instead:

1{
2 "type": "flows_generation",
3 "event_timestamp": 1739721600,
4 "data": {
5 "id": "JWr5N6X9ZTqf8jD2LmQb",
6 "status": "failed",
7 "failure_reason": "timeout",
8 "error_message": "Timed out while processing. You were not charged for this generation."
9 }
10}

Branch on data.status to decide which fields are present. The two terminal statuses are the only ones a webhook can carry, since delivery happens only when a generation finishes.

content_url is a signed URL that expires roughly an hour after the event is sent. Download the media promptly, or fetch the generation again for a fresh URL.

Handle the event

A handler verifies the signature, checks the event type, then branches on data.status. This example downloads the output of a completed generation and logs the reason for a failed one.

1# server.py
2import os
3
4import requests
5from dotenv import load_dotenv
6from elevenlabs.client import ElevenLabs
7from elevenlabs.errors import BadRequestError
8from fastapi import FastAPI, Request
9from fastapi.responses import JSONResponse
10
11load_dotenv()
12
13app = FastAPI()
14elevenlabs = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
15WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET")
16
17
18@app.post("/webhook/flows")
19async def receive_generation(request: Request):
20 payload = await request.body()
21 signature = request.headers.get("elevenlabs-signature")
22
23 try:
24 event = elevenlabs.webhooks.construct_event(
25 rawBody=payload.decode("utf-8"),
26 sig_header=signature,
27 secret=WEBHOOK_SECRET,
28 )
29 except BadRequestError:
30 return JSONResponse(content={"error": "Invalid signature"}, status_code=401)
31
32 # construct_event returns a parsed dict, not an object with attributes.
33 if event.get("type") != "flows_generation":
34 return {"status": "ignored"}
35
36 generation = event["data"]
37 if generation["status"] == "completed":
38 media = requests.get(generation["content_url"]).content
39 with open(f"{generation['id']}.mp4", "wb") as f:
40 f.write(media)
41 else:
42 print(f"Generation {generation['id']} failed: {generation['failure_reason']}")
43
44 return {"status": "received"}

Both examples download inside the request for brevity. A large video takes long enough that this can outlast the delivery timeout, so in production hand the generation ID to a queue and return 2xx immediately. The signed URL is valid for about an hour, which is ample for a background worker.

To receive events on a local server during development, expose it with a tunnel such as ngrok and use the HTTPS URL it gives you as the webhook’s callback URL.

Verify the signature

The handler above calls construct_event / constructEvent, which verifies the ElevenLabs-Signature header, validates the timestamp, and parses the payload in one step. Always verify before trusting an event.

It is important for the listener to validate all incoming webhooks. Webhooks currently support authentication via HMAC signatures. Set up HMAC authentication by:

  • Securely storing the shared secret generated upon creation of the webhook
  • Verifying the ElevenLabs-Signature header in your endpoint using the SDK

The JavaScript SDK exposes constructEvent; the Python SDK exposes construct_event with rawBody, sig_header, and secret (these are not named payload / signature in Python). Both verify the signature, validate the timestamp, and parse the JSON payload.

Example webhook handler using FastAPI:

1from dotenv import load_dotenv
2from fastapi import FastAPI, Request
3from fastapi.responses import JSONResponse
4from elevenlabs.client import ElevenLabs
5from elevenlabs.errors import BadRequestError
6import os
7
8load_dotenv()
9
10app = FastAPI()
11elevenlabs = ElevenLabs(
12 api_key=os.getenv("ELEVENLABS_API_KEY"),
13)
14
15WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET")
16
17@app.post("/webhook")
18async def receive_message(request: Request):
19 payload = await request.body()
20 signature = request.headers.get("elevenlabs-signature")
21
22 try:
23 event = elevenlabs.webhooks.construct_event(
24 rawBody=payload.decode("utf-8"),
25 sig_header=signature,
26 secret=WEBHOOK_SECRET,
27 )
28 except BadRequestError as e:
29 return JSONResponse(content={"error": "Invalid signature"}, status_code=401)
30
31 # construct_event returns a dict (parsed JSON), not an object with attributes
32 if event.get("type") == "post_call_transcription":
33 print(f"Received transcription: {event.get('data')}")
34
35 return {"status": "received"}

Delivery behavior

Each generation delivers exactly one terminal event per targeted webhook. Delivery is independent of the generation itself: a webhook that fails or is unreachable does not affect the result, which stays available from the GET endpoint and in the list response.

Return a 2xx status promptly from your handler. Repeated failures auto-disable a webhook, and a disabled webhook causes subsequent generations that target it to be rejected at create time. Design the handler to be idempotent and use the generation id to deduplicate.

For workflows where a missed result is not acceptable, treat webhooks as the fast path and reconcile periodically with flows.image.list or flows.video.list, filtering on status.

Next steps