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.

5 min readSume
All posts

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?

Spring receiver requirements from Sume's delivery rules, read 2026-10-04
Sume ruleSpring consequence
Any 2xx is successReturn 204 via ResponseEntity.noContent()
10 s timeout per attemptHand work to an executor or a queue; do not call back into Sume inline
Up to 10 attempts, then exhaustedPersist first, dedupe on job_id or request_id
3xx is a failed attemptDo not redirect http to https or trailing-slash variants on this path
Header may hold two signaturesLoop over sig.split(",")

What commonly goes wrong?

  • A Filter or @ControllerAdvice that logs request bodies by wrapping the request can consume the stream before your controller sees it.
  • Long.parseLong on a missing header throws; the @RequestHeader binding returns 400, 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

All Developers posts

Written by Sume