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.

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.
| Check | Fails when |
|---|---|
| secret | null or empty |
| timestamp | not a number, or more than 300 seconds from now |
| signature | no 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
- Sume webhook retry window: 4.5 minutes for jobs, 3 hours for runs
Job webhooks retry 10 times at a fixed 30 s, about 4.5 minutes. Run webhooks back off for about 3 hours. How long a deploy can take your receiver down.
- GET /v1/jobs thread_id filter: why a teammate's job still 404s
The thread_id filter on the Sume jobs list narrows results and never widens what an API key can read. Teammates' jobs stay 404, and only the creator can cancel.
- jobs_result batch: read a wave when some jobs are still running
Sume's batch jobs_result returns one entry per id in request order. Read ok per entry, treat job_not_completed as running, and re-read only failed_job_ids.
- A kill switch for paid Sume submits: stop new jobs, cancel queued
Add an off switch to code that spends on the Sume API: check a flag before each submit, then cancel queued jobs; a 409 job_generation_already_started will bill.
Written by Sume