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.

5 min readSume
All posts

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.

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