JDK 27 Java webhook verifier for Sume (HmacSHA256)

Verify a Sume sume-v1 webhook with javax.crypto and MessageDigest.isEqual on JDK 27: raw body bytes, five-minute window, empty secret refused.

5 min readSume
All posts

Short answer

Compute HmacSHA256 over <timestamp>.<raw body bytes> with javax.crypto.Mac, prefix the hex with sume-v1=, and compare to each comma-separated entry of x-sume-webhook-signature with MessageDigest.isEqual. Refuse an empty secret and a timestamp more than 300 seconds off. The scheme comes from Sume's webhooks page.

JDK 27 reached General Availability on 15 September 2026 per the project page, and its feature list includes JEP 527, Post-Quantum Hybrid Key Exchange for TLS 1.3 (read 2026-10-03). That is a transport feature for your endpoint's TLS; the webhook body is still authenticated by the HMAC below.

Why bytes, not a String

Sume signs the raw JSON body. A framework that decodes the body to a String and re-encodes it can alter whitespace or escapes, which changes the signature input. Keep a byte[] from the servlet or HttpExchange input stream and feed it to Mac.update unchanged.

During a signing-secret rotation the header carries one entry per live secret, so the loop must accept any matching entry and must not stop at the first mismatch.

Checks the verifier makes, in order (as of 2026-10-03)
CheckFails when
secretnull or empty
timestampnot a number, or more than 300 seconds from now
signatureno sume-v1= entry equals the recomputed value

The verifier

Run it with java Verify.java. The main method signs a body with a throwaway secret and prints the three results, so you can see the refusal paths without a live delivery.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
import static java.nio.charset.StandardCharsets.UTF_8;

public class Verify {
  static String sign(String secret, String ts, byte[] body) throws Exception {
    var mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
    mac.update((ts + ".").getBytes(UTF_8));
    return "sume-v1=" + HexFormat.of().formatHex(mac.doFinal(body));
  }
  static boolean verify(String secret, String ts, String header, byte[] body) throws Exception {
    if (secret == null || secret.isEmpty() || !ts.matches("\\d{1,12}")) return false;
    long age = Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts));
    if (age > 300) return false;
    var want = sign(secret, ts, body).getBytes(UTF_8);
    boolean ok = false;
    for (var e : header.split(",")) ok |= MessageDigest.isEqual(e.trim().getBytes(UTF_8), want);
    return ok;
  }
  public static void main(String[] a) throws Exception {
    var body = "{\"event\":\"job.completed\"}".getBytes(UTF_8);
    var ts = String.valueOf(System.currentTimeMillis() / 1000);
    var h = "sume-v1=old," + sign("s3cret", ts, body);
    System.out.println(verify("s3cret", ts, h, body) + " " + verify("", ts, h, body) + " " + verify("s3cret", "1", h, body));
  }
}

Where the secret comes from

Read the signing secret on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with any API key that carries account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker signs with. Job webhooks and run webhooks share that one secret.

If verification fails, compare x-sume-webhook-secret-fingerprint with the fingerprint shown beside the secret in the dashboard. Neither side has to send the secret itself.

What Sume does and does not do

Sume sends terminal events only, retries up to 10 attempts at a fixed 30 second spacing with a 10 second timeout per attempt, and offers Send test (a dummy signed webhook.test body) and Redeliver (the real terminal event with a fresh signature) as different actions. It does not send progress events and does not follow redirects on run webhooks.

Dedupe on job_id; the receiver must be safe to call twice.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume