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.

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.
| Step | Sume rule | Spring or Java |
|---|---|---|
| Raw body | Verify the raw bytes before any JSON parse | @RequestBody byte[] body |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature | @RequestHeader("x-sume-webhook-timestamp") String timestamp |
| MAC | HMAC-SHA256 over <timestamp>.<raw_body> | Mac.getInstance("HmacSHA256"), an algorithm every Java platform must support |
| Signature | sume-v1=<hex>; during a rotation, one entry per live secret, comma-separated | Split on commas, then HexFormat.of().parseHex(...), which reads hex digits case-insensitively |
| Compare | Constant time; accept any matching entry | MessageDigest.isEqual(expected, candidate) on every entry |
| Replay window | Reject timestamps outside it; five minutes is a reasonable default | Compare 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
- Run webhooks
- Webhooks
- Verifying webhooks
- Spring Boot: Servlet Web Applications (read 2026-09-27)
- Spring Framework: @RequestBody (read 2026-09-27)
- Spring Framework: HTTP Message Conversion (read 2026-09-27)
- Spring Framework: @RequestHeader (read 2026-09-27)
- Spring Framework: ResponseEntity (read 2026-09-27)
- Java SE 21: Mac (read 2026-09-27)
- Java SE 21: MessageDigest (read 2026-09-27)
- Java SE 21: HexFormat (read 2026-09-27)
- Java SE 21: SecretKeySpec (read 2026-09-27)
Related posts
More in Integrations
- Cline MCP remote server: add Sume's hosted MCP
Add Sume's hosted MCP server to Cline as a remote server with type streamableHttp and an API-key header, and keep paid tools out of autoApprove.
- VS Code remote MCP server: add Sume's hosted MCP in mcp.json
Add Sume's hosted MCP server to VS Code with an http entry in mcp.json, see what Sume's OAuth consent grants, and choose which tools chat can call.
- Supabase cron Edge Function: start an AI video on a schedule
Schedule a Supabase Edge Function with pg_cron and pg_net, keep the video API key in the function's secrets, and start one date-keyed run per day.
- Supabase Edge Function webhook for Sume: JWT off, HMAC on
Sume's webhook POST carries no Supabase JWT, so deploy the Edge Function with verify_jwt = false and check Sume's HMAC signature on every delivery.
Written by Sume