use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
pub id: String,
pub created_at: String,
pub data: WebhookEventData,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WebhookEventData {
#[serde(rename = "type")]
pub event_type: String,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebhookVerifyError {
InvalidSecret(String),
InvalidSignature(String),
SignatureMismatch,
TimestampExpired {
age_seconds: u64,
max_age_seconds: u64,
},
ParseError(String),
}
impl std::fmt::Display for WebhookVerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidSecret(msg) => write!(f, "invalid webhook secret: {msg}"),
Self::InvalidSignature(msg) => write!(f, "invalid signature header: {msg}"),
Self::SignatureMismatch => write!(f, "webhook signature does not match payload"),
Self::TimestampExpired { age_seconds, max_age_seconds } => {
write!(f, "webhook payload is {age_seconds}s old (max {max_age_seconds}s)")
}
Self::ParseError(msg) => write!(f, "failed to parse webhook payload: {msg}"),
}
}
}
impl std::error::Error for WebhookVerifyError {}
const MAX_PAYLOAD_AGE_SECONDS: u64 = 300;
pub fn verify_webhook(
payload: &str,
signature_header: &str,
signing_secret: &str,
) -> std::result::Result<WebhookEvent, WebhookVerifyError> {
let secret_bytes = decode_signing_secret(signing_secret)?;
let parts: Vec<&str> = signature_header.split(',').collect();
if parts.len() != 3 || parts[0] != "v1" {
return Err(WebhookVerifyError::InvalidSignature(
"expected format: v1,<timestamp>,<signature>".to_string(),
));
}
let timestamp_str = parts[1];
let provided_signature = parts[2];
let timestamp: u64 = timestamp_str.parse().map_err(|_| {
WebhookVerifyError::InvalidSignature("timestamp is not a valid integer".to_string())
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now > timestamp && (now - timestamp) > MAX_PAYLOAD_AGE_SECONDS {
return Err(WebhookVerifyError::TimestampExpired {
age_seconds: now - timestamp,
max_age_seconds: MAX_PAYLOAD_AGE_SECONDS,
});
}
let signed_content = format!("v1.{timestamp_str}.{payload}");
let mut mac = HmacSha256::new_from_slice(&secret_bytes)
.map_err(|e| WebhookVerifyError::InvalidSecret(format!("HMAC init failed: {e}")))?;
mac.update(signed_content.as_bytes());
let expected = mac.finalize().into_bytes();
let expected_hex = hex::encode(expected);
if !constant_time_eq(expected_hex.as_bytes(), provided_signature.as_bytes()) {
return Err(WebhookVerifyError::SignatureMismatch);
}
let event: WebhookEvent = serde_json::from_str(payload)
.map_err(|e| WebhookVerifyError::ParseError(format!("{e}")))?;
Ok(event)
}
fn decode_signing_secret(secret: &str) -> std::result::Result<Vec<u8>, WebhookVerifyError> {
let encoded = secret.strip_prefix("whsec_").ok_or_else(|| {
WebhookVerifyError::InvalidSecret("secret must start with 'whsec_'".to_string())
})?;
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| WebhookVerifyError::InvalidSecret(format!("base64 decode failed: {e}")))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
pub mod session_events {
pub const STATUS_RUN_STARTED: &str = "session.status_run_started";
pub const STATUS_IDLED: &str = "session.status_idled";
pub const STATUS_RESCHEDULED: &str = "session.status_rescheduled";
pub const STATUS_TERMINATED: &str = "session.status_terminated";
pub const THREAD_CREATED: &str = "session.thread_created";
pub const THREAD_IDLED: &str = "session.thread_idled";
pub const THREAD_TERMINATED: &str = "session.thread_terminated";
pub const OUTCOME_EVALUATION_ENDED: &str = "session.outcome_evaluation_ended";
}
pub mod vault_events {
pub const VAULT_CREATED: &str = "vault.created";
pub const VAULT_ARCHIVED: &str = "vault.archived";
pub const VAULT_DELETED: &str = "vault.deleted";
pub const CREDENTIAL_CREATED: &str = "vault_credential.created";
pub const CREDENTIAL_ARCHIVED: &str = "vault_credential.archived";
pub const CREDENTIAL_DELETED: &str = "vault_credential.deleted";
pub const CREDENTIAL_REFRESH_FAILED: &str = "vault_credential.refresh_failed";
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_secret() -> (String, Vec<u8>) {
use base64::Engine;
let key = b"test-webhook-secret-32-bytes-ok!"; let encoded = base64::engine::general_purpose::STANDARD.encode(key);
(format!("whsec_{encoded}"), key.to_vec())
}
fn sign_payload(payload: &str, timestamp: u64, key: &[u8]) -> String {
let signed_content = format!("v1.{timestamp}.{payload}");
let mut mac = HmacSha256::new_from_slice(key).unwrap();
mac.update(signed_content.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
format!("v1,{timestamp},{signature}")
}
#[test]
fn test_verify_valid_webhook() {
let (secret, key) = make_test_secret();
let payload = r#"{"type":"event","id":"event_01ABC","created_at":"2026-06-01T00:00:00Z","data":{"type":"session.status_idled","id":"sesn_01XYZ"}}"#;
let now =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let sig = sign_payload(payload, now, &key);
let event = verify_webhook(payload, &sig, &secret).unwrap();
assert_eq!(event.id, "event_01ABC");
assert_eq!(event.data.event_type, "session.status_idled");
assert_eq!(event.data.id, "sesn_01XYZ");
}
#[test]
fn test_verify_invalid_signature() {
let (secret, _key) = make_test_secret();
let payload = r#"{"type":"event","id":"event_01ABC","created_at":"2026-06-01T00:00:00Z","data":{"type":"session.status_idled","id":"sesn_01XYZ"}}"#;
let now =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let bad_sig = format!("v1,{now},deadbeef");
let result = verify_webhook(payload, &bad_sig, &secret);
assert!(matches!(result, Err(WebhookVerifyError::SignatureMismatch)));
}
#[test]
fn test_verify_expired_timestamp() {
let (secret, key) = make_test_secret();
let payload = r#"{"type":"event","id":"event_01ABC","created_at":"2026-06-01T00:00:00Z","data":{"type":"session.status_idled","id":"sesn_01XYZ"}}"#;
let old_timestamp =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
- 600; let sig = sign_payload(payload, old_timestamp, &key);
let result = verify_webhook(payload, &sig, &secret);
assert!(matches!(result, Err(WebhookVerifyError::TimestampExpired { .. })));
}
#[test]
fn test_verify_invalid_secret_prefix() {
let result = verify_webhook("payload", "v1,123,abc", "not_whsec_prefix");
assert!(matches!(result, Err(WebhookVerifyError::InvalidSecret(_))));
}
#[test]
fn test_verify_malformed_signature_header() {
let (secret, _) = make_test_secret();
let result = verify_webhook("payload", "bad-header", &secret);
assert!(matches!(result, Err(WebhookVerifyError::InvalidSignature(_))));
}
}