highlevel-api 0.2.0

Unofficial Rust SDK for the GoHighLevel (HighLevel) API
Documentation
/// Webhook signature verification for GHL Marketplace app webhooks.
///
/// GHL sends a `X-Signature` header with an HMAC-SHA256 hex digest of the
/// raw request body, keyed by the app's webhook secret (shared secret from
/// the Marketplace app settings).
///
/// # Example
///
/// ```no_run
/// use highlevel_api::webhook::verify_signature;
///
/// # let body = b"{}";
/// # let signature = "abc123";
/// # let secret = "your-webhook-secret";
/// let valid = verify_signature(body, signature, secret);
/// ```
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

/// Verify an HMAC-SHA256 webhook signature using constant-time comparison.
///
/// * `payload` - raw request body bytes
/// * `signature_header` - value of the `X-Signature` header (hex-encoded HMAC)
/// * `secret` - shared secret from GHL Marketplace app settings
pub fn verify_signature(payload: &[u8], signature_header: &str, secret: &str) -> bool {
    let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
        Ok(m) => m,
        Err(_) => return false,
    };
    mac.update(payload);
    let expected = mac.finalize().into_bytes();
    let Ok(provided) = hex::decode(signature_header) else {
        return false;
    };
    // Constant-time comparison
    expected.len() == provided.len()
        && expected
            .iter()
            .zip(provided.iter())
            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
            == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_signature_returns_true() {
        let secret = "test-secret";
        let payload = b"{\"event\":\"test\"}";

        // Generate the expected signature
        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
        mac.update(payload);
        let sig = hex::encode(mac.finalize().into_bytes());

        assert!(verify_signature(payload, &sig, secret));
    }

    #[test]
    fn invalid_signature_returns_false() {
        assert!(!verify_signature(b"payload", "invalidhex", "secret"));
        assert!(!verify_signature(b"payload", "deadbeef", "secret"));
    }

    #[test]
    fn empty_payload() {
        let secret = "s";
        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
        mac.update(b"");
        let sig = hex::encode(mac.finalize().into_bytes());
        assert!(verify_signature(b"", &sig, secret));
    }
}