Quarkus REST endpoint for Sume webhooks: @HeaderParam and byte[]
A Quarkus REST resource that takes the Sume webhook body as byte[], reads the signature headers with @HeaderParam, and answers 204 or 401.

In Quarkus, a Sume webhook receiver is a REST resource with a @POST method that takes the request body as byte[] and the two signature headers as @HeaderParam arguments. Recompute HMAC SHA-256 over <timestamp>.<raw_body> with your signing secret, compare it with every sume-v1= entry in constant time, and return Response.noContent() for a good delivery or 401 for a bad one.
Taking byte[] rather than a parsed entity matters: the signature covers the exact bytes Sume sent, and a JSON round trip through a mapper can change spacing or key order.
How do I read the headers in Quarkus REST?
The Quarkus REST guide says you can use the Jakarta REST annotations such as @HeaderParam for parameters, as long as you give the parameter name. Header names are case-insensitive over HTTP, so x-sume-webhook-timestamp and x-sume-webhook-signature work as written in Sume's docs.
A missing header arrives as null. The verifier below treats null, an empty secret, a non-numeric timestamp and a timestamp more than five minutes from now as a failure, so every malformed request ends in the same 401 path.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class Sume {
static boolean verify(String secret, String ts, String sigs, byte[] body) {
if (secret == null || secret.isEmpty() || ts == null || sigs == null) return false;
try {
long t = Long.parseLong(ts);
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"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] want = ("sume-v1=" + HexFormat.of().formatHex(mac.doFinal(body)))
.getBytes(StandardCharsets.UTF_8);
boolean ok = false;
for (String e : sigs.split(",")) {
ok |= MessageDigest.isEqual(e.trim().getBytes(StandardCharsets.UTF_8), want);
}
return ok;
} catch (Exception e) { return false; }
}
}What does the resource look like?
The resource reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name Sume's docs use for the dashboard secret, and keeps all work after the check out of the request thread's critical path. Add your own persistence where the comment sits.
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@Path("/hooks/sume")
public class SumeHook {
@POST
public Response receive(byte[] body,
@HeaderParam("x-sume-webhook-timestamp") String ts,
@HeaderParam("x-sume-webhook-signature") String sig) {
String secret = System.getenv("SUME_COM_WEBHOOK_SIGNING_SECRET");
if (!Sume.verify(secret, ts, sig, body)) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
// insert the event keyed by job_id or request_id, then acknowledge
return Response.noContent().build();
}
}Which Sume delivery details change the code?
Three facts shape a Quarkus receiver: the 10-second attempt timeout, the shared secret across both webhook families, and the two different dedupe keys.
| Fact | Job webhooks | Run webhooks |
|---|---|---|
| Events | job.completed, job.failed, job.canceled | action.run.terminal, format.run.terminal, agent.run.terminal |
| Signed string | <timestamp>.<raw_body>, HMAC SHA-256 | Same scheme, same secret |
| Headers | x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex> | Same |
| Attempts and timeout | Up to 10, fixed spacing (30s default), 10s each | Up to 10, exponential from 30s with jitter capped at 1h, 10s each |
| Dedupe key | job_id | request_id |
What should the handler do after the check passes?
Persist the event first, then return 204. Sume treats any 2xx as delivered and gives each attempt 10 seconds, so a handler that does real work inline can time out and trigger a retry of an event you already accepted. Write the body to your queue or table, answer, and process after the response.
Dedupe on the id the docs name: job_id for job events and request_id for run events. A retry and a manual redeliver carry the same ids with a fresh timestamp and signature, so a unique constraint on that id makes the second copy harmless.
Keep polling as the backup. After ten refused attempts the job or run is still finished, only the notification failed, and Sume's docs say to keep the status_url poll available for events that never arrive.
Sources
Related posts
More in Developers
- Sanic webhook route for Sume: request.body, HMAC, empty() 204
Verify a Sume webhook in Sanic with request.body bytes and hmac.compare_digest, then return an empty 204 so the delivery counts as done.
- What did one script_run cost? Sume script_runs and its children
Sume /v1/usage lists script_runs with rows and money per call, and counts script_run_children in includes. Read the cost of one script_run to the cent.
- Sonic 3.6 vs Eleven v4 Turbo: one script, one Python A/B run on Sume
Send the same text to sonic-3.6 and eleven-v4-turbo through the TTS Router with one Python script, poll both jobs, and compare the files by ear.
- Sume 400 invalid_request: read details.errors[].path to find the field
A Sume 400 invalid_request lists each bad field in details.errors with path, loc, message and type. Read them in Python instead of guessing from the message.
Written by Sume