Vert.x Web: verify a Sume webhook after BodyHandler reads the body
Install BodyHandler before your Vert.x route, read the body as bytes, recompute HMAC SHA-256 over timestamp.body, and return 204 to a Sume delivery.

To receive a Sume webhook in Vert.x Web, put BodyHandler on the webhook route, read the body with ctx.body().buffer() so you keep the exact bytes, and recompute HMAC SHA-256 over <timestamp>.<raw_body> with your signing secret. Compare the result to each sume-v1= entry in the x-sume-webhook-signature header, reject on a mismatch, and answer 204 once the event is stored.
Vert.x gives you no body until a handler asks for it. This post covers the order of handlers, the verifier, and the response codes that keep Sume from retrying a delivery you already accepted.
Why does the route need BodyHandler first?
The Vert.x Web documentation says a body handler must be on a matching route for any request that needs the body, and that it should be installed as early as possible because it resumes the request and installs its own handlers to consume the body. Without it, ctx.body() has nothing in it and your verifier signs an empty string, which never matches.
The same page says the default body limit is 10 megabytes and that an oversized request gets 413. Sume's run webhooks never exceed 1 MiB inline: a larger receipt is sent with payload: null and a result_url to fetch instead. A limit of 1,048,576 bytes therefore fits Sume traffic and shrinks the surface for everyone else who finds your URL.
What does the verifier look like?
Take the secret from the environment, refuse an empty one, check the timestamp against a five-minute window, and compare every sume-v1= entry in constant time. During a signing-secret rotation the header carries one entry per live secret, newest first, separated by commas, so a loop that accepts any match is required.
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 {
public static boolean verify(String secret, String ts, String header, byte[] body) {
if (secret == null || secret.isEmpty() || ts == null || header == 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));
String want = "sume-v1=" + HexFormat.of().formatHex(mac.doFinal(body));
boolean ok = false;
for (String e : header.split(",")) {
ok |= MessageDigest.isEqual(e.trim().getBytes(StandardCharsets.UTF_8),
want.getBytes(StandardCharsets.UTF_8));
}
return ok;
} catch (Exception e) {
return false;
}
}
}How do I wire it into the router?
Register BodyHandler on the path, then the POST handler. Return 401 for a bad signature and 204 when the event is stored. MessageDigest.isEqual is constant time for equal-length inputs, and the loop never exits early, so timing does not reveal which entry matched.
import io.vertx.core.Vertx;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler;
public class Main {
public static void main(String[] args) {
Vertx vertx = Vertx.vertx();
Router router = Router.router(vertx);
router.route("/hooks/sume").handler(BodyHandler.create().setBodyLimit(1_048_576));
router.post("/hooks/sume").handler(ctx -> {
var buf = ctx.body().buffer();
byte[] raw = buf == null ? new byte[0] : buf.getBytes();
var req = ctx.request();
boolean ok = Sume.verify(System.getenv("SUME_COM_WEBHOOK_SIGNING_SECRET"),
req.getHeader("x-sume-webhook-timestamp"),
req.getHeader("x-sume-webhook-signature"), raw);
if (!ok) { ctx.response().setStatusCode(401).end(); return; }
// store the event keyed by job_id or request_id, then acknowledge
ctx.response().setStatusCode(204).end();
});
vertx.createHttpServer().requestHandler(router).listen(8080);
}
}What does Sume expect back?
The table compares the two Sume webhook families. One verifier covers both because the signature scheme and the secret are shared.
| 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
- What one script_run call cost: the usage script_runs array
Sume's usage summary lists each script_run call with its rows, debited, held and refunded micros, so a fan-out of TTS or image jobs has one price.
- WireMock scenarios: fake a Sume job going queued to completed
Use one WireMock scenario per job so GET /v1/jobs/{id}/status answers queued, processing, then completed, and test the poll loop without spending.
- Word timestamps for an Eleven v4 voice: the router says 400, use STT
The Sume TTS Router rejects timestamps on eleven-* ids. Get word timings by running the finished narration through Sume STT for one cent a minute.
- xAI video API polling: pending, done, failed vs Sume job states
xAI's video API returns a request_id and three states. Here is how they map onto Sume's five /v1/videos statuses, with a runnable Python polling loop.
Written by Sume