Spring Boot webhook: verify an HMAC-SHA256 signature

A Spring Boot webhook takes the body as byte[], computes HMAC-SHA256 over the timestamp and raw bytes, and checks each sume-v1 entry in constant time.

6 min readSume
All posts

A Spring Boot webhook endpoint is a @RestController method mapped with @PostMapping that takes the body as @RequestBody byte[], so Spring hands you the raw bytes instead of a parsed object, and reads the signature headers with @RequestHeader. For a Sume webhook, compute HMAC-SHA256 over <timestamp>.<raw_body> with javax.crypto.Mac, accept the delivery when any sume-v1= entry matches under MessageDigest.isEqual, and answer 204 fast.

Spring facts come from the Spring Boot and Spring Framework reference docs, Java facts from the Java SE 21 API docs, all listed under Sources; Sume facts come from Run webhooks, Webhooks, and Verifying webhooks. All were read on 2026-09-27. Sume's SDK, @sume-com/sdk, is TypeScript, so a Java receiver implements the published scheme itself; the same check in other languages is in the Go, PHP and Laravel, and Python posts.

What does a Spring Boot webhook endpoint need?

Six pieces, all in the JDK and Spring MVC. Spring registers its byte-array converter on the server side by default, and that converter reads byte arrays for all media types, so a byte[] parameter gets the body exactly as sent.

From the Spring @RequestBody, message conversion, and @RequestHeader docs, the Java SE 21 Mac, MessageDigest, and HexFormat docs, and Sume's Run webhooks and Webhooks, read 2026-09-27.
StepSume ruleSpring or Java
Raw bodyVerify the raw bytes before any JSON parse@RequestBody byte[] body
Headersx-sume-webhook-timestamp, x-sume-webhook-signature@RequestHeader("x-sume-webhook-timestamp") String timestamp
MACHMAC-SHA256 over <timestamp>.<raw_body>Mac.getInstance("HmacSHA256"), an algorithm every Java platform must support
Signaturesume-v1=<hex>; during a rotation, one entry per live secret, comma-separatedSplit on commas, then HexFormat.of().parseHex(...), which reads hex digits case-insensitively
CompareConstant time; accept any matching entryMessageDigest.isEqual(expected, candidate) on every entry
Replay windowReject timestamps outside it; five minutes is a reasonable defaultCompare the parsed timestamp with the current Unix time

How do I verify the HMAC-SHA256 signature in Java?

Compute the MAC once, then check every sume-v1= entry: for 24 hours after a secret rotation Sume signs with both secrets, newest first, and a check that compares the whole header fails on every delivery in that window. Java documents that MessageDigest.isEqual examines all bytes of its first argument and takes time that depends only on that argument's length, not on the contents, so pass the expected MAC first. parseHex throws IllegalArgumentException on anything that is not hex, and HexFormat needs Java 17 or later. The class also needs Mac, SecretKeySpec, MessageDigest, StandardCharsets, and Instant.

It refuses an empty secret before any HMAC, so an unset variable can never verify a forged delivery; SecretKeySpec would otherwise throw IllegalArgumentException for an empty key.

public final class SumeSignature {
    public static boolean verify(byte[] body, String ts, String header, byte[] secret)
            throws Exception {
        long t;
        try { t = Long.parseLong(ts); } catch (NumberFormatException e) { return false; }
        long now = Instant.now().getEpochSecond();
        if (secret.length == 0 || Math.abs(now - t) > 300) return false; // 5-minute window
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
        byte[] expected = mac.doFinal(body); // the raw bytes, never re-encoded JSON
        boolean ok = false;
        for (String entry : header.split(",")) { // two entries during a rotation
            String e = entry.trim();
            if (!e.startsWith("sume-v1=")) continue;
            try {
                byte[] got = HexFormat.of().parseHex(e.substring("sume-v1=".length()));
                if (MessageDigest.isEqual(expected, got)) ok = true; // check them all
            } catch (IllegalArgumentException notHex) { /* not a match */ }
        }
        return ok;
    }
}

How do I receive the webhook in a Spring Boot controller?

Spring Boot auto-configures Spring MVC, where @RestController beans handle incoming HTTP requests. Read the signing secret on the dashboard's Webhooks tab, or from GET /v1/webhooks/signing-secret with a key carrying account:read, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET. ResponseEntity.noContent() builds the 204; ResponseEntity.status(401) builds the refusal.

@RestController
public class SumeWebhookController {
    private final byte[] secret = System.getenv()
            .getOrDefault("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
            .getBytes(StandardCharsets.UTF_8);

    @PostMapping("/hooks/sume")
    public ResponseEntity<Void> receive(
            @RequestBody byte[] body, // raw bytes, not a bound object
            @RequestHeader("x-sume-webhook-timestamp") String timestamp,
            @RequestHeader("x-sume-webhook-signature") String signature) throws Exception {
        if (!SumeSignature.verify(body, timestamp, signature, secret)) {
            return ResponseEntity.status(401).build();
        }
        recordOnce(body); // your store: parse these verified bytes, dedupe, queue work
        return ResponseEntity.noContent().build(); // 204 inside Sume's 10 s
    }
}

Why does every signature fail?

In Spring, most often because the body was bound to an object and re-serialized before the check. A parsed and re-serialized body does not verify: key order and whitespace are part of what Sume signed. Keep the byte[] parameter and parse only the bytes that passed verify. For the other causes, a different secret or a skewed clock, Sume webhook not received? walks through the checks.

What should the endpoint do after it verifies?

Record the event durably and answer: any 2xx counts, each attempt gets 10 seconds, and Sume makes up to 10 attempts. Dedupe on request_id for run webhooks and on job_id for generation-job webhooks. A receipt over 1 MiB arrives with payload: null and an error.result_url to fetch it from. Signed webhooks for Sume video runs covers retries and Redeliver.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume