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.

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?
| Field | Value |
|---|---|
| event | job.completed, job.failed or job.canceled |
| job_id | Your dedupe key |
| request_id | The request that created the job |
| status | OK or ERROR |
| payload.artifacts | Output 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
- Label speakers without diarization: transcribe each mic track, merge
Sume STT has no speaker labels. If you record each speaker on a separate track, transcribe both tracks and merge the segments by start time. Python script.
- Laravel queued job for an AI video API: delay, redispatch, poll
A Laravel ShouldQueue job reads a Sume video job once and redispatches itself with ->delay() from next_poll_after_seconds, so no worker sleeps during a render.
- Let QA override the image model per request, with an allowlist
An internal render endpoint that honors an X-Image-Model header only for allowlisted Sume ids, so QA can test gpt-image-2.5 before the config flips. Python.
- List your transcription jobs: GET /v1/jobs by type, status, cursor
GET /v1/jobs?type=speech_to_text pages 100 jobs at a time, newest first. Join on each row's idempotency_key, not array position, to rebuild a batch.
Written by Sume