DEV Community

Cover image for HeyGen API Implementation in Spring Boot: Generate AI Videos with Webhooks
stitas
stitas

Posted on • Originally published at stitas.dev

HeyGen API Implementation in Spring Boot: Generate AI Videos with Webhooks

Hey(Gen)! In this article, I'll show you how I integrated the HeyGen API into a Spring Boot application written in Kotlin to generate AI videos and receive webhook notifications.

I originally built this integration for my AI Santa greeting generator. If you're interested in the project itself, I wrote a separate article covering the idea, tech stack and marketing plan, which you can check out here.

Let's get started!

Setting up HeyGen configuration

First, update your configuration files with the values from HeyGen. In this case, I will be adding the config to application.yaml.

heygen:
  base-url: https://api.heygen.com
  api-key: ${HEYGEN_API_KEY}
  webhook-secret: ${HEYGEN_WEBHOOK_SECRET}
Enter fullscreen mode Exit fullscreen mode

Never put secrets directly into your application.yaml file. Always use environment variables. Also, keep in mind that API URLs can change, so check what HeyGen currently uses to avoid a headache later.

Next, create a @ConfigurationProperties class to retrieve the config values cleanly.

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("heygen")
data class HeyGenProperties(
    val baseUrl: String,
    val apiKey: String,
    val webhookSecret: String,
)
Enter fullscreen mode Exit fullscreen mode

Configuring the REST client

To send requests to the HeyGen API, we need to create a REST client. In my project, I have a separate @Configuration class called BeanConfig, where I register all of my beans

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.web.client.RestClient
import org.stitas.dovkal.properties.HeyGenProperties
import tools.jackson.module.kotlin.KotlinModule

@Configuration
class BeanConfig {

    @Bean
    fun heyGenRestClient(
        builder: RestClient.Builder,
        heyGenProperties: HeyGenProperties,
    ): RestClient {
        return builder
            .baseUrl(heyGenProperties.baseUrl)
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("x-api-key", heyGenProperties.apiKey)
            .build()
    }

    @Bean
    fun kotlinModule(): KotlinModule {
        return KotlinModule.Builder().build()
    }
}
Enter fullscreen mode Exit fullscreen mode

Request and response DTOs

Video generation DTOs

I used these DTOs to send video generation requests and receive the videoId from HeyGen as a response. The video generation process happens asynchronously, so at first, we only get the ID of the video.

import com.fasterxml.jackson.annotation.JsonProperty
import org.stitas.dovkal.enums.SantaType

data class VideoGenerationRequestDto(
    val type: String = "avatar",
    @JsonProperty("avatar_id")
    val avatarId: String,
    @JsonProperty("voice_id")
    val voiceId: String = SantaType.VOICE_ID,
    val script: String,
    val title: String = "Kalėdų senelio video sveikinimas",
    val resolution: String = "1080p",
    @JsonProperty("aspect_ratio")
    val aspectRatio: String = "16:9",
    @JsonProperty("voice_settings")
    val voiceGenerationSettingsDto: VoiceGenerationSettingsDto
)

data class VoiceGenerationSettingsDto(
    val speed: Int = 1,
    val pitch: Int = 0,
    val volume: Int = 1,
    val locale: String,
)

data class VideoGenerationResponseDto(
    val data: VideoGenerationDataDto
)

data class VideoGenerationDataDto(
    @JsonProperty("video_id")
    val videoId: String
)
Enter fullscreen mode Exit fullscreen mode

Video retrieval DTO

I used the following DTO to get data about the video by its videoId after it finished generating. In my case, I only needed the videoUrl, but HeyGen returns more data that you can also accept if needed.

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class VideoDataResponseDto(
    val data: VideoDataDto
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class VideoDataDto(
    @JsonProperty("video_url")
    val videoUrl: String,
)
Enter fullscreen mode Exit fullscreen mode

Webhook DTOs

Lastly, these are the DTOs I used for receiving webhook events.

First, we receive the payload as a string and try to convert it to HeyGenWebhookEnvelopeDto. Later, we convert the eventData field to the AvatarVideoEventDataDto class. We do it this way because we first need the full eventData object to verify the signature.

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import tools.jackson.databind.JsonNode

@JsonIgnoreProperties(ignoreUnknown = true)
data class HeyGenWebhookEnvelopeDto(

    @JsonProperty("event_type")
    val eventType: String,

    @JsonProperty("event_data")
    val eventData: JsonNode? = null
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class AvatarVideoEventDataDto(
    @JsonProperty("video_id")
    val videoId: String
)
Enter fullscreen mode Exit fullscreen mode

Implementing the HeyGen API client

To communicate with HeyGen, we need to fill in the DTOs we created with data and send them using our configured REST client.

This service does exactly that. I created two public methods.

Sending a video generation request

fun sendVideoGenerationRequest(santaType: SantaType, message: String, locale: String): String {
    log.info("Sending video generation request to HeyGen for santaType={}", santaType.name)
    val requestBody = VideoGenerationRequestDto(
        avatarId = santaType.avatarId,
        script = message,
        voiceGenerationSettingsDto = VoiceGenerationSettingsDto(locale = locale)
    )

    val response = heyGenRestClient.post()
        .uri("/v3/videos")
        .body(requestBody)
        .retrieve()
        .onStatus(HttpStatusCode::isError) { request, response ->
            val responseBody = getResponseBody(response)

            log.error(
                "HeyGen video generation request failed. method={}, uri={}, status={}, responseBody={}",
                request.method,
                request.uri,
                response.statusCode,
                responseBody
            )

            throw HeyGenIntegrationException("HeyGen video generation request failed and responded with status ${response.statusCode}")
        }
        .body(VideoGenerationResponseDto::class.java) ?: run {
            log.error("HeyGen video generation response returned empty body")
            throw HeyGenIntegrationException("HeyGen video generation response returned empty body")
        }

    log.info("Video generation request to HeyGen sent successfully videoId={}", response.data.videoId)
    return response.data.videoId
}
Enter fullscreen mode Exit fullscreen mode

Here, santaType is a custom enum from my app that holds some business logic. In this method, I use the avatarId property of the enum, which is a hardcoded ID value of an avatar I created in HeyGen through their UI.

Getting the generated video URL

fun getVideoUrl(videoId: String): String {
    val response = heyGenRestClient.get()
        .uri("/v3/videos/${videoId}")
        .retrieve()
        .onStatus(HttpStatusCode::isError) { request, response ->
            val responseBody = getResponseBody(response)

            log.error(
                "HeyGen get video data request failed. method={}, uri={}, status={}, responseBody={}",
                request.method,
                request.uri,
                response.statusCode,
                responseBody
            )

            throw HeyGenIntegrationException("HeyGen get video data request failed and responded with status ${response.statusCode}")
        }
        .body(VideoDataResponseDto::class.java) ?: run {
            log.error("HeyGen get video data response returned empty body")
            throw HeyGenIntegrationException("HeyGen get video data response returned empty body")
        }

    return response.data.videoUrl
}
Enter fullscreen mode Exit fullscreen mode

It is very convenient that HeyGen stores the videos on their side for a long time. This way, I do not need to create or pay for an S3 storage service, and I can just get the video URL directly.

Helper methods

Here are the helper methods used by the API client:

private fun getResponseBody(response: ClientHttpResponse): String {
    return runCatching {
        StreamUtils.copyToString(response.body, StandardCharsets.UTF_8)
    }.getOrDefault("<could not read response body>")
}
Enter fullscreen mode Exit fullscreen mode
companion object {
    private val log = LoggerFactory.getLogger(HeyGenApiClient::class.java)
}
Enter fullscreen mode Exit fullscreen mode

I also use a custom HeyGenIntegrationException that I built for my app, but I will not go deep into that here. For simplicity, you can use something like IllegalStateException or handle the error in a different way.

Later, I use these methods in a HeyGenService class, following the Controller -> Service -> Repository pattern. In this case, the API client replaces the repository part. I only add additional business logic there, so I will not share that part here.

Creating a webhook for HeyGen video generation events

tired programmer sitting in front of computer with head put on the table

Oh boy... This part of the implementation got me pulling my hair out. I honestly thought I was losing my mind. Keep reading to find out why.

Creating the webhook endpoint

First, we need an endpoint to receive the events, so we need to create a controller.

import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.stitas.dovkal.integrations.heygen.HeyGenService

@RestController
@RequestMapping("/api/v1/video")
class VideoController(
    private val heyGenService: HeyGenService,
) {

    @PostMapping("/hook")
    fun heyGenWebhook(
        @RequestBody payload: String,
        @RequestHeader headers: HttpHeaders,
    ): ResponseEntity<Void> {
        // Many cases for safety as docs are misleading
        val heyGenSignature =
            headers.getFirst("Heygen-Signature")
                ?: headers.getFirst("X-Heygen-Signature")
                ?: headers.getFirst("signature")

        heyGenService.handleWebhook(
            payload = payload,
            heyGenSignature = heyGenSignature,
        )

        return ResponseEntity.ok().build()
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, you might be asking me, "Why are you checking three different names for the same header? Doesn't HeyGen provide the header name in the documentation?"

Yeah, in their documentation, it is written that the header name is Heygen-Signature, but after hours of debugging and telling myself that I'm not crazy, it turned out that the header name was actually just signature.

So, in my implementation, I decided not to trust them anymore and left some fallbacks in case they decide to change the header name again.

Handling the webhook

Next, we need to handle the webhook. For this, I created the following method.

fun handleWebhook(
    payload: String,
    heyGenSignature: String?,
) {
    if (heyGenSignature.isNullOrBlank()) {
        log.error("HeyGen webhook rejected because signature header is missing")
        throw HeyGenIntegrationException(message = "Missing HeyGen webhook signature", httpStatus = HttpStatus.UNAUTHORIZED)
    }

    validateSignature(payload, heyGenSignature)
    log.info("HeyGen webhook signature verified successfully")

    val envelope = objectMapper.readValue(payload, HeyGenWebhookEnvelopeDto::class.java)

    when (envelope.eventType) {
        "avatar_video.success" -> {
            val order = processOrderBasedOnEvent(envelope, OrderStatus.DONE) ?: return
            log.info("HeyGen avatar video completed successfully. orderId={}", order.id)
            emailService.sendVideoReadyEmail(order.email, order.id)
        }
        "avatar_video.fail" -> {
            val order = processOrderBasedOnEvent(envelope, OrderStatus.ERROR) ?: return
            log.error("HeyGen avatar video generation failed. orderId={}, event_data={}", order.id, envelope.eventData)
        }
        else -> return
    }
}
Enter fullscreen mode Exit fullscreen mode

The method first validates the payload with the provided signature and then processes the event based on its type.

Validating the HeyGen webhook signature

private fun validateSignature(
    payload: String,
    heyGenSignature: String,
) {
    val expectedSignature = calculateHmacSha256Hex(heyGenProperties.webhookSecret, payload)

    val providedSignatureBytes = hexToBytes(heyGenSignature)
    val expectedSignatureBytes = hexToBytes(expectedSignature)

    if (!MessageDigest.isEqual(providedSignatureBytes, expectedSignatureBytes)) {
        log.error("HeyGen webhook rejected because signature verification failed")
        throw HeyGenIntegrationException(message = "Invalid HeyGen webhook signature", httpStatus = HttpStatus.UNAUTHORIZED)
    }
}

private fun calculateHmacSha256Hex(secret: String, payload: String): String {
    val secretKey = SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(secretKey)

    return mac.doFinal(payload.toByteArray(StandardCharsets.UTF_8))
        .joinToString(separator = "") { byte -> "%02x".format(byte) }
}

private fun hexToBytes(hex: String): ByteArray {
    if (hex.length % 2 != 0) {
        throw HeyGenIntegrationException(message = "Invalid HeyGen signature format",  httpStatus = HttpStatus.BAD_REQUEST)
    }

    return try {
        hex.chunked(2)
            .map { it.toInt(16).toByte() }
            .toByteArray()
    } catch (e: Exception) {
        throw HeyGenIntegrationException("Invalid HeyGen signature format", e, HttpStatus.BAD_REQUEST)
    }
}
Enter fullscreen mode Exit fullscreen mode

I usually only use AI for UI work and skip it for the backend because I do not trust it enough there. But I think this is a good example of where AI shines.

Knowing or finding all of these cryptography methods manually would take ages. AI helped me put this together in a few iterations without major problems.

Processing the webhook event

Here, I only convert eventData to my custom DTO, validate the event in the database, and check whether it is a duplicate. It is an edge case, but better safe than sorry. The rest is just business logic.

private fun processOrderBasedOnEvent(eventEnvelope: HeyGenWebhookEnvelopeDto, orderStatus: OrderStatus): Order? {
    log.info("Processing order from webhook event={}", eventEnvelope.eventType)

    val eventData = eventEnvelope.eventData ?: run {
        log.error("HeyGen webhook responded with missing event_data")
        throw HeyGenIntegrationException(message = "Missing HeyGen event_data", httpStatus = HttpStatus.BAD_REQUEST)
    }

    val data = objectMapper.treeToValue(eventData, AvatarVideoEventDataDto::class.java)

    val order = orderService.findByHeyGenVideoId(data.videoId) ?: run {
        log.error("Video ID received from HeyGen webhook not found in DB. videoId={}", data.videoId)
        throw HeyGenIntegrationException(message = "Video ID from webhook not found", httpStatus = HttpStatus.BAD_REQUEST)
    }

    if (order.status != OrderStatus.PROCESSING) {
        log.warn("Duplicate HeyGen webhook event received for videoId={}, event_data={}", data.videoId, eventData)
        return null
    }

    orderService.changeOrderStatus(order, orderStatus)
    orderService.save(order)
    return order
}
Enter fullscreen mode Exit fullscreen mode

Testing the HeyGen webhook

HeyGen does not provide a testing environment, unless you count testing in production :D. So, you have to send requests to your webhook manually.

For this, I created curl commands with mock events of type avatar_video.success and avatar_video.fail.

avatar_video.success

payload='{"event_type":"avatar_video.success","event_data":{"video_id":"videoId"}}'

signature=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "secret" -hex | sed 's/^.* //')

curl -i -X POST "http://localhost:8080/api/v1/video/hook" \
  -H "Content-Type: application/json" \
  -H "Heygen-Signature: $signature" \
  --data "$payload"
Enter fullscreen mode Exit fullscreen mode

avatar_video.fail

payload='{"event_type":"avatar_video.fail","event_data":{"video_id":"videoId","error":{"code":"mock_failure","message":"Mock HeyGen video generation failed"}}}'

signature=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "secret" -hex | sed 's/^.* //')

curl -i -X POST "http://localhost:8080/api/v1/video/hook" \
  -H "Content-Type: application/json" \
  -H "signature: $signature" \
  --data "$payload"
Enter fullscreen mode Exit fullscreen mode

Final notes

This integration definitely cost me some blood, sweat, and tears, especially the webhook signature issue. Hopefully documenting everything here saves someone else a few hours of debugging.

If you found this article useful, consider subscribing to my blog at stitas.dev. I'll be sharing more practical Spring Boot, Kotlin, and API integration tutorials in the future.

Top comments (0)