use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use crate::models::common::{Channel, Metadata, Money};
use crate::models::payout::Recipient;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(300);
pub const TIMESTAMP_HEADER: &str = "X-Webhook-Timestamp";
pub const SIGNATURE_HEADER: &str = "X-Webhook-Signature";
pub const EVENT_HEADER: &str = "X-Webhook-Event";
#[derive(Debug, thiserror::Error)]
pub enum WebhookError {
#[error("missing header {0}")]
MissingHeader(&'static str),
#[error("invalid X-Webhook-Timestamp header: {0}")]
InvalidTimestamp(String),
#[error("webhook timestamp is too old (age = {0:?})")]
TimestampTooOld(Duration),
#[error("invalid signature encoding: {0}")]
InvalidSignatureEncoding(#[source] hex::FromHexError),
#[error("webhook signature mismatch")]
SignatureMismatch,
#[error("invalid JSON body: {0}")]
InvalidJson(#[source] serde_json::Error),
}
#[derive(Clone)]
pub struct Verifier {
signing_key: Vec<u8>,
max_age: Duration,
}
impl std::fmt::Debug for Verifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Verifier")
.field("signing_key", &"<redacted>")
.field("max_age", &self.max_age)
.finish()
}
}
impl Verifier {
pub fn new(signing_key: impl Into<Vec<u8>>) -> Self {
Self {
signing_key: signing_key.into(),
max_age: DEFAULT_MAX_AGE,
}
}
pub fn with_max_age(mut self, age: Duration) -> Self {
self.max_age = age;
self
}
pub fn max_age(&self) -> Duration {
self.max_age
}
pub fn verify(
&self,
body: &[u8],
timestamp: &str,
signature: &str,
) -> Result<RawEvent, WebhookError> {
if timestamp.is_empty() {
return Err(WebhookError::MissingHeader(TIMESTAMP_HEADER));
}
if signature.is_empty() {
return Err(WebhookError::MissingHeader(SIGNATURE_HEADER));
}
let ts: u64 = timestamp
.parse()
.map_err(|_| WebhookError::InvalidTimestamp(timestamp.to_string()))?;
if let Some(now) = unix_now() {
let age_secs = now.saturating_sub(ts);
if age_secs > self.max_age.as_secs() {
return Err(WebhookError::TimestampTooOld(Duration::from_secs(age_secs)));
}
}
let provided = hex::decode(signature).map_err(WebhookError::InvalidSignatureEncoding)?;
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.signing_key)
.expect("HmacSha256 accepts a key of any length");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
mac.verify_slice(&provided)
.map_err(|_| WebhookError::SignatureMismatch)?;
serde_json::from_slice(body).map_err(WebhookError::InvalidJson)
}
pub fn verify_typed(
&self,
body: &[u8],
timestamp: &str,
signature: &str,
) -> Result<Event, WebhookError> {
let raw = self.verify(body, timestamp, signature)?;
raw.try_into_event().map_err(WebhookError::InvalidJson)
}
}
fn unix_now() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct RawEvent {
pub id: String,
#[serde(rename = "type")]
pub event_type: String,
pub api_version: String,
pub created_at: String,
pub data: serde_json::Value,
}
impl RawEvent {
pub fn try_into_event(self) -> Result<Event, serde_json::Error> {
let RawEvent {
id,
event_type,
api_version,
created_at,
data,
} = self;
let payload = match event_type.as_str() {
"payment.completed" => EventData::PaymentCompleted(serde_json::from_value(data)?),
"payment.failed" => EventData::PaymentFailed(serde_json::from_value(data)?),
"payment.voided" => EventData::PaymentVoided(serde_json::from_value(data)?),
"payment.expired" => EventData::PaymentExpired(serde_json::from_value(data)?),
"payout.completed" => EventData::PayoutCompleted(serde_json::from_value(data)?),
"payout.failed" => EventData::PayoutFailed(serde_json::from_value(data)?),
"payout.reversed" => EventData::PayoutReversed(serde_json::from_value(data)?),
other => EventData::Unknown {
event_type: other.to_string(),
data,
},
};
Ok(Event {
id,
api_version,
created_at,
data: payload,
})
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Event {
pub id: String,
pub api_version: String,
pub created_at: String,
pub data: EventData,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum EventData {
PaymentCompleted(PaymentEventData),
PaymentFailed(PaymentEventData),
PaymentVoided(PaymentEventData),
PaymentExpired(PaymentEventData),
PayoutCompleted(PayoutEventData),
PayoutFailed(PayoutEventData),
PayoutReversed(PayoutEventData),
Unknown {
event_type: String,
data: serde_json::Value,
},
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct PaymentEventData {
pub reference: String,
#[serde(default)]
pub external_reference: Option<String>,
pub status: String,
pub amount: Money,
#[serde(default)]
pub settlement: Option<Settlement>,
#[serde(default)]
pub channel: Option<Channel>,
#[serde(default)]
pub customer: Option<EventCustomer>,
#[serde(default)]
pub metadata: Option<Metadata>,
#[serde(default)]
pub completed_at: Option<String>,
#[serde(default)]
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct PayoutEventData {
pub reference: String,
#[serde(default)]
pub external_reference: Option<String>,
pub status: String,
pub amount: Money,
#[serde(default)]
pub fees: Option<Money>,
#[serde(default)]
pub total: Option<Money>,
#[serde(default)]
pub channel: Option<Channel>,
#[serde(default)]
pub recipient: Option<Recipient>,
#[serde(default)]
pub metadata: Option<Metadata>,
#[serde(default)]
pub completed_at: Option<String>,
#[serde(default)]
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Settlement {
pub gross: Money,
pub fees: Money,
pub net: Money,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct EventCustomer {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub phone: Option<String>,
#[serde(default)]
pub email: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use hmac::Mac;
fn sign(key: &[u8], timestamp: &str, body: &[u8]) -> String {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).unwrap();
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
hex::encode(mac.finalize().into_bytes())
}
fn current_ts() -> String {
unix_now().unwrap().to_string()
}
fn payment_completed_body(ts: &str) -> Vec<u8> {
let _ = ts;
let body = serde_json::json!({
"id": "evt_a1b2c3d4",
"type": "payment.completed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "pi_a1b2c3",
"external_reference": "SEL123",
"status": "completed",
"amount": {"value": 50000, "currency": "TZS"},
"settlement": {
"gross": {"value": 50000, "currency": "TZS"},
"fees": {"value": 1000, "currency": "TZS"},
"net": {"value": 49000, "currency": "TZS"}
},
"channel": {"type": "mobile_money", "provider": "mpesa"},
"customer": {"phone": "+255712345678", "name": "John", "email": "j@x.com"},
"metadata": {"order_id": "ORD-1"},
"completed_at": "2026-01-24T10:30:00Z"
}
});
serde_json::to_vec(&body).unwrap()
}
#[test]
fn verifies_valid_signature() {
let key = b"super_secret_signing_key";
let ts = current_ts();
let body = payment_completed_body(&ts);
let sig = sign(key, &ts, &body);
let v = Verifier::new(key.to_vec());
let event = v.verify_typed(&body, &ts, &sig).expect("verifies");
assert_eq!(event.id, "evt_a1b2c3d4");
match event.data {
EventData::PaymentCompleted(p) => {
assert_eq!(p.reference, "pi_a1b2c3");
assert_eq!(p.amount.value, 50000);
assert_eq!(p.settlement.unwrap().net.value, 49000);
}
other => panic!("wrong event variant: {other:?}"),
}
}
#[test]
fn rejects_bad_signature() {
let key = b"super_secret";
let ts = current_ts();
let body = payment_completed_body(&ts);
let bad_sig = sign(b"other_key", &ts, &body);
let v = Verifier::new(key.to_vec());
let err = v.verify(&body, &ts, &bad_sig).unwrap_err();
assert!(matches!(err, WebhookError::SignatureMismatch));
}
#[test]
fn rejects_old_timestamp() {
let key = b"k";
let body = b"{}";
let old = "1000000000"; let sig = sign(key, old, body);
let v = Verifier::new(key.to_vec());
let err = v.verify(body, old, &sig).unwrap_err();
assert!(matches!(err, WebhookError::TimestampTooOld(_)));
}
#[test]
fn rejects_tampered_body() {
let key = b"k";
let ts = current_ts();
let body = payment_completed_body(&ts);
let sig = sign(key, &ts, &body);
let mut tampered = body.clone();
let last = tampered.len() - 5;
tampered[last] ^= 0x01;
let v = Verifier::new(key.to_vec());
assert!(matches!(
v.verify(&tampered, &ts, &sig).unwrap_err(),
WebhookError::SignatureMismatch
));
}
#[test]
fn missing_signature_header() {
let v = Verifier::new(b"k".to_vec());
let err = v.verify(b"{}", "0", "").unwrap_err();
match err {
WebhookError::MissingHeader(name) => assert_eq!(name, SIGNATURE_HEADER),
other => panic!("expected MissingHeader, got {other:?}"),
}
}
#[test]
fn missing_timestamp_header() {
let v = Verifier::new(b"k".to_vec());
let err = v.verify(b"{}", "", "deadbeef").unwrap_err();
match err {
WebhookError::MissingHeader(name) => assert_eq!(name, TIMESTAMP_HEADER),
other => panic!("expected MissingHeader, got {other:?}"),
}
}
#[test]
fn unknown_event_type_returns_unknown_variant() {
let key = b"k";
let ts = current_ts();
let body = serde_json::to_vec(&serde_json::json!({
"id": "evt_x",
"type": "payment.invented",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {"foo": "bar"}
}))
.unwrap();
let sig = sign(key, &ts, &body);
let v = Verifier::new(key.to_vec());
let event = v.verify_typed(&body, &ts, &sig).unwrap();
match event.data {
EventData::Unknown { event_type, data } => {
assert_eq!(event_type, "payment.invented");
assert_eq!(data["foo"], "bar");
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn payout_completed_parses() {
let key = b"k";
let ts = current_ts();
let body = serde_json::to_vec(&serde_json::json!({
"id": "evt_p",
"type": "payout.completed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "po_1",
"status": "completed",
"amount": {"value": 5000, "currency": "TZS"},
"fees": {"value": 1500, "currency": "TZS"},
"total": {"value": 6500, "currency": "TZS"},
"channel": {"type": "mobile_money", "provider": "airtel"},
"recipient": {"name": "Jane", "phone": "255712345678"}
}
})).unwrap();
let sig = sign(key, &ts, &body);
let v = Verifier::new(key.to_vec());
let event = v.verify_typed(&body, &ts, &sig).unwrap();
match event.data {
EventData::PayoutCompleted(p) => {
assert_eq!(p.reference, "po_1");
assert_eq!(p.total.unwrap().value, 6500);
}
other => panic!("expected PayoutCompleted, got {other:?}"),
}
}
}