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.

5 min readSume
All posts

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.

Sume delivery facts, read 2026-10-11 from Webhooks and Run webhooks on docs.sume.com.
FactJob webhooksRun webhooks
Eventsjob.completed, job.failed, job.canceledaction.run.terminal, format.run.terminal, agent.run.terminal
Signed string<timestamp>.<raw_body>, HMAC SHA-256Same scheme, same secret
Headersx-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>Same
Attempts and timeoutUp to 10, fixed spacing (30s default), 10s eachUp to 10, exponential from 30s with jitter capped at 1h, 10s each
Dedupe keyjob_idrequest_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

All Developers posts

Written by Sume