Ktor webhook route for an AI video job: receiveText and HMAC SHA-256

A Ktor route reads the raw body with call.receiveText(), checks Sume's sume-v1 HMAC over timestamp.body in Kotlin and returns 401 when the secret is empty.

4 min readSume
All posts

In Ktor, read the raw webhook body with call.receiveText() and verify it before you parse any JSON. Sume signs <timestamp>.<raw_body> with HMAC SHA-256 and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>; during a secret rotation the header holds several comma separated entries (Sume webhooks guide, read 2026-10-06). Use the route as callback_url when you submit a /v1/videos job.

The Kotlin below uses only the JDK's javax.crypto.Mac and MessageDigest.isEqual, which compares in constant time.

What does the route look like?

Empty or missing secret returns 401 so the check can never pass against an empty key. Answer quickly and hand the download to a coroutine or queue.

import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }

fun Route.sumeHook() = post("/hooks/sume") {
    val secret = System.getenv("SUME_COM_WEBHOOK_SIGNING_SECRET").orEmpty()
    val ts = call.request.headers["x-sume-webhook-timestamp"].orEmpty()
    val sig = call.request.headers["x-sume-webhook-signature"].orEmpty()
    val raw = call.receiveText()
    val age = ts.toLongOrNull()?.let { Math.abs(System.currentTimeMillis() / 1000 - it) }
    if (secret.isEmpty() || age == null || age > 300) {
        return@post call.respond(HttpStatusCode.Unauthorized)
    }
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
    val want = "sume-v1=" + hex(mac.doFinal("$ts.$raw".toByteArray()))
    val ok = sig.split(",").any {
        MessageDigest.isEqual(it.trim().toByteArray(), want.toByteArray())
    }
    if (!ok) return@post call.respond(HttpStatusCode.Unauthorized)
    call.respond(HttpStatusCode.OK)  // then enqueue by job_id
}

Which body fields should the handler read?

Fields in a Sume job webhook body, from the Sume webhooks guide (read 2026-10-06)
FieldValue
eventjob.completed, job.failed or job.canceled
job_idYour dedupe key
request_idThe request that created the job
statusOK or ERROR
payload.artifactsOutput list on success

What about duplicate deliveries?

Any sender can deliver twice. Insert the job_id into a table with a unique constraint and skip the work if the insert fails. Keep a periodic status read as a backup, because the webhook only fires on a terminal state and a lost delivery would leave a clip marked running.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume