Verify webhooks in Java
Check the syntic-signature header before trusting a webhook delivery. The header carries a timestamp t and one or more v1 signatures (several during secret rotation). The signature is the hex HMAC-SHA256 of t + "." + raw body, keyed with your endpoint’s signing secret. Reject the delivery if no v1 matches in constant time, or if t is more than 300 seconds away from your clock. Always hash the raw body bytes, before any JSON parsing.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
public final class SynticWebhooks {
public static boolean verify(String header, byte[] rawBody, String secret, long nowEpochSeconds) throws Exception {
String t = null;
List<String> sigs = new ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) sigs.add(kv[1]);
}
if (t == null || Math.abs(nowEpochSeconds - Long.parseLong(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[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII);
for (String s : sigs) {
if (MessageDigest.isEqual(s.getBytes(StandardCharsets.US_ASCII), expected)) return true;
}
return false;
}
}In a Spring controller, take the body as @RequestBody byte[] body so it is not re-serialised before hashing, then call SynticWebhooks.verify(request.getHeader("syntic-signature"), body, secret, Instant.now().getEpochSecond()).
Test it
Send a test event to your endpoint, or recompute a signature by hand as shown on Webhooks.