Gareth Wilson Gareth Wilson

Guide to HeyGen Webhooks: Features and Best Practices

Published


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

FeatureDetails
Configurationv3 API: POST /v3/webhooks/endpoints, plus list, PATCH, DELETE and rotate-secret
SubscriptionAn events array per endpoint. Omit it, or set it to null, for all events
ScopingOptional entity_id, for example a personalized video project
Signature headersignature
SchemeHMAC-SHA256, hex
Signed contentThe raw request body only
Secretwhsec_..., returned once on create and on rotation, used whole as the key
Secret rotationThe old secret stops working immediately. There is no overlap
Event typeevent_type in the body
Response window2xx within 10 seconds
RetriesExponential backoff for up to 24 hours, resending the same payload
Event historyGET /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.

AreaEvents
Avatar videosavatar_video.success, avatar_video.fail
GIFs and captionsavatar_video_gif.success, avatar_video_gif.fail, avatar_video_caption.success, avatar_video_caption.fail
Translationvideo_translate.success, video_translate.fail
Video Agentvideo_agent.success, video_agent.fail
Digital twinsinstant_avatar.success, instant_avatar.fail
Photo avatarsphoto_avatar_generation.success, photo_avatar_generation.fail, photo_avatar_train.success, photo_avatar_train.fail, photo_avatar_add_motion.success, photo_avatar_add_motion.fail
Proofreadingproofread_creation.success, proofread_creation.fail
Live avatarlive_avatar.success, live_avatar.fail
Personalized videopersonalized_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_id} for avatar videos and captions, GET /v3/video-agents/{session_id} for Video Agent sessions, GET /v3/video-translations/{video_translation_id} for translations, and GET /v3/avatars/looks/{look_id} 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_id}. 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 signature header, answers HeyGen with an immediate 200 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, not webhook-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 GET endpoint.
  • Download videos promptly, or re-fetch the URL from GET /v3/videos/{video_id} if it has expired.

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_type with event_data.video_id, or on the callback_id you 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.