use std::time::{Duration, SystemTime};
use base64::Engine as _;
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
use serde::{Deserialize, Serialize};
pub const SIGNATURE_HEADER: &str = "x-wh-signature";
pub const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAokvo/r9tVgcfZ5DysOSC
Frm602qYV0MaAiNnX9O8KxMbiyRKWeL9JpCpVpt4XHIcBOK4u3cLSqJGOLaPuXw6
dO0t6Q/ZVdAV5Phz+ZtzPL16iCGeK9po6D6JHBpbi989mmzMryUnQJezlYJ3DVfB
csedpinheNnyYeFXolrJvcsjDtfAeRx5ByHQmTnSdFUzuAnC9/GepgLT9SM4nCpv
uxmZMxrJt5Rw+VUaQ9B8JSvbMPpez4peKaJPZHBbU3OdeCVx5klVXXZQGNHOs8gF
3kvoV5rTnXV0IknLBXlcKKAQLZcY/Q9rG6Ifi9c+5vqlvHPCUJFT5XUGG5RKgOKU
J062fRtN+rLYZUV+BjafxQauvC8wSWeYja63VSUruvmNj8xkx2zE/Juc+yjLjTXp
IocmaiFeAO6fUtNjDeFVkhf5LNb59vECyrHD2SQIrhgXpO4Q3dVNA5rw576PwTzN
h/AMfHKIjE4xQA1SZuYJmNnmVZLIZBlQAF9Ntd03rfadZ+yDiOXCCs9FkHibELhC
HULgCsnuDJHcrGNd5/Ddm5hxGQ0ASitgHeMZ0kcIOwKDOzOU53lDza6/Y09T7sYJ
PQe7z0cvj7aE4B+Ax1ZoZGPzpJlZtGXCsu9aTEGEnKzmsFqwcSsnw3JB31IGKAyk
T1hhTiaCeIY/OwwwNUY2yvcCAwEAAQ==
-----END PUBLIC KEY-----";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WebhookError {
#[error("signature is not valid base64: {0}")]
Signature(#[from] base64::DecodeError),
#[error("public key is not a valid PEM SPKI key: {0}")]
PublicKey(String),
#[error("signature does not match the payload")]
Mismatch,
}
pub fn verify(raw_body: &[u8], signature_b64: &str) -> Result<(), WebhookError> {
verify_with_key(raw_body, signature_b64, PUBLIC_KEY_PEM)
}
pub fn verify_with_key(
raw_body: &[u8],
signature_b64: &str,
public_key_pem: &str,
) -> Result<(), WebhookError> {
let cleaned: String = signature_b64.split_whitespace().collect();
let signature_bytes = base64::engine::general_purpose::STANDARD.decode(cleaned)?;
let key = rsa::RsaPublicKey::from_public_key_pem(public_key_pem)
.map_err(|e| WebhookError::PublicKey(e.to_string()))?;
let verifying_key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(key);
let signature = rsa::pkcs1v15::Signature::try_from(signature_bytes.as_slice())
.map_err(|_| WebhookError::Mismatch)?;
verifying_key
.verify(raw_body, &signature)
.map_err(|_| WebhookError::Mismatch)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebhookEvent {
#[serde(default, rename = "type")]
pub event_type: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub webhook_id: Option<String>,
#[serde(default)]
pub location_id: Option<String>,
#[serde(default)]
pub company_id: Option<String>,
#[serde(flatten)]
pub data: serde_json::Map<String, serde_json::Value>,
}
impl WebhookEvent {
pub fn event_type(&self) -> &str {
self.event_type.as_deref().unwrap_or_default()
}
pub fn timestamp_at(&self) -> Option<SystemTime> {
let raw = self.timestamp.as_deref()?;
parse_rfc3339(raw)
}
pub fn is_stale(&self, max_age: Duration) -> bool {
match self.timestamp_at() {
Some(t) => SystemTime::now()
.duration_since(t)
.map(|age| age > max_age)
.unwrap_or(false), None => true,
}
}
pub fn parse_as<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_value(serde_json::to_value(self)?)
}
}
fn parse_rfc3339(s: &str) -> Option<SystemTime> {
let bytes = s.as_bytes();
if bytes.len() < 19 {
return None;
}
let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
let (h, mi, sec) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
return None;
}
let rest = &s[19..];
let mut offset_secs: i64 = 0;
let tz = rest.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
if !(tz.is_empty() || tz == "Z" || tz == "z") {
let sign = match tz.as_bytes()[0] {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let oh = tz.get(1..3)?.parse::<i64>().ok()?;
let om = tz
.get(4..6)
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
offset_secs = sign * (oh * 3600 + om * 60);
}
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
let yoe = y_adj - era * 400;
let mp = (mo + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
let secs = days * 86_400 + h * 3600 + mi * 60 + sec - offset_secs;
if secs < 0 {
return None;
}
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_a_tampered_body() {
let err = verify(b"{\"type\":\"ContactCreate\"}", "aGVsbG8=").unwrap_err();
assert!(matches!(err, WebhookError::Mismatch), "got {err:?}");
}
#[test]
fn rejects_non_base64_signature() {
let err = verify(b"{}", "not base64!!!").unwrap_err();
assert!(matches!(err, WebhookError::Signature(_)), "got {err:?}");
}
#[test]
fn published_public_key_parses() {
assert!(rsa::RsaPublicKey::from_public_key_pem(PUBLIC_KEY_PEM).is_ok());
}
#[test]
fn round_trips_a_generated_signature() {
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::EncodePublicKey;
use rsa::signature::{SignatureEncoding, Signer};
let mut rng = rand::thread_rng();
let private = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keygen");
let pem = private
.to_public_key()
.to_public_key_pem(rsa::pkcs8::LineEnding::LF)
.expect("pem");
let body = br#"{"type":"ContactCreate","webhookId":"abc"}"#;
let sig = SigningKey::<sha2::Sha256>::new(private).sign(body);
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
verify_with_key(body, &sig_b64, &pem).expect("valid signature verifies");
verify_with_key(b"tampered", &sig_b64, &pem).expect_err("tampered body rejected");
}
#[test]
fn parses_envelope_and_keeps_unknown_fields() {
let raw = r#"{
"type": "ContactCreate",
"timestamp": "2026-01-28T14:35:00Z",
"webhookId": "abc123",
"locationId": "loc-1",
"id": "contact-9",
"email": "ada@example.com"
}"#;
let ev: WebhookEvent = serde_json::from_str(raw).unwrap();
assert_eq!(ev.event_type(), "ContactCreate");
assert_eq!(ev.webhook_id.as_deref(), Some("abc123"));
assert_eq!(ev.location_id.as_deref(), Some("loc-1"));
assert_eq!(ev.data["email"], "ada@example.com");
assert_eq!(ev.data["id"], "contact-9");
}
#[test]
fn timestamps_drive_the_replay_window() {
let old: WebhookEvent =
serde_json::from_str(r#"{"timestamp":"2020-01-01T00:00:00Z"}"#).unwrap();
assert!(old.is_stale(Duration::from_secs(300)));
assert!(old.timestamp_at().is_some());
let none: WebhookEvent = serde_json::from_str("{}").unwrap();
assert!(none.is_stale(Duration::from_secs(300)));
let with_offset = parse_rfc3339("2026-01-28T14:35:00+05:00").expect("offset form parses");
let as_utc = parse_rfc3339("2026-01-28T09:35:00Z").expect("utc form parses");
assert_eq!(with_offset, as_utc);
}
#[test]
fn fractional_seconds_parse() {
assert!(parse_rfc3339("2026-01-28T14:35:00.123Z").is_some());
assert!(parse_rfc3339("garbage").is_none());
assert!(parse_rfc3339("2026-13-28T14:35:00Z").is_none()); }
}