diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java
new file mode 100644
index 00000000..6aaed7ae
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java
@@ -0,0 +1,35 @@
+package com.cameleer.server.app.alerting.notify;
+
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.HexFormat;
+
+/**
+ * Computes HMAC-SHA256 webhook signatures.
+ *
+ * Output format: {@code sha256=}
+ */
+@Component
+public class HmacSigner {
+
+ /**
+ * Signs {@code body} with {@code secret} using HmacSHA256.
+ *
+ * @param secret plain-text secret (UTF-8 encoded)
+ * @param body request body bytes to sign
+ * @return {@code "sha256=" + hex(hmac)}
+ */
+ public String sign(String secret, byte[] body) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] digest = mac.doFinal(body);
+ return "sha256=" + HexFormat.of().formatHex(digest);
+ } catch (Exception e) {
+ throw new IllegalStateException("HMAC signing failed", e);
+ }
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java
new file mode 100644
index 00000000..2e69ae4e
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java
@@ -0,0 +1,55 @@
+package com.cameleer.server.app.alerting.notify;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HmacSignerTest {
+
+ private final HmacSigner signer = new HmacSigner();
+
+ /**
+ * Pre-computed:
+ * secret = "test-secret-key"
+ * body = "hello world"
+ * result = sha256=b3df71b4790eb32b24c2f0bbb20f215d82b0da5e921caa880c74acfc97cf7e5b
+ *
+ * Verified with: python3 -c "import hmac,hashlib; print('sha256='+hmac.new(b'test-secret-key',b'hello world',hashlib.sha256).hexdigest())"
+ */
+ @Test
+ void knownVector() {
+ String result = signer.sign("test-secret-key", "hello world".getBytes(StandardCharsets.UTF_8));
+ assertThat(result).isEqualTo("sha256=b3df71b4790eb32b24c2f0bbb20f215d82b0da5e921caa880c74acfc97cf7e5b");
+ }
+
+ @Test
+ void outputStartsWithSha256Prefix() {
+ String result = signer.sign("any-secret", "body".getBytes(StandardCharsets.UTF_8));
+ assertThat(result).startsWith("sha256=");
+ }
+
+ @Test
+ void outputIsLowercaseHex() {
+ String result = signer.sign("key", "data".getBytes(StandardCharsets.UTF_8));
+ // After "sha256=" every char must be a lowercase hex digit
+ String hex = result.substring("sha256=".length());
+ assertThat(hex).matches("[0-9a-f]{64}");
+ }
+
+ @Test
+ void differentSecretsProduceDifferentSignatures() {
+ byte[] body = "payload".getBytes(StandardCharsets.UTF_8);
+ String sig1 = signer.sign("secret-a", body);
+ String sig2 = signer.sign("secret-b", body);
+ assertThat(sig1).isNotEqualTo(sig2);
+ }
+
+ @Test
+ void differentBodiesProduceDifferentSignatures() {
+ String sig1 = signer.sign("secret", "body1".getBytes(StandardCharsets.UTF_8));
+ String sig2 = signer.sign("secret", "body2".getBytes(StandardCharsets.UTF_8));
+ assertThat(sig1).isNotEqualTo(sig2);
+ }
+}