Guide to HeyGen Webhooks: Features and Best Practices
HeyGen webhooks tell your application when AI video work finishes. Rendering an avatar video, translating a video or running a Video Agent session takes time, and instead of polling HeyGen's API for status, you register an endpoint and HeyGen posts to it when each job succeeds or fails.
This guide covers how HeyGen webhook endpoints are registered and signed, the events you can subscribe to, what the payloads contain and what they leave out, and the practices that keep a HeyGen receiver reliable.
What are HeyGen webhooks?
A HeyGen webhook endpoint is an HTTPS URL registered through HeyGen's v3 API, optionally limited to a list of event types and to one resource. When a matching event happens, HeyGen sends a POST with a JSON body to the URL.
Every delivery includes a header named signature, which is the hex HMAC-SHA256 of the raw request body. The key is the endpoint's secret, which starts with whsec_. Despite that prefix, this is not Standard Webhooks: the whole string, prefix included, is the HMAC key, and there are no webhook-id or webhook-timestamp headers.
HeyGen also has a second, lighter mechanism. When you create a video you can pass a callback_url, and HeyGen sends a one-off notification for that request without a registered endpoint. HeyGen does not document how those callbacks are signed, so this guide covers registered endpoints.
HeyGen webhook features
| Feature | Details |
|---|---|
| Configuration | v3 API: POST /v3/webhooks/endpoints, plus list, PATCH, DELETE and rotate-secret |
| Subscription | An events array per endpoint. Omit it, or set it to null, for all events |
| Scoping | Optional entity_id, for example a personalized video project |
| Signature header | signature |
| Scheme | HMAC-SHA256, hex |
| Signed content | The raw request body only |
| Secret | whsec_..., returned once on create and on rotation, used whole as the key |
| Secret rotation | The old secret stops working immediately. There is no overlap |
| Event type | event_type in the body |
| Response window | 2xx within 10 seconds |
| Retries | Exponential backoff for up to 24 hours, resending the same payload |
| Event history | GET /v3/webhooks/events lists delivered events |
HeyGen publishes no source IP list and no timestamp, so there is no replay window to check.
Common events
HeyGen documents 23 event types. Most come in .success and .fail pairs.
| Area | Events |
|---|---|
| Avatar videos | avatar_video.success, avatar_video.fail |
| GIFs and captions | avatar_video_gif.success, avatar_video_gif.fail, avatar_, avatar_ |
| Translation | video_translate.success, video_translate.fail |
| Video Agent | video_agent.success, video_agent.fail |
| Digital twins | instant_avatar.success, instant_avatar.fail |
| Photo avatars | photo_, photo_, photo_, photo_avatar_train.fail, photo_, photo_ |
| Proofreading | proofread_, proofread_creation.fail |
| Live avatar | live_avatar.success, live_avatar.fail |
| Personalized video | personalized_video |
GET /v3/webhooks/event-types returns the current list with a description of each type, so check it rather than hard-coding the table above.
The body carries event_type and event_data. HeyGen documents the event_data fields only for avatar_video.success. Its List Delivered Events API shows that event like this:
{
"event_id": "evt_abc123",
"event_type": "avatar_video.success",
"event_data": {
"video_id": "vid_xyz789",
"url": "https://files.heygen.com/video/vid_xyz789.mp4",
"gif_download_url": "https://resource.heygen.ai/video/vid_xyz789/gif.gif",
"video_page_url": "https://app.heygen.com/videos/vid_xyz789",
"video_share_page_url": "https://app.heygen.com/share/vid_xyz789",
"folder_id": null,
"callback_id": null
},
"created_at": "2026-03-25T12:05:00Z"
}
That is an API response item, not a captured delivery, so don't assume event_id and created_at are in the body you receive. For failure events, HeyGen says event_data "includes error details" without listing fields.
For everything except avatar_video.success, HeyGen recommends treating the webhook as a completion signal and fetching the result from the API: GET /v3/videos/{video_ for avatar videos and captions, GET /v3/video-agents/{session_ for Video Agent sessions, GET /v3/video-translations/{video_ for translations, and GET /v3/avatars/looks/{look_ for avatars.
Setting up HeyGen webhooks
Register an endpoint with your API key:
curl -X POST "https://api.heygen.com/v3/webhooks/endpoints" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/heygen",
"events": ["avatar_video.success", "avatar_video.fail"]
}'
The response includes the endpoint's endpoint_id and secret. Store the secret straight away, for example as HEYGEN_WEBHOOK_SECRET: list responses return secret: null, so this is the only time you see it.
To change the URL or the subscribed events, send PATCH /v3/webhooks/endpoints/{endpoint_. The events array is replaced, not merged, so include every event you want to keep. DELETE on the same path removes the endpoint, and POST .../rotate-secret issues a new secret.
Securing HeyGen webhooks
Read the signature header, compute an HMAC-SHA256 of the raw body with the full whsec_... secret as the key, hex-encode it, and compare in constant time. Do not use a Standard Webhooks or Svix library, and do not strip or decode the secret.
const crypto = require('crypto');
const express = require('express');
function verifyHeyGenSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex'), // secret as-is, whsec_ included
'utf8'
);
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/heygen', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.HEYGEN_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyHeyGenSignature(req.body, req.get('signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.event_type, then fetch the full result from the API if you need it.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_heygen_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(
signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
)
# In FastAPI: raw_body = await request.body()
# verify_heygen_signature(raw_body, request.headers.get("signature"),
# os.environ["HEYGEN_WEBHOOK_SECRET"])
HeyGen's own Node sample mounts express.raw({ type: "application/json" }). Using */* instead means the body stays raw even if the content type ever varies. Header names are case-insensitive, so signature and Signature are the same header.
Make HeyGen webhooks production-ready. Hookdeck Event Gateway verifies HeyGen's
signatureheader, answers HeyGen with an immediate200 OK, and durably queues every video, translation and avatar event before your service sees it.
HeyGen webhook limitations and pain points
A whsec_ secret that isn't Standard Webhooks
The Problem: Verification fails on every delivery, even with the correct secret.
Why It Happens: The whsec_ prefix suggests Standard Webhooks, whose libraries strip the prefix, base64-decode the rest and expect webhook-* headers. HeyGen uses the whole string as a plain HMAC key over the body alone, so a Standard Webhooks verifier can never match.
Workarounds:
- Use a plain HMAC-SHA256 with the full secret, as above.
- Read the header named
signature, notwebhook-signature.
How Hookdeck Can Help: Hookdeck's HeyGen source type uses the secret exactly as issued and verifies the signature header for you.
Rotation has no grace period
The Problem: Rotating the secret causes a burst of failed verifications.
Why It Happens: HeyGen invalidates the old secret the moment you rotate. Until every receiver has the new value, deliveries fail, and HeyGen itself warns to "expect a brief window of failed verifications during rollover".
Workarounds:
- Deploy the new secret immediately after calling
rotate-secret. - Let failed deliveries be retried. HeyGen keeps retrying for up to 24 hours, so a quick deploy recovers them.
How Hookdeck Can Help: With Hookdeck in front, the secret lives on the source. Update it there once and every service behind it keeps receiving events.
Thin payloads for most events
The Problem: You get a webhook but not the data you need to act on it.
Why It Happens: Only avatar_video.success has a documented field list. HeyGen tells you to fetch results from the API for everything else, and the video url it does send is pre-signed and expires.
Workarounds:
- Treat most events as a signal and fetch the resource from the matching
GETendpoint. - Download videos promptly, or re-fetch the URL from
GET /v3/videos/{video_if it has expired.id}
How Hookdeck Can Help: Hookdeck keeps each payload it receives, so you can see exactly which fields arrived for an event type, and filters on event_type can send .success and .fail events to different handlers.
No replay protection and no delivery id
The Problem: A captured request still verifies if it is sent again, and retries look like new events.
Why It Happens: Only the body is signed, with no timestamp. HeyGen documents no delivery id header and says a retry resends the same payload.
Workarounds:
- Deduplicate on
event_typewithevent_data.video_id, or on thecallback_idyou set when creating the video, as HeyGen suggests. - Keep the endpoint on HTTPS.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on the body fields you choose.
Best practices
Verify against the raw body
HeyGen's docs warn that any whitespace or key-order change breaks the HMAC. Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after the check passes.
Subscribe only to what you handle
An endpoint with no events filter receives all 23 types. List the events you need, and remember that a PATCH replaces the whole list.
Use callback_id to correlate jobs
Set a callback_id when you create a video. It comes back in event_data, so you can match the webhook to your own record without storing HeyGen's ids first.
Store videos promptly
The download url is pre-signed and expires. Copy the file to your own storage soon after avatar_video.success, or fetch a fresh URL from the API later.
Acknowledge fast and process asynchronously
HeyGen allows 10 seconds. Downloading a video takes longer, so verify, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
HeyGen signs every webhook with a header named signature: a hex HMAC-SHA256 of the raw body, keyed with the whole whsec_ secret. Verify with a plain HMAC rather than a Standard Webhooks library, subscribe only to the events you handle, fetch full results from the API, and deploy a rotated secret quickly because the old one stops working at once.
Hookdeck Event Gateway sits in front of your receiver, checks every HeyGen signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle HeyGen webhooks reliably in minutes.