Ktor receiver for Sume webhooks: receiveText, HmacSHA256, isEqual
A Ktor route that reads the raw body with receiveText, checks the sume-v1 HMAC with MessageDigest.isEqual, and refuses to start when the secret is empty.

In a Ktor post route, call call.receiveText() once to get the raw body, compute HmacSHA256 over "$timestamp.$raw" with javax.crypto.Mac, and compare it against every sume-v1= entry in x-sume-webhook-signature using MessageDigest.isEqual, which runs in constant time. Respond 401 on any failure, and refuse to start when SUME_COM_WEBHOOK_SIGNING_SECRET is empty.
The scheme is in Sume's webhook docs: the signed string is <timestamp>.<raw_body>, the header may hold comma-separated entries during a secret rotation, and you should reject timestamps outside a window of about five minutes.
The route
Parse JSON only after the check passes. A plugin such as ContentNegotiation is fine for other routes, but this route must read the text itself, as in the sample.
import io.ktor.http.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun sign(secret: String, ts: String, raw: String): String =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
doFinal("$ts.$raw".toByteArray()).joinToString("") { "%02x".format(it) }
}
fun main() {
val secret = System.getenv("SUME_COM_WEBHOOK_SIGNING_SECRET").orEmpty()
require(secret.isNotEmpty()) { "SUME_COM_WEBHOOK_SIGNING_SECRET is empty" }
embeddedServer(Netty, port = 8080) {
routing {
post("/sume") {
val raw = call.receiveText()
val ts = call.request.headers["x-sume-webhook-timestamp"]?.toLongOrNull()
val fresh = ts != null && kotlin.math.abs(System.currentTimeMillis() / 1000 - ts) <= 300
val want = "sume-v1=" + sign(secret, ts.toString(), raw)
val ok = fresh && call.request.headers["x-sume-webhook-signature"].orEmpty().split(",").any { MessageDigest.isEqual(it.trim().toByteArray(), want.toByteArray()) }
call.respond(if (ok) HttpStatusCode.NoContent else HttpStatusCode.Unauthorized)
}
}
}.start(wait = true)
}Why each line is there
Each choice in the sample closes one way a receiver goes wrong.
| Choice | Reason |
|---|---|
| require(secret.isNotEmpty()) | An empty key still makes a digest, so fail at boot |
| receiveText() before any parsing | The signature covers the exact bytes Sume sent |
| toLongOrNull on the timestamp | A missing or bad header is a rejection, not a crash |
| abs(now - ts) <= 300 | Rejects replays outside the five-minute window |
| MessageDigest.isEqual | Constant-time compare; String equals can leak position |
| Any entry may match | A rotation sends one sume-v1 entry per live secret |
Caveats
receiveText()decodes with the request charset. Sume sends JSON, which is UTF-8, so this matches the signed bytes. Switch toreceive<ByteArray>()if you ever see a mismatch with non-ASCII text.- After verifying, store
job_idunder a unique key before you answer. Sume retries up to 10 times, andjob_idis the idempotency key. - Answer in under 10 seconds. That is the per-attempt timeout.
Sources
Related posts
More in Developers
- Workers KV jurisdictions: keep Sume job records in region
Cloudflare made Workers KV jurisdictions generally available on Oct 2, 2026. Here is how to key Sume job_id records into a region-scoped namespace.
- LangGraph custom image: keep SUME_API_KEY out of it
langgraph-cli 0.4.32 adds an --image-uri flag for self-hosted custom containers. Inject SUME_API_KEY as a runtime env var; never bake it into the image.
- LinkedIn Live fails to process: use Baseline, not High or Main
LinkedIn says a Live stream that fails to process may need the Baseline H.264 profile, because High and Main add B-frames. Which a Sume render sets.
- List your failed TTS and STT jobs with the Sume jobs filters
GET /v1/jobs filters by status and type and pages 100 at a time with starting_after. A short loop to find every failed audio job.
Written by Sume