Verify a Sume avatar video webhook in Java with MessageDigest.isEqual
Java verifier for Sume avatar video webhooks: HmacSHA256 over timestamp.body, MessageDigest.isEqual for each sume-v1 entry, 300 s window, empty secret refused.
The short answer
Use Mac.getInstance("HmacSHA256") over <timestamp>.<raw body>, build sume-v1= plus lowercase hex with HexFormat, and compare with MessageDigest.isEqual against each header entry. The Java SE 21 documentation (read 2026-10-04) says its calculation time depends only on the length of the first argument.
The Java verifier
In Spring, take the body as @RequestBody String raw or read the servlet input stream yourself, not as a mapped object. HexFormat needs Java 17 or later; on older runtimes build the hex with a small loop instead.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class SumeWebhook {
public static boolean verify(String secret, String ts, String header, String rawBody)
throws Exception {
if (secret == null || secret.isEmpty()) return false;
long t;
try { t = Long.parseLong(ts); } catch (NumberFormatException e) { return false; }
if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal((ts + "." + rawBody).getBytes(StandardCharsets.UTF_8));
byte[] want = ("sume-v1=" + HexFormat.of().formatHex(sig)).getBytes(StandardCharsets.UTF_8);
boolean ok = false;
for (String entry : header.split(",")) {
byte[] got = entry.trim().getBytes(StandardCharsets.UTF_8);
if (MessageDigest.isEqual(want, got)) ok = true;
}
return ok;
}
}What Sume sends
Sume signs the raw JSON body of every terminal job event. An avatar talking video submitted with mode: "webhook" and a public HTTPS webhook_url ends in one of three events, and your endpoint must verify the signature before it trusts the payload. The contract from the webhook docs:
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled (terminal only) |
| Timestamp header | x-sume-webhook-timestamp |
| Signature header | x-sume-webhook-signature: sume-v1=<hex>, comma-separated during rotation, newest first |
| Signed string | <timestamp>.<raw_body>, HMAC-SHA256, hex |
| Replay window | 300 seconds by default |
| Secret | SUME_COM_WEBHOOK_SIGNING_SECRET, from the dashboard Webhooks tab |
| Delivery | Up to 10 attempts, 30 s apart, 10 s timeout per attempt |
Why this Java code is shaped this way
Because the timing depends only on the length of the first argument, the code passes the expected signature first and the received entry second. That way the time reflects a value you control. Each entry is trimmed and checked, and the loop never breaks early.
| Fact | Detail |
|---|---|
| Method | MessageDigest.isEqual(byte[] digesta, byte[] digestb) |
| Bytes examined | All bytes in digesta are examined |
| Timing | Depends only on the length of digesta |
| Not leaked | Does not depend on digestb length or contents of either |
Operating it
Verify against the raw bytes you received, never a parsed and re-serialized object, because any change in spacing breaks the HMAC. Return a 2xx only after you have stored the event durably, and use job_id as the idempotency key, since a delivery can arrive more than once. If your endpoint was down, POST /v1/jobs/{job_id}/webhook/redeliver (scope jobs:write) re-sends the real terminal event with a fresh timestamp and signature, and POST /v1/webhooks/test-deliveries sends a signed dummy webhook.test payload to try your route first. Keep polling the job status as a fallback, because ten refused attempts end automatic delivery.
The code refuses an empty secret and a stale or non-numeric timestamp, checks every sume-v1= entry in the header so a rotation never rejects a good delivery, and does not stop at the first match. A Standard avatar clip costs $0.184 per second, so a rejected webhook is not a rejected video: the render is already paid for and fetchable from the job result. For the same check in other languages, see the Node and Python versions, and the queue limits by plan if you submit many clips at once.
Sources
Related posts
More in Sume Avatar 1.0
- Wav2Lip is research-only: what to use for commercial lip sync
Wav2Lip's README limits results to research, academic or personal use. For client or ad work, here is the still-plus-audio lip sync Sume offers, with costs.
- Alt text for an avatar video poster: WCAG 1.1.1 in practice
A poster image from a Sume avatar preview still needs a text alternative under WCAG 1.1.1, and the video needs descriptive identification. What to write.
- Silent avatar video clip: what WCAG 1.2.1 asks for
A silent beat or silent clip in an avatar video is prerecorded video-only content. Here is what WCAG 1.2.1 wants and how to supply it from Sume.
- WCAG 1.2.3 for an AI avatar video: is the script enough?
A talking-head avatar video already has its words in a script. Here is when that text meets WCAG 1.2.3 and what to add if the picture carries more.
Written by Sume