Spring Boot endpoint for Sume webhooks: take the body as byte[]
Take @RequestBody byte[] so Spring never re-encodes the JSON, feed timestamp, a dot and the bytes to Mac HmacSHA256, and compare with MessageDigest.isEqual.

In Spring Boot, declare the webhook parameter as @RequestBody byte[] body, not a DTO and not a String. A DTO has already been parsed and cannot be re-serialized to the signed bytes, and a String leaves a charset decision to the message converter. With the raw bytes you feed timestamp + "." and then the body into a Mac for HmacSHA256, and compare the result with MessageDigest.isEqual.
Sume signs the raw JSON body with HMAC-SHA256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>; during a secret rotation the header carries one sume-v1= entry per live secret, comma-separated, newest first.
What does the endpoint look like?
Imports are the usual ones (javax.crypto, java.security.MessageDigest, java.util.HexFormat, Spring's ResponseEntity).
@RestController
class SumeWebhook {
private final String secret =
System.getenv().getOrDefault("SUME_COM_WEBHOOK_SIGNING_SECRET", "");
@PostMapping("/hooks/sume")
ResponseEntity<Void> receive(@RequestBody byte[] body,
@RequestHeader("x-sume-webhook-timestamp") String ts,
@RequestHeader("x-sume-webhook-signature") String sig) throws Exception {
if (secret.isEmpty()) return ResponseEntity.status(500).build();
long t = Long.parseLong(ts);
if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300)
return ResponseEntity.status(401).build();
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] want = ("sume-v1=" + HexFormat.of().formatHex(mac.doFinal(body)))
.getBytes(StandardCharsets.UTF_8);
for (String entry : sig.split(",")) {
if (MessageDigest.isEqual(want, entry.trim().getBytes(StandardCharsets.UTF_8)))
return ResponseEntity.noContent().build();
}
return ResponseEntity.status(401).build();
}
}Why bytes instead of a String?
The signature is over bytes. Decoding to a String and re-encoding can change them if a converter picks a different charset, and the failure looks like a wrong secret. Working from byte[] removes the question. Parse the JSON with Jackson only after the check passes, and use ObjectMapper.readTree so you can branch on event without a class per event.
What does the receiver have to get right?
| Sume rule | Spring consequence |
|---|---|
| Any 2xx is success | Return 204 via ResponseEntity.noContent() |
| 10 s timeout per attempt | Hand work to an executor or a queue; do not call back into Sume inline |
| Up to 10 attempts, then exhausted | Persist first, dedupe on job_id or request_id |
| 3xx is a failed attempt | Do not redirect http to https or trailing-slash variants on this path |
| Header may hold two signatures | Loop over sig.split(",") |
What commonly goes wrong?
- A
Filteror@ControllerAdvicethat logs request bodies by wrapping the request can consume the stream before your controller sees it. Long.parseLongon a missing header throws; the@RequestHeaderbinding returns400, which is retried like any non-2xx. Decide whether that is what you want.- Redirects from a load balancer count as failed attempts, because Sume does not follow them.
- A Spring Security CSRF filter will reject a signed POST with no token; exempt this path.
Where do you go next?
Run the receiver behind a tunnel, press Send test on the Webhooks tab, then submit one small job with mode: "webhook" and confirm the job.completed body arrives and verifies.
Sources
Related posts
More in Developers
- Streamlit image generator app with the Sume Images API (30 lines)
A 30-line Streamlit app: pick a Sume image model from the live catalog, type a prompt, show the result and the billed cost. Handles 200 and 202 responses.
- Sume STT metadata: tag a transcript job with your own ids
The metadata field on a Sume STT request is stored with the job and is not sent to the speech provider. Use it to tie transcripts to your records.
- Sume STT sentence segmentation fails closed when no words are timed
If the speech provider returns no timed words, a Sume STT request with segmentation returns a typed error instead of guessed sentences. Plan for it.
- Test a Sume STT webhook locally: webhook_url must be public HTTPS
Sume rejects localhost, private-network and non-HTTPS webhook_url values. Put a tunnel in front of your dev server, or poll while you build.
Written by Sume