Skip to main content

highlevel_api/
webhook.rs

1/// Webhook signature verification for GHL Marketplace app webhooks.
2///
3/// GHL sends a `X-Signature` header with an HMAC-SHA256 hex digest of the
4/// raw request body, keyed by the app's webhook secret (shared secret from
5/// the Marketplace app settings).
6///
7/// # Example
8///
9/// ```no_run
10/// use highlevel_api::webhook::verify_signature;
11///
12/// # let body = b"{}";
13/// # let signature = "abc123";
14/// # let secret = "your-webhook-secret";
15/// let valid = verify_signature(body, signature, secret);
16/// ```
17use hmac::{Hmac, Mac};
18use sha2::Sha256;
19
20type HmacSha256 = Hmac<Sha256>;
21
22/// Verify an HMAC-SHA256 webhook signature using constant-time comparison.
23///
24/// * `payload` - raw request body bytes
25/// * `signature_header` - value of the `X-Signature` header (hex-encoded HMAC)
26/// * `secret` - shared secret from GHL Marketplace app settings
27pub fn verify_signature(payload: &[u8], signature_header: &str, secret: &str) -> bool {
28    let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
29        Ok(m) => m,
30        Err(_) => return false,
31    };
32    mac.update(payload);
33    let expected = mac.finalize().into_bytes();
34    let Ok(provided) = hex::decode(signature_header) else {
35        return false;
36    };
37    // Constant-time comparison
38    expected.len() == provided.len()
39        && expected
40            .iter()
41            .zip(provided.iter())
42            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
43            == 0
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn valid_signature_returns_true() {
52        let secret = "test-secret";
53        let payload = b"{\"event\":\"test\"}";
54
55        // Generate the expected signature
56        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
57        mac.update(payload);
58        let sig = hex::encode(mac.finalize().into_bytes());
59
60        assert!(verify_signature(payload, &sig, secret));
61    }
62
63    #[test]
64    fn invalid_signature_returns_false() {
65        assert!(!verify_signature(b"payload", "invalidhex", "secret"));
66        assert!(!verify_signature(b"payload", "deadbeef", "secret"));
67    }
68
69    #[test]
70    fn empty_payload() {
71        let secret = "s";
72        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
73        mac.update(b"");
74        let sig = hex::encode(mac.finalize().into_bytes());
75        assert!(verify_signature(b"", &sig, secret));
76    }
77}