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.

5 min readSume
All posts

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.

Ktor receiver choices for Sume webhooks (Sume docs, read 2026-10-04)
ChoiceReason
require(secret.isNotEmpty())An empty key still makes a digest, so fail at boot
receiveText() before any parsingThe signature covers the exact bytes Sume sent
toLongOrNull on the timestampA missing or bad header is a rejection, not a crash
abs(now - ts) <= 300Rejects replays outside the five-minute window
MessageDigest.isEqualConstant-time compare; String equals can leak position
Any entry may matchA 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 to receive<ByteArray>() if you ever see a mismatch with non-ASCII text.
  • After verifying, store job_id under a unique key before you answer. Sume retries up to 10 times, and job_id is the idempotency key.
  • Answer in under 10 seconds. That is the per-attempt timeout.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume