diff --git a/lightning-background-processor/Cargo.toml b/lightning-background-processor/Cargo.toml
index fa89b078de5..86e07c4fe6e 100644
--- a/lightning-background-processor/Cargo.toml
+++ b/lightning-background-processor/Cargo.toml
@@ -14,9 +14,10 @@ all-features = true
 rustdoc-args = ["--cfg", "docsrs"]
 
 [features]
+default = ["std", "time"]
 std = ["lightning/std", "lightning-liquidity/std", "bitcoin-io/std", "bitcoin_hashes/std"]
+time = ["std", "lightning-liquidity/time"]
 
-default = ["std"]
 
 [dependencies]
 bitcoin = { version = "0.32.2", default-features = false }
diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs
index 235bb39c7d4..8bdb303e1a5 100644
--- a/lightning-background-processor/src/lib.rs
+++ b/lightning-background-processor/src/lib.rs
@@ -648,6 +648,7 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput};
 /// # use std::sync::atomic::{AtomicBool, Ordering};
 /// # use std::time::SystemTime;
 /// # use lightning_background_processor::{process_events_async, GossipSync};
+/// # use lightning_liquidity::lsps5::service::TimeProvider;
 /// # struct Logger {}
 /// # impl lightning::util::logger::Logger for Logger {
 /// #     fn log(&self, _record: lightning::util::logger::Record) {}
@@ -659,6 +660,16 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput};
 /// #     fn remove(&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool) -> io::Result<()> { Ok(()) }
 /// #     fn list(&self, primary_namespace: &str, secondary_namespace: &str) -> io::Result<Vec<String>> { Ok(Vec::new()) }
 /// # }
+/// #
+/// # use core::time::Duration;
+/// # struct DefaultTimeProvider;
+/// #
+/// # impl TimeProvider for DefaultTimeProvider {
+/// #    fn duration_since_epoch(&self) -> Duration {
+/// #        use std::time::{SystemTime, UNIX_EPOCH};
+/// #        SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch")
+/// #    }
+/// # }
 /// # struct EventHandler {}
 /// # impl EventHandler {
 /// #     async fn handle_event(&self, _: lightning::events::Event) -> Result<(), ReplayEvent> { Ok(()) }
@@ -674,7 +685,7 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput};
 /// # type P2PGossipSync<UL> = lightning::routing::gossip::P2PGossipSync<Arc<NetworkGraph>, Arc<UL>, Arc<Logger>>;
 /// # type ChannelManager<B, F, FE> = lightning::ln::channelmanager::SimpleArcChannelManager<ChainMonitor<B, F, FE>, B, FE, Logger>;
 /// # type OnionMessenger<B, F, FE> = lightning::onion_message::messenger::OnionMessenger<Arc<lightning::sign::KeysManager>, Arc<lightning::sign::KeysManager>, Arc<Logger>, Arc<ChannelManager<B, F, FE>>, Arc<lightning::onion_message::messenger::DefaultMessageRouter<Arc<NetworkGraph>, Arc<Logger>, Arc<lightning::sign::KeysManager>>>, Arc<ChannelManager<B, F, FE>>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>;
-/// # type LiquidityManager<B, F, FE> = lightning_liquidity::LiquidityManager<Arc<lightning::sign::KeysManager>, Arc<ChannelManager<B, F, FE>>, Arc<F>>;
+/// # type LiquidityManager<B, F, FE> = lightning_liquidity::LiquidityManager<Arc<lightning::sign::KeysManager>, Arc<ChannelManager<B, F, FE>>, Arc<F>, Arc<DefaultTimeProvider>>;
 /// # type Scorer = RwLock<lightning::routing::scoring::ProbabilisticScorer<Arc<NetworkGraph>, Arc<Logger>>>;
 /// # type PeerManager<B, F, FE, UL> = lightning::ln::peer_handler::SimpleArcPeerManager<SocketDescriptor, ChainMonitor<B, F, FE>, B, FE, Arc<UL>, Logger, F, Store>;
 /// #
@@ -1151,7 +1162,7 @@ impl Drop for BackgroundProcessor {
 	}
 }
 
-#[cfg(all(feature = "std", test))]
+#[cfg(all(feature = "std", feature = "time", test))]
 mod tests {
 	use super::{BackgroundProcessor, GossipSync, FRESHNESS_TIMER};
 	use bitcoin::constants::{genesis_block, ChainHash};
@@ -1196,6 +1207,8 @@ mod tests {
 	use lightning::util::sweep::{OutputSpendStatus, OutputSweeperSync, PRUNE_DELAY_BLOCKS};
 	use lightning::util::test_utils;
 	use lightning::{get_event, get_event_msg};
+	#[cfg(feature = "time")]
+	use lightning_liquidity::lsps5::service::DefaultTimeProvider;
 	use lightning_liquidity::LiquidityManager;
 	use lightning_persister::fs_store::FilesystemStore;
 	use lightning_rapid_gossip_sync::RapidGossipSync;
@@ -1292,8 +1305,12 @@ mod tests {
 		IgnoringMessageHandler,
 	>;
 
-	type LM =
-		LiquidityManager<Arc<KeysManager>, Arc<ChannelManager>, Arc<dyn Filter + Sync + Send>>;
+	type LM = LiquidityManager<
+		Arc<KeysManager>,
+		Arc<ChannelManager>,
+		Arc<dyn Filter + Sync + Send>,
+		Arc<DefaultTimeProvider>,
+	>;
 
 	struct Node {
 		node: Arc<ChannelManager>,
diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml
index 0733d387b15..f301e4fe34c 100644
--- a/lightning-liquidity/Cargo.toml
+++ b/lightning-liquidity/Cargo.toml
@@ -14,8 +14,9 @@ categories = ["cryptography::cryptocurrencies"]
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [features]
-default = ["std"]
+default = ["std", "time"]
 std = ["lightning/std"]
+time = ["std"]
 backtrace = ["dep:backtrace"]
 
 [dependencies]
diff --git a/lightning-liquidity/src/events/mod.rs b/lightning-liquidity/src/events/mod.rs
index 506b91494c3..82e480a454c 100644
--- a/lightning-liquidity/src/events/mod.rs
+++ b/lightning-liquidity/src/events/mod.rs
@@ -23,6 +23,7 @@ pub use event_queue::MAX_EVENT_QUEUE_SIZE;
 use crate::lsps0;
 use crate::lsps1;
 use crate::lsps2;
+use crate::lsps5;
 
 /// An event which you should probably take some action in response to.
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -38,6 +39,10 @@ pub enum LiquidityEvent {
 	LSPS2Client(lsps2::event::LSPS2ClientEvent),
 	/// An LSPS2 (JIT Channel) server event.
 	LSPS2Service(lsps2::event::LSPS2ServiceEvent),
+	/// An LSPS5 (Webhook) client event.
+	LSPS5Client(lsps5::event::LSPS5ClientEvent),
+	/// An LSPS5 (Webhook) server event.
+	LSPS5Service(lsps5::event::LSPS5ServiceEvent),
 }
 
 impl From<lsps0::event::LSPS0ClientEvent> for LiquidityEvent {
@@ -70,3 +75,15 @@ impl From<lsps2::event::LSPS2ServiceEvent> for LiquidityEvent {
 		Self::LSPS2Service(event)
 	}
 }
+
+impl From<lsps5::event::LSPS5ClientEvent> for LiquidityEvent {
+	fn from(event: lsps5::event::LSPS5ClientEvent) -> Self {
+		Self::LSPS5Client(event)
+	}
+}
+
+impl From<lsps5::event::LSPS5ServiceEvent> for LiquidityEvent {
+	fn from(event: lsps5::event::LSPS5ServiceEvent) -> Self {
+		Self::LSPS5Service(event)
+	}
+}
diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs
index 5fb59c319c8..275d101ca37 100644
--- a/lightning-liquidity/src/lib.rs
+++ b/lightning-liquidity/src/lib.rs
@@ -23,6 +23,8 @@
 //! an LSP will open a "just-in-time" channel. This is useful for the initial on-boarding of
 //! clients as the channel opening fees are deducted from the incoming payment, i.e., no funds are
 //! required client-side to initiate this flow.
+//! - [bLIP-55 / LSPS5] defines a protocol for sending webhook notifications to clients. This is
+//! useful for notifying clients about incoming payments, channel expiries, etc.
 //!
 //! To get started, you'll want to setup a [`LiquidityManager`] and configure it to be the
 //! [`CustomMessageHandler`] of your LDK node. You can then for example call
@@ -37,6 +39,7 @@
 //! [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md
 //! [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md
 //! [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md
+//! [bLIP-55 / LSPS5]: https://github.com/lightning/blips/pull/55/files
 //! [`CustomMessageHandler`]: lightning::ln::peer_handler::CustomMessageHandler
 //! [`LiquidityManager::next_event`]: crate::LiquidityManager::next_event
 #![deny(missing_docs)]
@@ -59,6 +62,7 @@ pub mod events;
 pub mod lsps0;
 pub mod lsps1;
 pub mod lsps2;
+pub mod lsps5;
 mod manager;
 pub mod message_queue;
 #[allow(dead_code)]
diff --git a/lightning-liquidity/src/lsps0/msgs.rs b/lightning-liquidity/src/lsps0/msgs.rs
index 24df03a1481..6fb885659b5 100644
--- a/lightning-liquidity/src/lsps0/msgs.rs
+++ b/lightning-liquidity/src/lsps0/msgs.rs
@@ -83,6 +83,7 @@ impl TryFrom<LSPSMessage> for LSPS0Message {
 			LSPSMessage::LSPS0(message) => Ok(message),
 			LSPSMessage::LSPS1(_) => Err(()),
 			LSPSMessage::LSPS2(_) => Err(()),
+			LSPSMessage::LSPS5(_) => Err(()),
 		}
 	}
 }
diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs
index 9fb27713892..aeb29422678 100644
--- a/lightning-liquidity/src/lsps0/ser.rs
+++ b/lightning-liquidity/src/lsps0/ser.rs
@@ -21,6 +21,11 @@ use crate::lsps1::msgs::{
 use crate::lsps2::msgs::{
 	LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME,
 };
+use crate::lsps5::msgs::{
+	LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME,
+	LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME,
+};
+
 use crate::prelude::HashMap;
 
 use lightning::ln::msgs::{DecodeError, LightningError};
@@ -29,7 +34,8 @@ use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength};
 
 use bitcoin::secp256k1::PublicKey;
 
-#[cfg(feature = "std")]
+use core::time::Duration;
+#[cfg(feature = "time")]
 use std::time::{SystemTime, UNIX_EPOCH};
 
 use serde::de::{self, MapAccess, Visitor};
@@ -60,6 +66,9 @@ pub(crate) enum LSPSMethod {
 	LSPS1CreateOrder,
 	LSPS2GetInfo,
 	LSPS2Buy,
+	LSPS5SetWebhook,
+	LSPS5ListWebhooks,
+	LSPS5RemoveWebhook,
 }
 
 impl LSPSMethod {
@@ -71,6 +80,9 @@ impl LSPSMethod {
 			Self::LSPS1GetOrder => LSPS1_GET_ORDER_METHOD_NAME,
 			Self::LSPS2GetInfo => LSPS2_GET_INFO_METHOD_NAME,
 			Self::LSPS2Buy => LSPS2_BUY_METHOD_NAME,
+			Self::LSPS5SetWebhook => LSPS5_SET_WEBHOOK_METHOD_NAME,
+			Self::LSPS5ListWebhooks => LSPS5_LIST_WEBHOOKS_METHOD_NAME,
+			Self::LSPS5RemoveWebhook => LSPS5_REMOVE_WEBHOOK_METHOD_NAME,
 		}
 	}
 }
@@ -85,6 +97,9 @@ impl FromStr for LSPSMethod {
 			LSPS1_GET_ORDER_METHOD_NAME => Ok(Self::LSPS1GetOrder),
 			LSPS2_GET_INFO_METHOD_NAME => Ok(Self::LSPS2GetInfo),
 			LSPS2_BUY_METHOD_NAME => Ok(Self::LSPS2Buy),
+			LSPS5_SET_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5SetWebhook),
+			LSPS5_LIST_WEBHOOKS_METHOD_NAME => Ok(Self::LSPS5ListWebhooks),
+			LSPS5_REMOVE_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5RemoveWebhook),
 			_ => Err(&"Unknown method name"),
 		}
 	}
@@ -117,6 +132,16 @@ impl From<&LSPS2Request> for LSPSMethod {
 	}
 }
 
+impl From<&LSPS5Request> for LSPSMethod {
+	fn from(value: &LSPS5Request) -> Self {
+		match value {
+			LSPS5Request::SetWebhook(_) => Self::LSPS5SetWebhook,
+			LSPS5Request::ListWebhooks(_) => Self::LSPS5ListWebhooks,
+			LSPS5Request::RemoveWebhook(_) => Self::LSPS5RemoveWebhook,
+		}
+	}
+}
+
 impl<'de> Deserialize<'de> for LSPSMethod {
 	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
 	where
@@ -204,7 +229,7 @@ impl LSPSDateTime {
 	}
 
 	/// Returns if the given time is in the past.
-	#[cfg(feature = "std")]
+	#[cfg(feature = "time")]
 	pub fn is_past(&self) -> bool {
 		let now_seconds_since_epoch = SystemTime::now()
 			.duration_since(UNIX_EPOCH)
@@ -214,6 +239,16 @@ impl LSPSDateTime {
 			self.0.timestamp().try_into().expect("expiration to be ahead of unix epoch");
 		now_seconds_since_epoch > datetime_seconds_since_epoch
 	}
+
+	/// Returns the time in seconds since the unix epoch.
+	pub fn abs_diff(&self, other: &Self) -> u64 {
+		self.0.timestamp().abs_diff(other.0.timestamp())
+	}
+
+	/// Returns the time in seconds since the unix epoch.
+	pub fn new_from_duration_since_epoch(duration: Duration) -> Self {
+		Self(chrono::DateTime::UNIX_EPOCH + duration)
+	}
 }
 
 impl FromStr for LSPSDateTime {
@@ -255,6 +290,8 @@ pub enum LSPSMessage {
 	LSPS1(LSPS1Message),
 	/// An LSPS2 message.
 	LSPS2(LSPS2Message),
+	/// An LSPS5 message.
+	LSPS5(LSPS5Message),
 }
 
 impl LSPSMessage {
@@ -282,6 +319,9 @@ impl LSPSMessage {
 			LSPSMessage::LSPS2(LSPS2Message::Request(request_id, request)) => {
 				Some((LSPSRequestId(request_id.0.clone()), request.into()))
 			},
+			LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => {
+				Some((LSPSRequestId(request_id.0.clone()), request.into()))
+			},
 			_ => None,
 		}
 	}
@@ -398,6 +438,44 @@ impl Serialize for LSPSMessage {
 				jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &serde_json::Value::Null)?;
 				jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, &error)?;
 			},
+			LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => {
+				jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?;
+				jsonrpc_object
+					.serialize_field(JSONRPC_METHOD_FIELD_KEY, &LSPSMethod::from(request))?;
+
+				match request {
+					LSPS5Request::SetWebhook(params) => {
+						jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)?
+					},
+					LSPS5Request::ListWebhooks(params) => {
+						jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)?
+					},
+					LSPS5Request::RemoveWebhook(params) => {
+						jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)?
+					},
+				}
+			},
+			LSPSMessage::LSPS5(LSPS5Message::Response(request_id, response)) => {
+				jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?;
+
+				match response {
+					LSPS5Response::SetWebhook(result) => {
+						jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)?
+					},
+					LSPS5Response::SetWebhookError(error) => {
+						jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)?
+					},
+					LSPS5Response::ListWebhooks(result) => {
+						jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)?
+					},
+					LSPS5Response::RemoveWebhook(result) => {
+						jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)?
+					},
+					LSPS5Response::RemoveWebhookError(error) => {
+						jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)?
+					},
+				}
+			},
 		}
 
 		jsonrpc_object.end()
@@ -511,6 +589,30 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> {
 						.map_err(de::Error::custom)?;
 					Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request))))
 				},
+				LSPSMethod::LSPS5SetWebhook => {
+					let request = serde_json::from_value(params.unwrap_or(json!({})))
+						.map_err(de::Error::custom)?;
+					Ok(LSPSMessage::LSPS5(LSPS5Message::Request(
+						id,
+						LSPS5Request::SetWebhook(request),
+					)))
+				},
+				LSPSMethod::LSPS5ListWebhooks => {
+					let request = serde_json::from_value(params.unwrap_or(json!({})))
+						.map_err(de::Error::custom)?;
+					Ok(LSPSMessage::LSPS5(LSPS5Message::Request(
+						id,
+						LSPS5Request::ListWebhooks(request),
+					)))
+				},
+				LSPSMethod::LSPS5RemoveWebhook => {
+					let request = serde_json::from_value(params.unwrap_or(json!({})))
+						.map_err(de::Error::custom)?;
+					Ok(LSPSMessage::LSPS5(LSPS5Message::Request(
+						id,
+						LSPS5Request::RemoveWebhook(request),
+					)))
+				},
 			},
 			None => match self.request_id_to_method_map.remove(&id) {
 				Some(method) => match method {
@@ -616,6 +718,52 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> {
 							Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required"))
 						}
 					},
+					LSPSMethod::LSPS5SetWebhook => {
+						if let Some(error) = error {
+							Ok(LSPSMessage::LSPS5(LSPS5Message::Response(
+								id,
+								LSPS5Response::SetWebhookError(error.into()),
+							)))
+						} else if let Some(result) = result {
+							let response =
+								serde_json::from_value(result).map_err(de::Error::custom)?;
+							Ok(LSPSMessage::LSPS5(LSPS5Message::Response(
+								id,
+								LSPS5Response::SetWebhook(response),
+							)))
+						} else {
+							Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required"))
+						}
+					},
+					LSPSMethod::LSPS5ListWebhooks => {
+						if let Some(result) = result {
+							let response =
+								serde_json::from_value(result).map_err(de::Error::custom)?;
+							Ok(LSPSMessage::LSPS5(LSPS5Message::Response(
+								id,
+								LSPS5Response::ListWebhooks(response),
+							)))
+						} else {
+							Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required"))
+						}
+					},
+					LSPSMethod::LSPS5RemoveWebhook => {
+						if let Some(error) = error {
+							Ok(LSPSMessage::LSPS5(LSPS5Message::Response(
+								id,
+								LSPS5Response::RemoveWebhookError(error.into()),
+							)))
+						} else if let Some(result) = result {
+							let response =
+								serde_json::from_value(result).map_err(de::Error::custom)?;
+							Ok(LSPSMessage::LSPS5(LSPS5Message::Response(
+								id,
+								LSPS5Response::RemoveWebhook(response),
+							)))
+						} else {
+							Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required"))
+						}
+					},
 				},
 				None => Err(de::Error::custom(format!(
 					"Received response for unknown request id: {}",
diff --git a/lightning-liquidity/src/lsps2/utils.rs b/lightning-liquidity/src/lsps2/utils.rs
index 76ceeb8f60b..a2c4d65936d 100644
--- a/lightning-liquidity/src/lsps2/utils.rs
+++ b/lightning-liquidity/src/lsps2/utils.rs
@@ -28,13 +28,13 @@ pub fn is_valid_opening_fee_params(
 }
 
 /// Determines if the given parameters are expired, or still valid.
-#[cfg_attr(not(feature = "std"), allow(unused_variables))]
+#[cfg_attr(not(feature = "time"), allow(unused_variables))]
 pub fn is_expired_opening_fee_params(fee_params: &LSPS2OpeningFeeParams) -> bool {
-	#[cfg(feature = "std")]
+	#[cfg(feature = "time")]
 	{
 		fee_params.valid_until.is_past()
 	}
-	#[cfg(not(feature = "std"))]
+	#[cfg(not(feature = "time"))]
 	{
 		// TODO: We need to find a way to check expiry times in no-std builds.
 		false
diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs
new file mode 100644
index 00000000000..1f6c1a9be3c
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/client.rs
@@ -0,0 +1,641 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! Client implementation for LSPS5 webhook registration.
+
+use crate::alloc::string::ToString;
+use crate::events::EventQueue;
+use crate::lsps0::ser::{LSPSDateTime, LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId};
+use crate::lsps5::event::LSPS5ClientEvent;
+use crate::lsps5::msgs::{
+	LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, RemoveWebhookRequest,
+	SetWebhookRequest,
+};
+
+use crate::message_queue::MessageQueue;
+use crate::prelude::{new_hash_map, HashMap};
+use crate::sync::{Arc, Mutex, RwLock};
+use crate::utils::generate_request_id;
+
+use super::msgs::{LSPS5AppName, LSPS5Error, LSPS5WebhookUrl};
+use super::service::TimeProvider;
+
+use bitcoin::secp256k1::PublicKey;
+
+use lightning::ln::msgs::{ErrorAction, LightningError};
+use lightning::sign::EntropySource;
+use lightning::util::logger::Level;
+
+use alloc::string::String;
+
+use core::ops::Deref;
+use core::time::Duration;
+
+/// Default maximum age in seconds for cached responses (1 hour).
+pub const DEFAULT_RESPONSE_MAX_AGE_SECS: u64 = 3600;
+
+#[derive(Debug, Clone)]
+/// Configuration for the LSPS5 client
+pub struct LSPS5ClientConfig {
+	/// Maximum age in seconds for cached responses (default: 3600 - 1 hour).
+	pub response_max_age_secs: Duration,
+}
+
+impl Default for LSPS5ClientConfig {
+	fn default() -> Self {
+		Self { response_max_age_secs: Duration::from_secs(DEFAULT_RESPONSE_MAX_AGE_SECS) }
+	}
+}
+
+struct PeerState<TP: Deref + Clone>
+where
+	TP::Target: TimeProvider,
+{
+	pending_set_webhook_requests:
+		HashMap<LSPSRequestId, (LSPS5AppName, LSPS5WebhookUrl, LSPSDateTime)>,
+	pending_list_webhooks_requests: HashMap<LSPSRequestId, LSPSDateTime>,
+	pending_remove_webhook_requests: HashMap<LSPSRequestId, (LSPS5AppName, LSPSDateTime)>,
+	last_cleanup: Option<LSPSDateTime>,
+	max_age_secs: Duration,
+	time_provider: TP,
+}
+
+impl<TP: Deref + Clone> PeerState<TP>
+where
+	TP::Target: TimeProvider,
+{
+	fn new(max_age_secs: Duration, time_provider: TP) -> Self {
+		Self {
+			pending_set_webhook_requests: new_hash_map(),
+			pending_list_webhooks_requests: new_hash_map(),
+			pending_remove_webhook_requests: new_hash_map(),
+			last_cleanup: None,
+			max_age_secs,
+			time_provider,
+		}
+	}
+
+	fn cleanup_expired_responses(&mut self) {
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+		// Only run cleanup once per minute to avoid excessive processing
+		const CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
+		if let Some(last_cleanup) = &self.last_cleanup {
+			let time_since_last_cleanup = Duration::from_secs(now.abs_diff(&last_cleanup));
+			if time_since_last_cleanup < CLEANUP_INTERVAL {
+				return;
+			}
+		}
+
+		self.last_cleanup = Some(now.clone());
+
+		self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| {
+			Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs
+		});
+		self.pending_list_webhooks_requests.retain(|_, timestamp| {
+			Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs
+		});
+		self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| {
+			Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs
+		});
+	}
+}
+
+/// Client-side handler for the LSPS5 (bLIP-55) webhook registration protocol.
+///
+/// `LSPS5ClientHandler` is the primary interface for LSP clients
+/// to register, list, and remove webhook endpoints with an LSP.
+///
+/// This handler is intended for use on the client-side (e.g., a mobile app)
+/// which has access to the node's keys and can send/receive peer messages.
+///
+/// For validating incoming webhook notifications on a server, see [`LSPS5Validator`].
+///
+/// # Core Capabilities
+///
+///  - `set_webhook(peer, app_name, url)` -> register or update a webhook [`lsps5.set_webhook`]
+///  - `list_webhooks(peer)` -> retrieve all registered webhooks [`lsps5.list_webhooks`]
+///  - `remove_webhook(peer, name)` -> delete a webhook [`lsps5.remove_webhook`]
+///
+/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files
+/// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook
+/// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks
+/// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook
+/// [`LSPS5Validator`]: super::validator::LSPS5Validator
+pub struct LSPS5ClientHandler<ES: Deref, TP: Deref + Clone>
+where
+	ES::Target: EntropySource,
+	TP::Target: TimeProvider,
+{
+	pending_messages: Arc<MessageQueue>,
+	pending_events: Arc<EventQueue>,
+	entropy_source: ES,
+	per_peer_state: RwLock<HashMap<PublicKey, Mutex<PeerState<TP>>>>,
+	config: LSPS5ClientConfig,
+	time_provider: TP,
+}
+
+impl<ES: Deref, TP: Deref + Clone> LSPS5ClientHandler<ES, TP>
+where
+	ES::Target: EntropySource,
+	TP::Target: TimeProvider,
+{
+	/// Constructs an `LSPS5ClientHandler`.
+	pub(crate) fn new_with_time_provider(
+		entropy_source: ES, pending_messages: Arc<MessageQueue>, pending_events: Arc<EventQueue>,
+		config: LSPS5ClientConfig, time_provider: TP,
+	) -> Self {
+		Self {
+			pending_messages,
+			pending_events,
+			entropy_source,
+			per_peer_state: RwLock::new(new_hash_map()),
+			config,
+			time_provider,
+		}
+	}
+
+	fn with_peer_state<F, R>(&self, counterparty_node_id: PublicKey, f: F) -> R
+	where
+		F: FnOnce(&mut PeerState<TP>) -> R,
+	{
+		let mut outer_state_lock = self.per_peer_state.write().unwrap();
+		let inner_state_lock = outer_state_lock.entry(counterparty_node_id).or_insert(Mutex::new(
+			PeerState::new(self.config.response_max_age_secs, self.time_provider.clone()),
+		));
+		let mut peer_state_lock = inner_state_lock.lock().unwrap();
+
+		peer_state_lock.cleanup_expired_responses();
+
+		f(&mut *peer_state_lock)
+	}
+
+	/// Register or update a webhook endpoint under a human-readable name.
+	///
+	/// Sends a `lsps5.set_webhook` JSON-RPC request to the given LSP peer.
+	///
+	/// # Parameters
+	/// - `counterparty_node_id`: The LSP node ID to contact.
+	/// - `app_name`: A UTF-8 name for this webhook.
+	/// - `webhook_url`: HTTPS URL for push notifications.
+	///
+	/// # Returns
+	/// A unique `LSPSRequestId` for correlating the asynchronous response.
+	///
+	/// Response from the LSP peer will be provided asynchronously through a
+	/// [`LSPS5Response::SetWebhook`] or [`LSPS5Response::SetWebhookError`] message, and this client
+	/// will then enqueue either a [`WebhookRegistered`] or [`WebhookRegistrationFailed`] event.
+	///
+	/// **Note**: Ensure the app name is valid and its length does not exceed [`MAX_APP_NAME_LENGTH`].
+	/// Also ensure the URL is valid, has HTTPS protocol, its length does not exceed [`MAX_WEBHOOK_URL_LENGTH`]
+	/// and that the URL points to a public host.
+	///
+	/// [`MAX_WEBHOOK_URL_LENGTH`]: super::msgs::MAX_WEBHOOK_URL_LENGTH
+	/// [`MAX_APP_NAME_LENGTH`]: super::msgs::MAX_APP_NAME_LENGTH
+	/// [`WebhookRegistered`]: super::event::LSPS5ClientEvent::WebhookRegistered
+	/// [`WebhookRegistrationFailed`]: super::event::LSPS5ClientEvent::WebhookRegistrationFailed
+	/// [`LSPS5Response::SetWebhook`]: super::msgs::LSPS5Response::SetWebhook
+	/// [`LSPS5Response::SetWebhookError`]: super::msgs::LSPS5Response::SetWebhookError
+	pub fn set_webhook(
+		&self, counterparty_node_id: PublicKey, app_name: String, webhook_url: String,
+	) -> Result<LSPSRequestId, LSPS5Error> {
+		let app_name = LSPS5AppName::from_string(app_name)?;
+
+		let lsps_webhook_url = LSPS5WebhookUrl::from_string(webhook_url)?;
+
+		let request_id = generate_request_id(&self.entropy_source);
+
+		self.with_peer_state(counterparty_node_id, |peer_state| {
+			peer_state.pending_set_webhook_requests.insert(
+				request_id.clone(),
+				(
+					app_name.clone(),
+					lsps_webhook_url.clone(),
+					LSPSDateTime::new_from_duration_since_epoch(
+						self.time_provider.duration_since_epoch(),
+					),
+				),
+			);
+		});
+
+		let request =
+			LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook: lsps_webhook_url });
+
+		let message = LSPS5Message::Request(request_id.clone(), request);
+		self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message));
+
+		Ok(request_id)
+	}
+
+	/// List all webhook names currently registered with the LSP.
+	///
+	/// Sends a `lsps5.list_webhooks` JSON-RPC request to the peer.
+	///
+	/// # Parameters
+	/// - `counterparty_node_id`: The LSP node ID to query.
+	///
+	/// # Returns
+	/// A unique `LSPSRequestId` for correlating the asynchronous response.
+	///
+	/// Response from the LSP peer will be provided asynchronously through a
+	/// [`LSPS5Response::ListWebhooks`] message, and this client
+	/// will then enqueue a [`WebhooksListed`] event.
+	///
+	/// [`WebhooksListed`]: super::event::LSPS5ClientEvent::WebhooksListed
+	/// [`LSPS5Response::ListWebhooks`]: super::msgs::LSPS5Response::ListWebhooks
+	pub fn list_webhooks(&self, counterparty_node_id: PublicKey) -> LSPSRequestId {
+		let request_id = generate_request_id(&self.entropy_source);
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+
+		self.with_peer_state(counterparty_node_id, |peer_state| {
+			peer_state.pending_list_webhooks_requests.insert(request_id.clone(), now);
+		});
+
+		let request = LSPS5Request::ListWebhooks(ListWebhooksRequest {});
+		let message = LSPS5Message::Request(request_id.clone(), request);
+		self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message));
+
+		request_id
+	}
+
+	/// Remove a previously registered webhook by its name.
+	///
+	/// Sends a `lsps5.remove_webhook` JSON-RPC request to the peer.
+	///
+	/// # Parameters
+	/// - `counterparty_node_id`: The LSP node ID to contact.
+	/// - `app_name`: The name of the webhook to remove.
+	///
+	/// # Returns
+	/// A unique `LSPSRequestId` for correlating the asynchronous response.
+	///
+	/// Response from the LSP peer will be provided asynchronously through a
+	/// [`LSPS5Response::RemoveWebhook`] or [`LSPS5Response::RemoveWebhookError`] message, and this client
+	/// will then enqueue either a [`WebhookRemoved`] or [`WebhookRemovalFailed`] event.
+	///
+	/// [`WebhookRemoved`]: super::event::LSPS5ClientEvent::WebhookRemoved
+	/// [`WebhookRemovalFailed`]: super::event::LSPS5ClientEvent::WebhookRemovalFailed
+	/// [`LSPS5Response::RemoveWebhook`]: super::msgs::LSPS5Response::RemoveWebhook
+	/// [`LSPS5Response::RemoveWebhookError`]: super::msgs::LSPS5Response::RemoveWebhookError
+	pub fn remove_webhook(
+		&self, counterparty_node_id: PublicKey, app_name: String,
+	) -> Result<LSPSRequestId, LSPS5Error> {
+		let app_name = LSPS5AppName::from_string(app_name)?;
+
+		let request_id = generate_request_id(&self.entropy_source);
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+
+		self.with_peer_state(counterparty_node_id, |peer_state| {
+			peer_state
+				.pending_remove_webhook_requests
+				.insert(request_id.clone(), (app_name.clone(), now));
+		});
+
+		let request = LSPS5Request::RemoveWebhook(RemoveWebhookRequest { app_name });
+		let message = LSPS5Message::Request(request_id.clone(), request);
+		self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message));
+
+		Ok(request_id)
+	}
+
+	fn handle_message(
+		&self, message: LSPS5Message, counterparty_node_id: &PublicKey,
+	) -> Result<(), LightningError> {
+		let (request_id, response) = match message {
+			LSPS5Message::Request(_, _) => {
+				return Err(LightningError {
+					err: format!(
+						"Received unexpected request message from {}",
+						counterparty_node_id
+					),
+					action: ErrorAction::IgnoreAndLog(Level::Info),
+				});
+			},
+			LSPS5Message::Response(rid, resp) => (rid, resp),
+		};
+		let mut result: Result<(), LightningError> = Err(LightningError {
+			err: format!("Received LSPS5 response from unknown peer: {}", counterparty_node_id),
+			action: ErrorAction::IgnoreAndLog(Level::Error),
+		});
+		let event_queue_notifier = self.pending_events.notifier();
+		let handle_response = |peer_state: &mut PeerState<TP>| {
+			if let Some((app_name, webhook_url, _)) =
+				peer_state.pending_set_webhook_requests.remove(&request_id)
+			{
+				match &response {
+					LSPS5Response::SetWebhook(r) => {
+						event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistered {
+							counterparty_node_id: *counterparty_node_id,
+							num_webhooks: r.num_webhooks,
+							max_webhooks: r.max_webhooks,
+							no_change: r.no_change,
+							app_name,
+							url: webhook_url,
+							request_id,
+						});
+						result = Ok(());
+					},
+					LSPS5Response::SetWebhookError(e) => {
+						event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistrationFailed {
+							counterparty_node_id: *counterparty_node_id,
+							error: e.clone().into(),
+							app_name,
+							url: webhook_url,
+							request_id,
+						});
+						result = Ok(());
+					},
+					_ => {
+						result = Err(LightningError {
+							err: "Unexpected response type for SetWebhook".to_string(),
+							action: ErrorAction::IgnoreAndLog(Level::Error),
+						});
+					},
+				}
+			} else if peer_state.pending_list_webhooks_requests.remove(&request_id).is_some() {
+				match &response {
+					LSPS5Response::ListWebhooks(r) => {
+						event_queue_notifier.enqueue(LSPS5ClientEvent::WebhooksListed {
+							counterparty_node_id: *counterparty_node_id,
+							app_names: r.app_names.clone(),
+							max_webhooks: r.max_webhooks,
+							request_id,
+						});
+						result = Ok(());
+					},
+					_ => {
+						result = Err(LightningError {
+							err: "Unexpected response type for ListWebhooks".to_string(),
+							action: ErrorAction::IgnoreAndLog(Level::Error),
+						});
+					},
+				}
+			} else if let Some((app_name, _)) =
+				peer_state.pending_remove_webhook_requests.remove(&request_id)
+			{
+				match &response {
+					LSPS5Response::RemoveWebhook(_) => {
+						event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRemoved {
+							counterparty_node_id: *counterparty_node_id,
+							app_name,
+							request_id,
+						});
+						result = Ok(());
+					},
+					LSPS5Response::RemoveWebhookError(e) => {
+						event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRemovalFailed {
+							counterparty_node_id: *counterparty_node_id,
+							error: e.clone().into(),
+							app_name,
+							request_id,
+						});
+						result = Ok(());
+					},
+					_ => {
+						result = Err(LightningError {
+							err: "Unexpected response type for RemoveWebhook".to_string(),
+							action: ErrorAction::IgnoreAndLog(Level::Error),
+						});
+					},
+				}
+			} else {
+				result = Err(LightningError {
+					err: format!("Received response for unknown request ID: {}", request_id.0),
+					action: ErrorAction::IgnoreAndLog(Level::Info),
+				});
+			}
+		};
+		self.with_peer_state(*counterparty_node_id, handle_response);
+		result
+	}
+}
+
+impl<ES: Deref, TP: Deref + Clone> LSPSProtocolMessageHandler for LSPS5ClientHandler<ES, TP>
+where
+	ES::Target: EntropySource,
+	TP::Target: TimeProvider,
+{
+	type ProtocolMessage = LSPS5Message;
+	const PROTOCOL_NUMBER: Option<u16> = Some(5);
+
+	fn handle_message(
+		&self, message: Self::ProtocolMessage, lsp_node_id: &PublicKey,
+	) -> Result<(), LightningError> {
+		self.handle_message(message, lsp_node_id)
+	}
+}
+
+#[cfg(all(test, feature = "time"))]
+mod tests {
+	use core::time::Duration;
+
+	use super::*;
+	use crate::{
+		lsps0::ser::LSPSRequestId,
+		lsps5::{msgs::SetWebhookResponse, service::DefaultTimeProvider},
+		tests::utils::TestEntropy,
+	};
+	use bitcoin::{key::Secp256k1, secp256k1::SecretKey};
+
+	fn setup_test_client() -> (
+		LSPS5ClientHandler<Arc<TestEntropy>, Arc<DefaultTimeProvider>>,
+		Arc<MessageQueue>,
+		Arc<EventQueue>,
+		PublicKey,
+		PublicKey,
+	) {
+		let test_entropy_source = Arc::new(TestEntropy {});
+		let message_queue = Arc::new(MessageQueue::new());
+		let event_queue = Arc::new(EventQueue::new());
+		let client = LSPS5ClientHandler::new_with_time_provider(
+			test_entropy_source,
+			Arc::clone(&message_queue),
+			Arc::clone(&event_queue),
+			LSPS5ClientConfig::default(),
+			Arc::new(DefaultTimeProvider),
+		);
+
+		let secp = Secp256k1::new();
+		let secret_key_1 = SecretKey::from_slice(&[42u8; 32]).unwrap();
+		let secret_key_2 = SecretKey::from_slice(&[43u8; 32]).unwrap();
+		let peer_1 = PublicKey::from_secret_key(&secp, &secret_key_1);
+		let peer_2 = PublicKey::from_secret_key(&secp, &secret_key_2);
+
+		(client, message_queue, event_queue, peer_1, peer_2)
+	}
+
+	#[test]
+	fn test_per_peer_state_isolation() {
+		let (client, _, _, peer_1, peer_2) = setup_test_client();
+
+		let req_id_1 = client
+			.set_webhook(peer_1, "test-app-1".to_string(), "https://example.com/hook1".to_string())
+			.unwrap();
+		let req_id_2 = client
+			.set_webhook(peer_2, "test-app-2".to_string(), "https://example.com/hook2".to_string())
+			.unwrap();
+
+		{
+			let outer_state_lock = client.per_peer_state.read().unwrap();
+
+			let peer_1_state = outer_state_lock.get(&peer_1).unwrap().lock().unwrap();
+			assert!(peer_1_state.pending_set_webhook_requests.contains_key(&req_id_1));
+
+			let peer_2_state = outer_state_lock.get(&peer_2).unwrap().lock().unwrap();
+			assert!(peer_2_state.pending_set_webhook_requests.contains_key(&req_id_2));
+		}
+	}
+
+	#[test]
+	fn test_pending_request_tracking() {
+		let (client, _, _, peer, _) = setup_test_client();
+		const APP_NAME: &str = "test-app";
+		const WEBHOOK_URL: &str = "https://example.com/hook";
+		let lsps5_app_name = LSPS5AppName::from_string(APP_NAME.to_string()).unwrap();
+		let lsps5_webhook_url = LSPS5WebhookUrl::from_string(WEBHOOK_URL.to_string()).unwrap();
+		let set_req_id =
+			client.set_webhook(peer, APP_NAME.to_string(), WEBHOOK_URL.to_string()).unwrap();
+		let list_req_id = client.list_webhooks(peer);
+		let remove_req_id = client.remove_webhook(peer, "test-app".to_string()).unwrap();
+
+		{
+			let outer_state_lock = client.per_peer_state.read().unwrap();
+			let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap();
+			assert_eq!(
+				peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap(),
+				&(
+					lsps5_app_name.clone(),
+					lsps5_webhook_url,
+					peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap().2.clone()
+				)
+			);
+
+			assert!(peer_state.pending_list_webhooks_requests.contains_key(&list_req_id));
+
+			assert_eq!(
+				peer_state.pending_remove_webhook_requests.get(&remove_req_id).unwrap().0,
+				lsps5_app_name
+			);
+		}
+	}
+
+	#[test]
+	fn test_handle_response_clears_pending_state() {
+		let (client, _, _, peer, _) = setup_test_client();
+
+		let req_id = client
+			.set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string())
+			.unwrap();
+
+		let response = LSPS5Response::SetWebhook(SetWebhookResponse {
+			num_webhooks: 1,
+			max_webhooks: 5,
+			no_change: false,
+		});
+		let response_msg = LSPS5Message::Response(req_id.clone(), response);
+
+		{
+			let outer_state_lock = client.per_peer_state.read().unwrap();
+			let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap();
+			assert!(peer_state.pending_set_webhook_requests.contains_key(&req_id));
+		}
+
+		client.handle_message(response_msg, &peer).unwrap();
+
+		{
+			let outer_state_lock = client.per_peer_state.read().unwrap();
+			let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap();
+			assert!(!peer_state.pending_set_webhook_requests.contains_key(&req_id));
+		}
+	}
+
+	#[test]
+	fn test_cleanup_expired_responses() {
+		let (client, _, _, _, _) = setup_test_client();
+		let time_provider = &client.time_provider;
+		const OLD_APP_NAME: &str = "test-app-old";
+		const NEW_APP_NAME: &str = "test-app-new";
+		const WEBHOOK_URL: &str = "https://example.com/hook";
+		let lsps5_old_app_name = LSPS5AppName::from_string(OLD_APP_NAME.to_string()).unwrap();
+		let lsps5_new_app_name = LSPS5AppName::from_string(NEW_APP_NAME.to_string()).unwrap();
+		let lsps5_webhook_url = LSPS5WebhookUrl::from_string(WEBHOOK_URL.to_string()).unwrap();
+		let now = time_provider.duration_since_epoch();
+		let mut peer_state = PeerState::<Arc<DefaultTimeProvider>>::new(
+			Duration::from_secs(1800),
+			Arc::clone(time_provider),
+		);
+		peer_state.last_cleanup = Some(LSPSDateTime::new_from_duration_since_epoch(
+			now.checked_sub(Duration::from_secs(120)).unwrap(),
+		));
+
+		let old_request_id = LSPSRequestId("test:request:old".to_string());
+		let new_request_id = LSPSRequestId("test:request:new".to_string());
+
+		// Add an old request (should be removed during cleanup)
+		peer_state.pending_set_webhook_requests.insert(
+			old_request_id.clone(),
+			(
+				lsps5_old_app_name,
+				lsps5_webhook_url.clone(),
+				LSPSDateTime::new_from_duration_since_epoch(
+					now.checked_sub(Duration::from_secs(7200)).unwrap(),
+				),
+			), // 2 hours old
+		);
+
+		// Add a recent request (should be kept)
+		peer_state.pending_set_webhook_requests.insert(
+			new_request_id.clone(),
+			(
+				lsps5_new_app_name,
+				lsps5_webhook_url,
+				LSPSDateTime::new_from_duration_since_epoch(
+					now.checked_sub(Duration::from_secs(600)).unwrap(),
+				),
+			), // 10 minutes old
+		);
+
+		peer_state.cleanup_expired_responses();
+
+		assert!(!peer_state.pending_set_webhook_requests.contains_key(&old_request_id));
+		assert!(peer_state.pending_set_webhook_requests.contains_key(&new_request_id));
+
+		let cleanup_age = if let Some(last_cleanup) = peer_state.last_cleanup {
+			LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch())
+				.abs_diff(&last_cleanup)
+		} else {
+			0
+		};
+		assert!(cleanup_age < 10);
+	}
+
+	#[test]
+	fn test_unknown_request_id_handling() {
+		let (client, _message_queue, _, peer, _) = setup_test_client();
+
+		let _valid_req = client
+			.set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string())
+			.unwrap();
+
+		let unknown_req_id = LSPSRequestId("unknown:request:id".to_string());
+		let response = LSPS5Response::SetWebhook(SetWebhookResponse {
+			num_webhooks: 1,
+			max_webhooks: 5,
+			no_change: false,
+		});
+		let response_msg = LSPS5Message::Response(unknown_req_id, response);
+
+		let result = client.handle_message(response_msg, &peer);
+		assert!(result.is_err());
+		let error = result.unwrap_err();
+		assert!(error.err.to_lowercase().contains("unknown request id"));
+	}
+}
diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs
new file mode 100644
index 00000000000..7730428e5ce
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/event.rs
@@ -0,0 +1,213 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! Contains bLIP-55 / LSPS5 event types
+
+use crate::lsps0::ser::LSPSRequestId;
+use alloc::string::String;
+use alloc::vec::Vec;
+use bitcoin::secp256k1::PublicKey;
+use lightning::util::hash_tables::HashMap;
+
+use super::msgs::LSPS5AppName;
+use super::msgs::LSPS5Error;
+use super::msgs::LSPS5WebhookUrl;
+use super::msgs::WebhookNotification;
+
+/// An event which an bLIP-55 / LSPS5 server should take some action in response to.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LSPS5ServiceEvent {
+	/// A notification needs to be sent to a client.
+	///
+	/// This event is triggered when the LSP needs to notify a client about an event
+	/// via their registered webhook.
+	///
+	/// The LSP should send an HTTP POST to the [`url`], using the
+	/// JSON-serialized [`notification`] as the body and including the `headers`.
+	/// If the HTTP request fails, the LSP may implement a retry policy according to its
+	/// implementation preferences, but must respect rate-limiting as defined in
+	/// [`notification_cooldown_hours`].
+	///
+	/// The notification is signed using the LSP's node ID to ensure authenticity
+	/// when received by the client. The client verifies this signature using
+	/// [`validate`], which guards against replay attacks and tampering.
+	///
+	/// [`validate`]: super::validator::LSPS5Validator::validate
+	/// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours
+	/// [`url`]: super::msgs::LSPS5WebhookUrl
+	/// [`notification`]: super::msgs::WebhookNotification
+	SendWebhookNotification {
+		/// Client node ID to be notified.
+		counterparty_node_id: PublicKey,
+		/// [`App name`] to be notified.
+		///
+		/// This identifies which webhook registration should be notified.
+		///
+		/// [`App name`]: super::msgs::LSPS5AppName
+		app_name: LSPS5AppName,
+		/// URL to be called.
+		///
+		/// This is the [`webhook URL`] provided by the client during registration.
+		///
+		/// [`webhook URL`]: super::msgs::LSPS5WebhookUrl
+		url: LSPS5WebhookUrl,
+		/// Notification method with its parameters.
+		///
+		/// This contains the type of notification and any associated data to be sent to the client.
+		notification: WebhookNotification,
+		/// Headers to be included in the HTTP POST request.
+		///
+		/// This is a map of HTTP header key-value pairs. It will include:
+		/// - `"Content-Type"`: with a value like `"application/json"`.
+		/// - `"x-lsps5-timestamp"`: with the timestamp in RFC3339 format (`"YYYY-MM-DDThh:mm:ss.uuuZ"`).
+		/// - `"x-lsps5-signature"`: with the signature of the notification payload, signed using the LSP's node ID.
+		/// Other custom headers may also be included as needed.
+		headers: HashMap<String, String>,
+	},
+}
+
+/// An event which an LSPS5 client should take some action in response to.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LSPS5ClientEvent {
+	/// A webhook was successfully registered with the LSP.
+	///
+	/// This event is triggered when the LSP confirms successful registration
+	/// of a webhook via [`lsps5.set_webhook`].
+	///
+	/// If `no_change` is `false` (indicating the registered webhook is a new registration),
+	/// the LSP will also emit a [`SendWebhookNotification`] event with a [`webhook_registered`] notification
+	/// to notify the client about this registration.
+	///
+	/// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook
+	/// [`SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification
+	/// [`webhook_registered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered
+	WebhookRegistered {
+		/// The node id of the LSP that confirmed the registration.
+		counterparty_node_id: PublicKey,
+		/// Current number of webhooks registered for this client.
+		num_webhooks: u32,
+		/// Maximum number of webhooks allowed by LSP.
+		max_webhooks: u32,
+		/// Whether this was an unchanged registration (same app_name and URL).
+		/// If true, the LSP didn't send a webhook notification for this registration.
+		no_change: bool,
+		/// The app name that was registered.
+		app_name: LSPS5AppName,
+		/// The webhook URL that was registered.
+		url: LSPS5WebhookUrl,
+		/// The identifier of the issued bLIP-55 / LSPS5 webhook registration request.
+		///
+		/// This can be used to track which request this event corresponds to.
+		request_id: LSPSRequestId,
+	},
+
+	/// A webhook registration attempt failed.
+	///
+	/// This event is triggered when the LSP rejects a webhook registration
+	/// via [`lsps5.set_webhook`].
+	///
+	/// Possible errors:
+	/// - The [`app_name`] exceeds [`MAX_APP_NAME_LENGTH`] (error [`AppNameTooLong`]).
+	/// - The [`url`] exceeds [`MAX_WEBHOOK_URL_LENGTH`] (error [`WebhookUrlTooLong`]).
+	/// - The [`url`] uses an unsupported protocol. HTTPS is required (error [`UnsupportedProtocol`]).
+	/// - Maximum number of webhooks per client has been reached (error [`TooManyWebhooks`]). Remove a webhook before
+	///  registering a new one.
+	///
+	/// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook
+	/// [`app_name`]: super::msgs::LSPS5AppName
+	/// [`url`]: super::msgs::LSPS5WebhookUrl
+	/// [`MAX_APP_NAME_LENGTH`]: super::msgs::MAX_APP_NAME_LENGTH
+	/// [`MAX_WEBHOOK_URL_LENGTH`]: super::msgs::MAX_WEBHOOK_URL_LENGTH
+	/// [`AppNameTooLong`]: super::msgs::LSPS5ProtocolError::AppNameTooLong
+	/// [`WebhookUrlTooLong`]: super::msgs::LSPS5ProtocolError::WebhookUrlTooLong
+	/// [`UnsupportedProtocol`]: super::msgs::LSPS5ProtocolError::UnsupportedProtocol
+	/// [`TooManyWebhooks`]: super::msgs::LSPS5ProtocolError::TooManyWebhooks
+	WebhookRegistrationFailed {
+		/// The node id of the LSP that rejected the registration.
+		counterparty_node_id: PublicKey,
+		/// Error from the LSP.
+		error: LSPS5Error,
+		/// The app name that was attempted.
+		app_name: LSPS5AppName,
+		/// The webhook URL that was attempted.
+		url: LSPS5WebhookUrl,
+		/// The identifier of the issued bLIP-55 / LSPS5 webhook registration request.
+		///
+		/// This can be used to track which request this event corresponds to.
+		request_id: LSPSRequestId,
+	},
+
+	/// The list of registered webhooks was successfully retrieved.
+	///
+	/// This event is triggered when the LSP responds to a
+	/// [`lsps5.list_webhooks`] request.
+	///
+	/// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks
+	WebhooksListed {
+		/// The node id of the LSP that provided the list.
+		counterparty_node_id: PublicKey,
+		/// List of app names with registered webhooks.
+		app_names: Vec<LSPS5AppName>,
+		/// Maximum number of webhooks allowed by LSP.
+		max_webhooks: u32,
+		/// The identifier of the issued bLIP-55 / LSPS5 list webhooks request.
+		///
+		/// This can be used to track which request this event corresponds to.
+		request_id: LSPSRequestId,
+	},
+
+	/// A webhook was successfully removed.
+	///
+	/// This event is triggered when the LSP confirms successful removal
+	/// of a webhook via [`lsps5.remove_webhook`]. The webhook registration
+	/// has been deleted from the LSP's system and will no longer receive
+	/// notifications.
+	///
+	/// After this event, the app_name is free to be reused for a new webhook
+	/// registration if desired.
+	///
+	/// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook
+	WebhookRemoved {
+		/// The node id of the LSP that confirmed the removal.
+		counterparty_node_id: PublicKey,
+		/// The app name that was removed.
+		app_name: LSPS5AppName,
+		/// The identifier of the issued bLIP-55 / LSPS5 remove webhook request.
+		///
+		/// This can be used to track which request this event corresponds to.
+		request_id: LSPSRequestId,
+	},
+
+	/// A webhook removal attempt failed.
+	///
+	/// This event is triggered when the LSP rejects a webhook removal
+	/// via [`lsps5.remove_webhook`].
+	///
+	/// The most common error is [`LSPS5ProtocolError::AppNameNotFound`]
+	/// (error code [`LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE`]), which indicates
+	/// the given [`app_name`] was not found in the LSP's registration database.
+	///
+	/// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook
+	/// [`AppNameNotFound`]: super::msgs::LSPS5ProtocolError::AppNameNotFound
+	/// [`LSPS5ProtocolError::AppNameNotFound`]: super::msgs::LSPS5ProtocolError::AppNameNotFound
+	/// [`LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE`]: super::msgs::LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE
+	/// [`app_name`]: super::msgs::LSPS5AppName
+	WebhookRemovalFailed {
+		/// The node id of the LSP that rejected the removal.
+		counterparty_node_id: PublicKey,
+		/// Error from the LSP.
+		error: LSPS5Error,
+		/// The app name that was attempted to be removed.
+		app_name: LSPS5AppName,
+		/// The identifier of the issued bLIP-55 / LSPS5 remove webhook request.
+		///
+		/// This can be used to track which request this event corresponds to.
+		request_id: LSPSRequestId,
+	},
+}
diff --git a/lightning-liquidity/src/lsps5/mod.rs b/lightning-liquidity/src/lsps5/mod.rs
new file mode 100644
index 00000000000..62d64ddda39
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/mod.rs
@@ -0,0 +1,23 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! LSPS5 Webhook Registration Protocol Implementation
+//!
+//! Implements bLIP-55: LSP Protocol for Notification Webhook Registration
+//!
+//! This module provides functionality for Lightning Service Providers to send
+//! webhook notifications to their clients, and for clients to register webhooks
+//! with LSPs.
+
+pub mod client;
+pub mod event;
+pub mod msgs;
+pub mod service;
+pub mod url_utils;
+pub mod validator;
diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs
new file mode 100644
index 00000000000..6d3743a9a5a
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/msgs.rs
@@ -0,0 +1,943 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! LSPS5 message formats for webhook registration
+
+use crate::alloc::string::ToString;
+use crate::lsps0::ser::LSPSMessage;
+use crate::lsps0::ser::LSPSRequestId;
+use crate::lsps0::ser::LSPSResponseError;
+
+use super::url_utils::LSPSUrl;
+
+use lightning_types::string::UntrustedString;
+
+use serde::de::{self, Deserializer, MapAccess, Visitor};
+use serde::ser::SerializeMap;
+use serde::ser::SerializeStruct;
+use serde::Serializer;
+use serde::{Deserialize, Serialize};
+
+use alloc::string::String;
+use alloc::vec::Vec;
+
+use core::fmt;
+use core::fmt::Display;
+use core::ops::Deref;
+
+/// Maximum allowed length for an `app_name` (in bytes).
+pub const MAX_APP_NAME_LENGTH: usize = 64;
+
+/// Maximum allowed length for a webhook URL (in characters).
+pub const MAX_WEBHOOK_URL_LENGTH: usize = 1024;
+
+/// Either the app name or the webhook URL is too long.
+pub const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500;
+/// The provided URL could not be parsed.
+pub const LSPS5_URL_PARSE_ERROR_CODE: i32 = 501;
+/// The provided URL is not HTTPS.
+pub const LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 502;
+/// The client has too many webhooks registered.
+pub const LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 503;
+/// The app name was not found.
+pub const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010;
+/// An unknown error occurred.
+pub const LSPS5_UNKNOWN_ERROR_CODE: i32 = 1000;
+/// An error occurred during serialization of LSPS5 webhook notification.
+pub const LSPS5_SERIALIZATION_ERROR_CODE: i32 = 1001;
+
+pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook";
+pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks";
+pub(crate) const LSPS5_REMOVE_WEBHOOK_METHOD_NAME: &str = "lsps5.remove_webhook";
+
+pub(crate) const LSPS5_WEBHOOK_REGISTERED_NOTIFICATION: &str = "lsps5.webhook_registered";
+pub(crate) const LSPS5_PAYMENT_INCOMING_NOTIFICATION: &str = "lsps5.payment_incoming";
+pub(crate) const LSPS5_EXPIRY_SOON_NOTIFICATION: &str = "lsps5.expiry_soon";
+pub(crate) const LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION: &str =
+	"lsps5.liquidity_management_request";
+pub(crate) const LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION: &str = "lsps5.onion_message_incoming";
+
+/// Protocol errors defined in the LSPS5/bLIP-55 specification.
+///
+/// These errors are sent over JSON-RPC when protocol-level validation fails
+/// and correspond directly to error codes defined in the LSPS5 specification.
+/// LSPs must use these errors when rejecting client requests.
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub enum LSPS5ProtocolError {
+	/// App name exceeds the maximum allowed length of 64 bytes.
+	///
+	/// Sent when registering a webhook with an app name longer than MAX_APP_NAME_LENGTH.
+	AppNameTooLong,
+
+	/// Webhook URL exceeds the maximum allowed length of 1024 bytes.
+	///
+	/// Sent when registering a webhook with a URL longer than MAX_WEBHOOK_URL_LENGTH.
+	WebhookUrlTooLong,
+
+	/// Webhook URL is not a valid URL.
+	///
+	/// Sent when the provided webhook URL cannot be parsed or is syntactically invalid.
+	UrlParse,
+
+	/// Webhook URL does not use HTTPS.
+	///
+	/// The LSPS5 specification requires all webhook URLs to use HTTPS.
+	UnsupportedProtocol,
+
+	/// Client has reached their maximum allowed number of webhooks.
+	TooManyWebhooks,
+
+	/// The specified app name was not found in the registered webhooks.
+	///
+	/// Sent when trying to update or remove a webhook that doesn't exist.
+	AppNameNotFound,
+
+	/// An unspecified or unexpected error occurred.
+	UnknownError,
+
+	/// Error during serialization of LSPS5 webhook notification.
+	SerializationError,
+}
+
+impl LSPS5ProtocolError {
+	/// private code range so we never collide with the spec's codes
+	pub fn code(&self) -> i32 {
+		match self {
+			LSPS5ProtocolError::AppNameTooLong | LSPS5ProtocolError::WebhookUrlTooLong => {
+				LSPS5_TOO_LONG_ERROR_CODE
+			},
+			LSPS5ProtocolError::UrlParse => LSPS5_URL_PARSE_ERROR_CODE,
+			LSPS5ProtocolError::UnsupportedProtocol => LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE,
+			LSPS5ProtocolError::TooManyWebhooks => LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE,
+			LSPS5ProtocolError::AppNameNotFound => LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE,
+			LSPS5ProtocolError::UnknownError => LSPS5_UNKNOWN_ERROR_CODE,
+			LSPS5ProtocolError::SerializationError => LSPS5_SERIALIZATION_ERROR_CODE,
+		}
+	}
+	/// The error message for the LSPS5 protocol error.
+	pub fn message(&self) -> &'static str {
+		match self {
+			LSPS5ProtocolError::AppNameTooLong => "App name exceeds maximum length",
+			LSPS5ProtocolError::WebhookUrlTooLong => "Webhook URL exceeds maximum length",
+			LSPS5ProtocolError::UrlParse => "Error parsing URL",
+			LSPS5ProtocolError::UnsupportedProtocol => "Unsupported protocol: HTTPS is required",
+			LSPS5ProtocolError::TooManyWebhooks => "Maximum number of webhooks allowed per client",
+			LSPS5ProtocolError::AppNameNotFound => "App name not found",
+			LSPS5ProtocolError::UnknownError => "Unknown error",
+			LSPS5ProtocolError::SerializationError => {
+				"Error serializing LSPS5 webhook notification"
+			},
+		}
+	}
+}
+
+impl Serialize for LSPS5ProtocolError {
+	fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
+	where
+		S: Serializer,
+	{
+		let mut s = ser.serialize_struct("error", 2)?;
+		s.serialize_field("code", &self.code())?;
+		s.serialize_field("message", &self.message())?;
+		s.end()
+	}
+}
+
+/// Client-side validation and processing errors.
+///
+/// Unlike LSPS5ProtocolError, these errors are not part of the LSPS5 specification
+/// and are meant for internal use in the client implementation. They represent
+/// failures when parsing, validating, or processing webhook notifications.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum LSPS5ClientError {
+	/// Signature verification failed.
+	///
+	/// The cryptographic signature from the LSP node doesn't validate.
+	InvalidSignature,
+
+	/// Notification timestamp is too old or too far in the future.
+	///
+	/// LSPS5 requires timestamps to be within ±10 minutes of current time.
+	InvalidTimestamp,
+
+	/// Detected a reused notification signature.
+	///
+	/// Indicates a potential replay attack where a previously seen
+	/// notification signature was reused.
+	ReplayAttack,
+
+	/// Error during serialization of LSPS5 webhook notification.
+	SerializationError,
+}
+
+impl LSPS5ClientError {
+	const BASE: i32 = 100_000;
+	/// The error code for the client error.
+	pub fn code(&self) -> i32 {
+		use LSPS5ClientError::*;
+		match self {
+			InvalidSignature => Self::BASE + 1,
+			InvalidTimestamp => Self::BASE + 2,
+			ReplayAttack => Self::BASE + 3,
+			SerializationError => LSPS5_SERIALIZATION_ERROR_CODE,
+		}
+	}
+	/// The error message for the client error.
+	pub fn message(&self) -> &'static str {
+		use LSPS5ClientError::*;
+		match self {
+			InvalidSignature => "Invalid signature",
+			InvalidTimestamp => "Timestamp out of range",
+			ReplayAttack => "Replay attack detected",
+			SerializationError => "Error serializing LSPS5 webhook notification",
+		}
+	}
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+/// Combined error type for LSPS5 client and protocol errors.
+///
+/// This enum wraps both specification-defined protocol errors and
+/// client-side processing errors into a single error type for use
+/// throughout the LSPS5 implementation.
+pub enum LSPS5Error {
+	/// An error defined in the LSPS5 specification.
+	///
+	/// This represents errors that are part of the formal protocol.
+	Protocol(LSPS5ProtocolError),
+
+	/// A client-side processing error.
+	///
+	/// This represents errors that occur during client-side handling
+	/// of notifications or other validation.
+	Client(LSPS5ClientError),
+}
+
+impl From<LSPS5ProtocolError> for LSPS5Error {
+	fn from(e: LSPS5ProtocolError) -> Self {
+		LSPS5Error::Protocol(e)
+	}
+}
+impl From<LSPS5ClientError> for LSPS5Error {
+	fn from(e: LSPS5ClientError) -> Self {
+		LSPS5Error::Client(e)
+	}
+}
+
+impl From<LSPSResponseError> for LSPS5Error {
+	fn from(err: LSPSResponseError) -> Self {
+		LSPS5ProtocolError::from(err).into()
+	}
+}
+
+impl From<LSPSResponseError> for LSPS5ProtocolError {
+	fn from(err: LSPSResponseError) -> Self {
+		match err.code {
+			LSPS5_TOO_LONG_ERROR_CODE => LSPS5ProtocolError::AppNameTooLong,
+			LSPS5_URL_PARSE_ERROR_CODE => LSPS5ProtocolError::UrlParse,
+			LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE => LSPS5ProtocolError::UnsupportedProtocol,
+			LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE => LSPS5ProtocolError::TooManyWebhooks,
+			LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE => LSPS5ProtocolError::AppNameNotFound,
+			_ => LSPS5ProtocolError::UnknownError,
+		}
+	}
+}
+
+impl From<LSPS5ProtocolError> for LSPSResponseError {
+	fn from(e: LSPS5ProtocolError) -> Self {
+		LSPSResponseError { code: e.code(), message: e.message().into(), data: None }
+	}
+}
+
+impl From<LSPS5Error> for LSPSResponseError {
+	fn from(e: LSPS5Error) -> Self {
+		match e {
+			LSPS5Error::Protocol(p) => p.into(),
+			LSPS5Error::Client(c) => {
+				LSPSResponseError { code: c.code(), message: c.message().into(), data: None }
+			},
+		}
+	}
+}
+
+/// App name for LSPS5 webhooks.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct LSPS5AppName(UntrustedString);
+
+impl LSPS5AppName {
+	/// Create a new LSPS5 app name.
+	pub fn new(app_name: String) -> Result<Self, LSPS5Error> {
+		if app_name.len() > MAX_APP_NAME_LENGTH {
+			return Err(LSPS5ProtocolError::AppNameTooLong.into());
+		}
+		Ok(Self(UntrustedString(app_name)))
+	}
+
+	/// Create a new LSPS5 app name from a regular String.
+	pub fn from_string(app_name: String) -> Result<Self, LSPS5Error> {
+		Self::new(app_name)
+	}
+
+	/// Get the app name as a string.
+	pub fn as_str(&self) -> &str {
+		self
+	}
+}
+
+impl Deref for LSPS5AppName {
+	type Target = str;
+
+	fn deref(&self) -> &Self::Target {
+		&self.0 .0
+	}
+}
+
+impl Display for LSPS5AppName {
+	fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+		f.write_str(self)
+	}
+}
+
+impl Serialize for LSPS5AppName {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		serializer.serialize_str(self)
+	}
+}
+
+impl<'de> Deserialize<'de> for LSPS5AppName {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		let s = String::deserialize(deserializer)?;
+		if s.len() > MAX_APP_NAME_LENGTH {
+			return Err(serde::de::Error::custom("App name exceeds maximum length"));
+		}
+		Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
+	}
+}
+
+impl AsRef<str> for LSPS5AppName {
+	fn as_ref(&self) -> &str {
+		self
+	}
+}
+
+impl From<LSPS5AppName> for String {
+	fn from(app_name: LSPS5AppName) -> Self {
+		app_name.to_string()
+	}
+}
+
+/// URL for LSPS5 webhooks.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct LSPS5WebhookUrl(LSPSUrl);
+
+impl LSPS5WebhookUrl {
+	/// Create a new LSPS5 webhook URL.
+	pub fn new(url: String) -> Result<Self, LSPS5Error> {
+		if url.len() > MAX_WEBHOOK_URL_LENGTH {
+			return Err(LSPS5ProtocolError::WebhookUrlTooLong.into());
+		}
+		let parsed_url = LSPSUrl::parse(url)?;
+
+		Ok(Self(parsed_url))
+	}
+
+	/// Create a new LSPS5 webhook URL from a regular String.
+	pub fn from_string(url: String) -> Result<Self, LSPS5Error> {
+		Self::new(url)
+	}
+
+	/// Get the webhook URL as a string.
+	pub fn as_str(&self) -> &str {
+		self
+	}
+}
+
+impl Deref for LSPS5WebhookUrl {
+	type Target = str;
+
+	fn deref(&self) -> &Self::Target {
+		self.0.url()
+	}
+}
+
+impl Display for LSPS5WebhookUrl {
+	fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+		f.write_str(self) // Using Deref
+	}
+}
+
+impl Serialize for LSPS5WebhookUrl {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		serializer.serialize_str(self)
+	}
+}
+
+impl<'de> Deserialize<'de> for LSPS5WebhookUrl {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		let s = String::deserialize(deserializer)?;
+		if s.len() > MAX_WEBHOOK_URL_LENGTH {
+			return Err(serde::de::Error::custom("Webhook URL exceeds maximum length"));
+		}
+		Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
+	}
+}
+
+impl AsRef<str> for LSPS5WebhookUrl {
+	fn as_ref(&self) -> &str {
+		self
+	}
+}
+
+impl From<LSPS5WebhookUrl> for String {
+	fn from(url: LSPS5WebhookUrl) -> Self {
+		url.to_string()
+	}
+}
+
+/// Parameters for `lsps5.set_webhook` request.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct SetWebhookRequest {
+	/// Human-readable name for the webhook.
+	pub app_name: LSPS5AppName,
+	/// URL of the webhook.
+	pub webhook: LSPS5WebhookUrl,
+}
+
+/// Response for `lsps5.set_webhook`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct SetWebhookResponse {
+	/// Current number of webhooks registered for this client.
+	pub num_webhooks: u32,
+	/// Maximum number of webhooks allowed by LSP.
+	pub max_webhooks: u32,
+	/// Whether this is an unchanged registration.
+	pub no_change: bool,
+}
+
+/// Parameters for `lsps5.list_webhooks` request.
+#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
+pub struct ListWebhooksRequest {}
+
+/// Response for `lsps5.list_webhooks`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ListWebhooksResponse {
+	/// List of app_names with registered webhooks.
+	pub app_names: Vec<LSPS5AppName>,
+	/// Maximum number of webhooks allowed by LSP.
+	pub max_webhooks: u32,
+}
+
+/// Parameters for `lsps5.remove_webhook` request.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RemoveWebhookRequest {
+	/// App name identifying the webhook to remove.
+	pub app_name: LSPS5AppName,
+}
+
+/// Response for `lsps5.remove_webhook`.
+#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
+pub struct RemoveWebhookResponse {}
+
+/// Webhook notification methods defined in LSPS5.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum WebhookNotificationMethod {
+	/// Webhook has been successfully registered.
+	LSPS5WebhookRegistered,
+	/// Client has payments pending to be received.
+	LSPS5PaymentIncoming,
+	/// HTLC or time-bound contract is about to expire.
+	LSPS5ExpirySoon {
+		/// Block height when timeout occurs and the LSP would be forced to close the channel
+		timeout: u32,
+	},
+	/// LSP wants to take back some liquidity.
+	LSPS5LiquidityManagementRequest,
+	/// Client has onion messages pending.
+	LSPS5OnionMessageIncoming,
+}
+
+/// Webhook notification payload.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WebhookNotification {
+	/// Notification method with parameters.
+	pub method: WebhookNotificationMethod,
+}
+
+impl WebhookNotification {
+	/// Create a new webhook notification.
+	pub fn new(method: WebhookNotificationMethod) -> Self {
+		Self { method }
+	}
+
+	/// Create a webhook_registered notification.
+	pub fn webhook_registered() -> Self {
+		Self::new(WebhookNotificationMethod::LSPS5WebhookRegistered)
+	}
+
+	/// Create a payment_incoming notification.
+	pub fn payment_incoming() -> Self {
+		Self::new(WebhookNotificationMethod::LSPS5PaymentIncoming)
+	}
+
+	/// Create an expiry_soon notification.
+	pub fn expiry_soon(timeout: u32) -> Self {
+		Self::new(WebhookNotificationMethod::LSPS5ExpirySoon { timeout })
+	}
+
+	/// Create a liquidity_management_request notification.
+	pub fn liquidity_management_request() -> Self {
+		Self::new(WebhookNotificationMethod::LSPS5LiquidityManagementRequest)
+	}
+
+	/// Create an onion_message_incoming notification.
+	pub fn onion_message_incoming() -> Self {
+		Self::new(WebhookNotificationMethod::LSPS5OnionMessageIncoming)
+	}
+}
+
+impl Serialize for WebhookNotification {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		let mut map = serializer.serialize_map(Some(3))?;
+		map.serialize_entry("jsonrpc", "2.0")?;
+
+		let method_name = match &self.method {
+			WebhookNotificationMethod::LSPS5WebhookRegistered => {
+				LSPS5_WEBHOOK_REGISTERED_NOTIFICATION
+			},
+			WebhookNotificationMethod::LSPS5PaymentIncoming => LSPS5_PAYMENT_INCOMING_NOTIFICATION,
+			WebhookNotificationMethod::LSPS5ExpirySoon { .. } => LSPS5_EXPIRY_SOON_NOTIFICATION,
+			WebhookNotificationMethod::LSPS5LiquidityManagementRequest => {
+				LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION
+			},
+			WebhookNotificationMethod::LSPS5OnionMessageIncoming => {
+				LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION
+			},
+		};
+		map.serialize_entry("method", &method_name)?;
+
+		let params = match &self.method {
+			WebhookNotificationMethod::LSPS5WebhookRegistered => serde_json::json!({}),
+			WebhookNotificationMethod::LSPS5PaymentIncoming => serde_json::json!({}),
+			WebhookNotificationMethod::LSPS5ExpirySoon { timeout } => {
+				serde_json::json!({ "timeout": timeout })
+			},
+			WebhookNotificationMethod::LSPS5LiquidityManagementRequest => serde_json::json!({}),
+			WebhookNotificationMethod::LSPS5OnionMessageIncoming => serde_json::json!({}),
+		};
+		map.serialize_entry("params", &params)?;
+
+		map.end()
+	}
+}
+
+impl<'de> Deserialize<'de> for WebhookNotification {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: Deserializer<'de>,
+	{
+		struct WebhookNotificationVisitor;
+
+		impl<'de> Visitor<'de> for WebhookNotificationVisitor {
+			type Value = WebhookNotification;
+
+			fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+				formatter.write_str("a valid LSPS5 WebhookNotification object")
+			}
+
+			fn visit_map<V>(self, mut map: V) -> Result<WebhookNotification, V::Error>
+			where
+				V: MapAccess<'de>,
+			{
+				let mut jsonrpc: Option<String> = None;
+				let mut method: Option<String> = None;
+				let mut params: Option<serde_json::Value> = None;
+
+				while let Some(key) = map.next_key::<&str>()? {
+					match key {
+						"jsonrpc" => jsonrpc = Some(map.next_value()?),
+						"method" => method = Some(map.next_value()?),
+						"params" => params = Some(map.next_value()?),
+						_ => {
+							let _: serde::de::IgnoredAny = map.next_value()?;
+						},
+					}
+				}
+
+				let jsonrpc = jsonrpc.ok_or_else(|| de::Error::missing_field("jsonrpc"))?;
+				if jsonrpc != "2.0" {
+					return Err(de::Error::custom("Invalid jsonrpc version"));
+				}
+				let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
+				let params = params.ok_or_else(|| de::Error::missing_field("params"))?;
+
+				let method = match method.as_str() {
+					LSPS5_WEBHOOK_REGISTERED_NOTIFICATION => {
+						WebhookNotificationMethod::LSPS5WebhookRegistered
+					},
+					LSPS5_PAYMENT_INCOMING_NOTIFICATION => {
+						WebhookNotificationMethod::LSPS5PaymentIncoming
+					},
+					LSPS5_EXPIRY_SOON_NOTIFICATION => {
+						if let Some(timeout) = params.get("timeout").and_then(|t| t.as_u64()) {
+							WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 }
+						} else {
+							return Err(de::Error::custom(
+								"Missing or invalid timeout parameter for expiry_soon notification",
+							));
+						}
+					},
+					LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION => {
+						WebhookNotificationMethod::LSPS5LiquidityManagementRequest
+					},
+					LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION => {
+						WebhookNotificationMethod::LSPS5OnionMessageIncoming
+					},
+					_ => return Err(de::Error::custom(format!("Unknown method: {}", method))),
+				};
+
+				Ok(WebhookNotification { method })
+			}
+		}
+
+		deserializer.deserialize_map(WebhookNotificationVisitor)
+	}
+}
+
+/// An LSPS5 protocol request.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum LSPS5Request {
+	/// Register or update a webhook.
+	SetWebhook(SetWebhookRequest),
+	/// List all registered webhooks.
+	ListWebhooks(ListWebhooksRequest),
+	/// Remove a webhook.
+	RemoveWebhook(RemoveWebhookRequest),
+}
+
+/// An LSPS5 protocol response.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum LSPS5Response {
+	/// Response to [`SetWebhook`](SetWebhookRequest) request.
+	SetWebhook(SetWebhookResponse),
+	/// Error response to [`SetWebhook`](SetWebhookRequest) request.
+	SetWebhookError(LSPSResponseError),
+	/// Response to [`ListWebhooks`](ListWebhooksRequest) request.
+	ListWebhooks(ListWebhooksResponse),
+	/// Response to [`RemoveWebhook`](RemoveWebhookRequest) request.
+	RemoveWebhook(RemoveWebhookResponse),
+	/// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request.
+	RemoveWebhookError(LSPSResponseError),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+/// An LSPS5 protocol message.
+pub enum LSPS5Message {
+	/// A request variant.
+	Request(LSPSRequestId, LSPS5Request),
+	/// A response variant.
+	Response(LSPSRequestId, LSPS5Response),
+}
+
+impl TryFrom<LSPSMessage> for LSPS5Message {
+	type Error = ();
+
+	fn try_from(message: LSPSMessage) -> Result<Self, Self::Error> {
+		match message {
+			LSPSMessage::LSPS5(message) => Ok(message),
+			_ => Err(()),
+		}
+	}
+}
+
+impl From<LSPS5Message> for LSPSMessage {
+	fn from(message: LSPS5Message) -> Self {
+		LSPSMessage::LSPS5(message)
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use super::*;
+	use crate::alloc::string::ToString;
+
+	#[test]
+	fn webhook_notification_serialization() {
+		let notification = WebhookNotification::webhook_registered();
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+
+		let notification = WebhookNotification::expiry_soon(144);
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+	}
+
+	#[test]
+	fn parse_set_webhook_request() {
+		let json_str = r#"{"app_name":"my_app","webhook":"https://example.com/webhook"}"#;
+		let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap();
+		assert_eq!(request.app_name, LSPS5AppName::new("my_app".to_string()).unwrap());
+		assert_eq!(
+			request.webhook,
+			LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap()
+		);
+	}
+
+	#[test]
+	fn parse_set_webhook_response() {
+		let json_str = r#"{"num_webhooks":1,"max_webhooks":5,"no_change":false}"#;
+		let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap();
+		assert_eq!(response.num_webhooks, 1);
+		assert_eq!(response.max_webhooks, 5);
+		assert_eq!(response.no_change, false);
+	}
+
+	#[test]
+	fn parse_list_webhooks_response() {
+		let json_str = r#"{"app_names":["app1","app2"],"max_webhooks":5}"#;
+		let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap();
+		let app1 = LSPS5AppName::new("app1".to_string()).unwrap();
+		let app2 = LSPS5AppName::new("app2".to_string()).unwrap();
+		assert_eq!(response.app_names, vec![app1, app2]);
+		assert_eq!(response.max_webhooks, 5);
+	}
+
+	#[test]
+	fn parse_empty_requests_responses() {
+		let json_str = r#"{}"#;
+		let _list_req: ListWebhooksRequest = serde_json::from_str(json_str).unwrap();
+		let _remove_resp: RemoveWebhookResponse = serde_json::from_str(json_str).unwrap();
+	}
+
+	#[test]
+	fn spec_example_set_webhook_request() {
+		let json_str = r#"{"app_name":"My LSPS-Compliant Lightning Client","webhook":"https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"}"#;
+		let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap();
+		assert_eq!(
+			request.app_name,
+			LSPS5AppName::new("My LSPS-Compliant Lightning Client".to_string()).unwrap()
+		);
+		assert_eq!(
+			request.webhook,
+			LSPS5WebhookUrl::new(
+				"https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"
+					.to_string()
+			)
+			.unwrap()
+		);
+	}
+
+	#[test]
+	fn spec_example_set_webhook_response() {
+		let json_str = r#"{"num_webhooks":2,"max_webhooks":4,"no_change":false}"#;
+		let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap();
+		assert_eq!(response.num_webhooks, 2);
+		assert_eq!(response.max_webhooks, 4);
+		assert_eq!(response.no_change, false);
+	}
+
+	#[test]
+	fn spec_example_list_webhooks_response() {
+		let json_str = r#"{"app_names":["My LSPS-Compliant Lightning Wallet","Another Wallet With The Same Signing Device"],"max_webhooks":42}"#;
+		let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap();
+		let app1 = LSPS5AppName::new("My LSPS-Compliant Lightning Wallet".to_string()).unwrap();
+		let app2 =
+			LSPS5AppName::new("Another Wallet With The Same Signing Device".to_string()).unwrap();
+		assert_eq!(response.app_names, vec![app1, app2]);
+		assert_eq!(response.max_webhooks, 42);
+	}
+
+	#[test]
+	fn spec_example_remove_webhook_request() {
+		let json_str = r#"{"app_name":"Another Wallet With The Same Signig Device"}"#;
+		let request: RemoveWebhookRequest = serde_json::from_str(json_str).unwrap();
+		assert_eq!(
+			request.app_name,
+			LSPS5AppName::new("Another Wallet With The Same Signig Device".to_string()).unwrap()
+		);
+	}
+
+	#[test]
+	fn spec_example_webhook_notifications() {
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#;
+		let notification: WebhookNotification = serde_json::from_str(json_str).unwrap();
+		assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered);
+
+		let notification = WebhookNotification::payment_incoming();
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.payment_incoming","params":{}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+
+		let notification = WebhookNotification::expiry_soon(144);
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+
+		let notification = WebhookNotification::liquidity_management_request();
+		let json_str =
+			r#"{"jsonrpc":"2.0","method":"lsps5.liquidity_management_request","params":{}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+
+		let notification = WebhookNotification::onion_message_incoming();
+		let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.onion_message_incoming","params":{}}"#;
+		assert_eq!(json_str, serde_json::json!(notification).to_string());
+	}
+
+	#[test]
+	fn test_url_security_validation() {
+		let urls_that_should_throw = [
+			"test-app",
+			"http://example.com/webhook",
+			"ftp://example.com/webhook",
+			"ws://example.com/webhook",
+			"ws+unix://example.com/webhook",
+			"ws+unix:/example.com/webhook",
+			"ws+unix://example.com/webhook?param=value",
+			"ws+unix:/example.com/webhook?param=value",
+		];
+
+		for url_str in urls_that_should_throw.iter() {
+			match LSPS5WebhookUrl::new(url_str.to_string()) {
+				Ok(_) => panic!("Expected error"),
+				Err(e) => {
+					let protocol_error = match e {
+						LSPS5Error::Protocol(err) => err,
+						_ => panic!("Expected protocol error"),
+					};
+					let code = protocol_error.code();
+					assert!(
+						code == LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE
+							|| code == LSPS5_URL_PARSE_ERROR_CODE
+					);
+				},
+			}
+		}
+	}
+
+	#[test]
+	fn test_webhook_notification_parameter_binding() {
+		let notification = WebhookNotification::expiry_soon(144);
+		if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = notification.method {
+			assert_eq!(timeout, 144);
+		} else {
+			panic!("Expected LSPS5ExpirySoon variant");
+		}
+
+		let json = serde_json::to_string(&notification).unwrap();
+		assert_eq!(
+			json,
+			r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#
+		);
+		let deserialized: WebhookNotification = serde_json::from_str(&json).unwrap();
+		if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = deserialized.method {
+			assert_eq!(timeout, 144);
+		} else {
+			panic!("Expected LSPS5ExpirySoon variant after deserialization");
+		}
+	}
+
+	#[test]
+	fn test_missing_parameter_error() {
+		let json_without_timeout = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{}}"#;
+
+		let result: Result<WebhookNotification, _> = serde_json::from_str(json_without_timeout);
+		assert!(result.is_err(), "Should fail when timeout parameter is missing");
+
+		let err = result.unwrap_err().to_string();
+		assert!(
+			err.contains("Missing or invalid timeout parameter"),
+			"Error should mention missing parameter: {}",
+			err
+		);
+	}
+
+	#[test]
+	fn test_notification_round_trip_all_types() {
+		let notifications = vec![
+			WebhookNotification::webhook_registered(),
+			WebhookNotification::payment_incoming(),
+			WebhookNotification::expiry_soon(123),
+			WebhookNotification::liquidity_management_request(),
+			WebhookNotification::onion_message_incoming(),
+		];
+
+		for original in notifications {
+			let json = serde_json::to_string(&original).unwrap();
+			let deserialized: WebhookNotification = serde_json::from_str(&json).unwrap();
+
+			assert_eq!(original, deserialized);
+
+			if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout: original_timeout } =
+				original.method
+			{
+				if let WebhookNotificationMethod::LSPS5ExpirySoon {
+					timeout: deserialized_timeout,
+				} = deserialized.method
+				{
+					assert_eq!(original_timeout, deserialized_timeout);
+				} else {
+					panic!("Expected LSPS5ExpirySoon after deserialization");
+				}
+			}
+		}
+	}
+
+	#[test]
+	fn test_all_notification_methods_from_spec() {
+		let methods = [
+			("lsps5.webhook_registered", WebhookNotificationMethod::LSPS5WebhookRegistered, "{}"),
+			("lsps5.payment_incoming", WebhookNotificationMethod::LSPS5PaymentIncoming, "{}"),
+			(
+				"lsps5.expiry_soon",
+				WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 144 },
+				"{\"timeout\":144}",
+			),
+			(
+				"lsps5.liquidity_management_request",
+				WebhookNotificationMethod::LSPS5LiquidityManagementRequest,
+				"{}",
+			),
+			(
+				"lsps5.onion_message_incoming",
+				WebhookNotificationMethod::LSPS5OnionMessageIncoming,
+				"{}",
+			),
+		];
+
+		for (method_name, method_enum, params_json) in methods {
+			let json = format!(
+				r#"{{"jsonrpc":"2.0","method":"{}","params":{}}}"#,
+				method_name, params_json
+			);
+
+			let notification: WebhookNotification = serde_json::from_str(&json).unwrap();
+
+			assert_eq!(notification.method, method_enum);
+
+			let serialized = serde_json::to_string(&notification).unwrap();
+			assert!(serialized.contains(&format!("\"method\":\"{}\"", method_name)));
+
+			if method_name == "lsps5.expiry_soon" {
+				assert!(serialized.contains("\"timeout\":144"));
+			}
+		}
+	}
+}
diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs
new file mode 100644
index 00000000000..88026c1e6e9
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/service.rs
@@ -0,0 +1,542 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! Service implementation for LSPS5 webhook registration.
+
+use crate::alloc::string::ToString;
+use crate::events::EventQueue;
+use crate::lsps0::ser::{LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId};
+use crate::lsps5::msgs::{
+	ListWebhooksRequest, ListWebhooksResponse, RemoveWebhookRequest, RemoveWebhookResponse,
+	SetWebhookRequest, SetWebhookResponse, WebhookNotification, WebhookNotificationMethod,
+};
+use crate::message_queue::MessageQueue;
+use crate::prelude::hash_map::Entry;
+use crate::prelude::*;
+use crate::sync::{Arc, Mutex};
+
+use bitcoin::secp256k1::{PublicKey, SecretKey};
+
+use lightning::ln::channelmanager::AChannelManager;
+use lightning::ln::msgs::{ErrorAction, LightningError};
+use lightning::util::logger::Level;
+use lightning::util::message_signing;
+
+use core::ops::Deref;
+use core::time::Duration;
+
+use alloc::string::String;
+use alloc::vec::Vec;
+
+use super::event::LSPS5ServiceEvent;
+use super::msgs::{
+	LSPS5AppName, LSPS5Message, LSPS5ProtocolError, LSPS5Request, LSPS5Response, LSPS5WebhookUrl,
+};
+
+/// Minimum number of days to retain webhooks after a client's last channel is closed.
+pub const MIN_WEBHOOK_RETENTION_DAYS: Duration = Duration::from_secs(30 * 24 * 60 * 60);
+/// Interval for pruning stale webhooks.
+pub const PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS: Duration = Duration::from_secs(24 * 60 * 60);
+
+/// A stored webhook.
+#[derive(Debug, Clone)]
+struct StoredWebhook {
+	_app_name: LSPS5AppName,
+	url: LSPS5WebhookUrl,
+	_counterparty_node_id: PublicKey,
+	last_used: LSPSDateTime,
+	last_notification_sent: HashMap<WebhookNotificationMethod, LSPSDateTime>,
+}
+
+/// Trait defining a time provider for LSPS5 service.
+///
+/// This trait is used to provide the current time for LSPS5 service operations
+/// and to convert between timestamps and durations.
+pub trait TimeProvider {
+	/// Get the current time as a duration since the Unix epoch.
+	fn duration_since_epoch(&self) -> Duration;
+}
+
+/// Default time provider using the system clock.
+#[derive(Clone, Debug)]
+#[cfg(feature = "time")]
+pub struct DefaultTimeProvider;
+
+#[cfg(feature = "time")]
+impl TimeProvider for DefaultTimeProvider {
+	fn duration_since_epoch(&self) -> Duration {
+		use std::time::{SystemTime, UNIX_EPOCH};
+		SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch")
+	}
+}
+
+/// Server-side configuration options for LSPS5 Webhook Registration.
+#[derive(Clone, Debug)]
+pub struct LSPS5ServiceConfig {
+	/// Maximum number of webhooks allowed per client.
+	pub max_webhooks_per_client: u32,
+	/// Signing key for LSP notifications.
+	pub signing_key: SecretKey,
+	/// Minimum time between sending the same notification type in hours (default: 24)
+	pub notification_cooldown_hours: Duration,
+}
+
+/// Service-side handler for the [`bLIP-55 / LSPS5`] webhook registration protocol.
+///
+/// Runs on the LSP (server) side. Stores and manages client-registered webhooks,
+/// enforces per-client limits and retention policies, and emits signed JSON-RPC
+/// notifications to each webhook endpoint when events occur.
+///
+/// # Core Responsibilities
+/// - Handle incoming JSON-RPC requests:
+///   - `lsps5.set_webhook` -> insert or replace a webhook, enforce [`max_webhooks_per_client`],
+/// and send an initial [`lsps5.webhook_registered`] notification if new or changed.
+///   - `lsps5.list_webhooks` -> return all registered [`app_name`]s via response.
+///   - `lsps5.remove_webhook` -> delete a named webhook or return [`app_name_not_found`] error.
+/// - Prune stale webhooks after a client has no open channels and no activity for at least
+/// [`MIN_WEBHOOK_RETENTION_DAYS`].
+/// - Rate-limit repeat notifications of the same method to a client by
+///   [`notification_cooldown_hours`].
+/// - Sign and enqueue outgoing webhook notifications:
+///   - Construct JSON-RPC 2.0 Notification objects [`WebhookNotification`],
+///   - Timestamp and LN-style zbase32-sign each payload,
+///   - Emit [`LSPS5ServiceEvent::SendWebhookNotification`] with HTTP headers.
+///
+/// # Security & Spec Compliance
+/// - All notifications are signed with the LSP's node key according to bLIP-50/LSPS0.
+/// - Clients must validate signature, timestamp (±10 min), and replay protection via
+///   `LSPS5ClientHandler::parse_webhook_notification`.
+/// - Webhook endpoints use only HTTPS and must guard against unauthorized calls.
+///
+/// [`bLIP-55 / LSPS5`]: https://github.com/lightning/blips/pull/55/files
+/// [`max_webhooks_per_client`]: super::service::LSPS5ServiceConfig::max_webhooks_per_client
+/// [`app_name_not_found`]: super::msgs::LSPS5ProtocolError::AppNameNotFound
+/// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours
+/// [`WebhookNotification`]: super::msgs::WebhookNotification
+/// [`LSPS5ServiceEvent::SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification
+/// [`app_name`]: super::msgs::LSPS5AppName
+/// [`lsps5.webhook_registered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered
+pub struct LSPS5ServiceHandler<CM: Deref, TP: Deref>
+where
+	CM::Target: AChannelManager,
+	TP::Target: TimeProvider,
+{
+	config: LSPS5ServiceConfig,
+	webhooks: Mutex<HashMap<PublicKey, HashMap<LSPS5AppName, StoredWebhook>>>,
+	event_queue: Arc<EventQueue>,
+	pending_messages: Arc<MessageQueue>,
+	time_provider: TP,
+	channel_manager: CM,
+	last_pruning: Mutex<Option<LSPSDateTime>>,
+}
+
+impl<CM: Deref, TP: Deref> LSPS5ServiceHandler<CM, TP>
+where
+	CM::Target: AChannelManager,
+	TP::Target: TimeProvider,
+{
+	/// Constructs a `LSPS5ServiceHandler` using the given time provider.
+	pub(crate) fn new_with_time_provider(
+		event_queue: Arc<EventQueue>, pending_messages: Arc<MessageQueue>, channel_manager: CM,
+		config: LSPS5ServiceConfig, time_provider: TP,
+	) -> Self {
+		assert!(config.max_webhooks_per_client > 0, "`max_webhooks_per_client` must be > 0");
+		Self {
+			config,
+			webhooks: Mutex::new(new_hash_map()),
+			event_queue,
+			pending_messages,
+			time_provider,
+			channel_manager,
+			last_pruning: Mutex::new(None),
+		}
+	}
+
+	fn check_prune_stale_webhooks(&self) {
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+		let should_prune = {
+			let last_pruning = self.last_pruning.lock().unwrap();
+			last_pruning.as_ref().map_or(true, |last_time| {
+				now.abs_diff(&last_time) > PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs()
+			})
+		};
+
+		if should_prune {
+			self.prune_stale_webhooks();
+		}
+	}
+
+	fn handle_set_webhook(
+		&self, counterparty_node_id: PublicKey, request_id: LSPSRequestId,
+		params: SetWebhookRequest,
+	) -> Result<(), LightningError> {
+		self.check_prune_stale_webhooks();
+
+		let mut webhooks = self.webhooks.lock().unwrap();
+
+		let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map);
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+
+		let num_webhooks = client_webhooks.len();
+		let mut no_change = false;
+		match client_webhooks.entry(params.app_name.clone()) {
+			Entry::Occupied(mut entry) => {
+				no_change = entry.get().url == params.webhook;
+				let (last_used, last_notification_sent) = if no_change {
+					(entry.get().last_used.clone(), entry.get().last_notification_sent.clone())
+				} else {
+					(now, new_hash_map())
+				};
+				entry.insert(StoredWebhook {
+					_app_name: params.app_name.clone(),
+					url: params.webhook.clone(),
+					_counterparty_node_id: counterparty_node_id,
+					last_used,
+					last_notification_sent,
+				});
+			},
+			Entry::Vacant(entry) => {
+				if num_webhooks >= self.config.max_webhooks_per_client as usize {
+					let error = LSPS5ProtocolError::TooManyWebhooks;
+					let msg = LSPS5Message::Response(
+						request_id,
+						LSPS5Response::SetWebhookError(error.clone().into()),
+					)
+					.into();
+					self.pending_messages.enqueue(&counterparty_node_id, msg);
+					return Err(LightningError {
+						err: error.message().into(),
+						action: ErrorAction::IgnoreAndLog(Level::Info),
+					});
+				}
+
+				entry.insert(StoredWebhook {
+					_app_name: params.app_name.clone(),
+					url: params.webhook.clone(),
+					_counterparty_node_id: counterparty_node_id,
+					last_used: now,
+					last_notification_sent: new_hash_map(),
+				});
+			},
+		}
+
+		if !no_change {
+			self.send_webhook_registered_notification(
+				counterparty_node_id,
+				params.app_name,
+				params.webhook,
+			)
+			.map_err(|e| {
+				let msg = LSPS5Message::Response(
+					request_id.clone(),
+					LSPS5Response::SetWebhookError(e.clone().into()),
+				)
+				.into();
+				self.pending_messages.enqueue(&counterparty_node_id, msg);
+				LightningError {
+					err: e.message().into(),
+					action: ErrorAction::IgnoreAndLog(Level::Info),
+				}
+			})?;
+		}
+
+		let msg = LSPS5Message::Response(
+			request_id,
+			LSPS5Response::SetWebhook(SetWebhookResponse {
+				num_webhooks: client_webhooks.len() as u32,
+				max_webhooks: self.config.max_webhooks_per_client,
+				no_change,
+			}),
+		)
+		.into();
+		self.pending_messages.enqueue(&counterparty_node_id, msg);
+		Ok(())
+	}
+
+	fn handle_list_webhooks(
+		&self, counterparty_node_id: PublicKey, request_id: LSPSRequestId,
+		_params: ListWebhooksRequest,
+	) -> Result<(), LightningError> {
+		self.check_prune_stale_webhooks();
+
+		let webhooks = self.webhooks.lock().unwrap();
+
+		let app_names = webhooks
+			.get(&counterparty_node_id)
+			.map(|client_webhooks| client_webhooks.keys().cloned().collect::<Vec<_>>())
+			.unwrap_or_else(Vec::new);
+
+		let max_webhooks = self.config.max_webhooks_per_client;
+
+		let response = ListWebhooksResponse { app_names, max_webhooks };
+		let msg = LSPS5Message::Response(request_id, LSPS5Response::ListWebhooks(response)).into();
+		self.pending_messages.enqueue(&counterparty_node_id, msg);
+
+		Ok(())
+	}
+
+	fn handle_remove_webhook(
+		&self, counterparty_node_id: PublicKey, request_id: LSPSRequestId,
+		params: RemoveWebhookRequest,
+	) -> Result<(), LightningError> {
+		self.check_prune_stale_webhooks();
+
+		let mut webhooks = self.webhooks.lock().unwrap();
+
+		if let Some(client_webhooks) = webhooks.get_mut(&counterparty_node_id) {
+			if client_webhooks.remove(&params.app_name).is_some() {
+				let response = RemoveWebhookResponse {};
+				let msg =
+					LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhook(response))
+						.into();
+				self.pending_messages.enqueue(&counterparty_node_id, msg);
+
+				return Ok(());
+			}
+		}
+
+		let error = LSPS5ProtocolError::AppNameNotFound;
+		let msg = LSPS5Message::Response(
+			request_id,
+			LSPS5Response::RemoveWebhookError(error.clone().into()),
+		)
+		.into();
+
+		self.pending_messages.enqueue(&counterparty_node_id, msg);
+		return Err(LightningError {
+			err: error.message().into(),
+			action: ErrorAction::IgnoreAndLog(Level::Info),
+		});
+	}
+
+	fn send_webhook_registered_notification(
+		&self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl,
+	) -> Result<(), LSPS5ProtocolError> {
+		let notification = WebhookNotification::webhook_registered();
+		self.send_notification(client_node_id, app_name, url, notification)
+	}
+
+	/// Notify the LSP service that the client has one or more incoming payments pending.
+	///
+	/// SHOULD be called by your LSP application logic as soon as you detect an incoming
+	/// payment (HTLC or future mechanism) for `client_id`.
+	/// This builds a [`WebhookNotificationMethod::LSPS5PaymentIncoming`] webhook notification, signs it with your
+	/// node key, and enqueues HTTP POSTs to all registered webhook URLs for that client.
+	///
+	/// # Parameters
+	/// - `client_id`: the client's node-ID whose webhooks should be invoked.
+	///
+	/// [`WebhookNotificationMethod::LSPS5PaymentIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5PaymentIncoming
+	pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LSPS5ProtocolError> {
+		let notification = WebhookNotification::payment_incoming();
+		self.broadcast_notification(client_id, notification)
+	}
+
+	/// Notify that an HTLC or other time-bound contract is expiring soon.
+	///
+	/// SHOULD be called by your LSP application logic when a channel contract for `client_id`
+	/// is within 24 blocks of timeout, and the timeout would cause a channel closure.
+	/// Builds a [`WebhookNotificationMethod::LSPS5ExpirySoon`] notification including
+	/// the `timeout` block height, signs it, and enqueues HTTP POSTs to the client's
+	/// registered webhooks.
+	///
+	/// # Parameters
+	/// - `client_id`: the client's node-ID whose webhooks should be invoked.
+	/// - `timeout`: the block height at which the channel contract will expire.
+	///
+	/// [`WebhookNotificationMethod::LSPS5ExpirySoon`]: super::msgs::WebhookNotificationMethod::LSPS5ExpirySoon
+	pub fn notify_expiry_soon(
+		&self, client_id: PublicKey, timeout: u32,
+	) -> Result<(), LSPS5ProtocolError> {
+		let notification = WebhookNotification::expiry_soon(timeout);
+		self.broadcast_notification(client_id, notification)
+	}
+
+	/// Notify that the LSP intends to manage liquidity (e.g. close or splice) on client channels.
+	///
+	/// SHOULD be called by your LSP application logic when you decide to reclaim or adjust
+	/// liquidity for `client_id`. Builds a [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`] notification,
+	/// signs it, and sends it to all of the client's registered webhook URLs.
+	///
+	/// # Parameters
+	/// - `client_id`: the client's node-ID whose webhooks should be invoked.
+	///
+	/// [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`]: super::msgs::WebhookNotificationMethod::LSPS5LiquidityManagementRequest
+	pub fn notify_liquidity_management_request(
+		&self, client_id: PublicKey,
+	) -> Result<(), LSPS5ProtocolError> {
+		let notification = WebhookNotification::liquidity_management_request();
+		self.broadcast_notification(client_id, notification)
+	}
+
+	/// Notify that the client has one or more pending BOLT Onion Messages.
+	///
+	/// SHOULD be called by your LSP application logic when you receive Onion Messages
+	/// for `client_id` while the client is offline. Builds a [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`]
+	/// notification, signs it, and enqueues HTTP POSTs to each registered webhook.
+	///
+	/// # Parameters
+	/// - `client_id`: the client's node-ID whose webhooks should be invoked.
+	///
+	/// [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5OnionMessageIncoming
+	pub fn notify_onion_message_incoming(
+		&self, client_id: PublicKey,
+	) -> Result<(), LSPS5ProtocolError> {
+		let notification = WebhookNotification::onion_message_incoming();
+		self.broadcast_notification(client_id, notification)
+	}
+
+	fn broadcast_notification(
+		&self, client_id: PublicKey, notification: WebhookNotification,
+	) -> Result<(), LSPS5ProtocolError> {
+		let mut webhooks = self.webhooks.lock().unwrap();
+
+		let client_webhooks = match webhooks.get_mut(&client_id) {
+			Some(webhooks) if !webhooks.is_empty() => webhooks,
+			_ => return Ok(()),
+		};
+
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+
+		for (app_name, webhook) in client_webhooks.iter_mut() {
+			if webhook
+				.last_notification_sent
+				.get(&notification.method)
+				.map(|last_sent| now.clone().abs_diff(&last_sent))
+				.map_or(true, |duration| {
+					duration >= self.config.notification_cooldown_hours.as_secs()
+				}) {
+				webhook.last_notification_sent.insert(notification.method.clone(), now.clone());
+				webhook.last_used = now.clone();
+				self.send_notification(
+					client_id,
+					app_name.clone(),
+					webhook.url.clone(),
+					notification.clone(),
+				)?;
+			}
+		}
+		Ok(())
+	}
+
+	fn send_notification(
+		&self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl,
+		notification: WebhookNotification,
+	) -> Result<(), LSPS5ProtocolError> {
+		let event_queue_notifier = self.event_queue.notifier();
+		let timestamp =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+
+		let signature_hex = self.sign_notification(&notification, &timestamp)?;
+
+		let mut headers: HashMap<String, String> = [("Content-Type", "application/json")]
+			.into_iter()
+			.map(|(k, v)| (k.to_string(), v.to_string()))
+			.collect();
+		headers.insert("x-lsps5-timestamp".into(), timestamp.to_rfc3339());
+		headers.insert("x-lsps5-signature".into(), signature_hex);
+
+		event_queue_notifier.enqueue(LSPS5ServiceEvent::SendWebhookNotification {
+			counterparty_node_id,
+			app_name,
+			url,
+			notification,
+			headers,
+		});
+
+		Ok(())
+	}
+
+	fn sign_notification(
+		&self, body: &WebhookNotification, timestamp: &LSPSDateTime,
+	) -> Result<String, LSPS5ProtocolError> {
+		let notification_json =
+			serde_json::to_string(body).map_err(|_| LSPS5ProtocolError::SerializationError)?;
+
+		let message = format!(
+			"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
+			timestamp.to_rfc3339(),
+			notification_json
+		);
+
+		Ok(message_signing::sign(message.as_bytes(), &self.config.signing_key))
+	}
+
+	fn prune_stale_webhooks(&self) {
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+		let mut webhooks = self.webhooks.lock().unwrap();
+
+		webhooks.retain(|client_id, client_webhooks| {
+			if !self.client_has_open_channel(client_id) {
+				client_webhooks.retain(|_, webhook| {
+					now.abs_diff(&webhook.last_used) < MIN_WEBHOOK_RETENTION_DAYS.as_secs()
+				});
+				!client_webhooks.is_empty()
+			} else {
+				true
+			}
+		});
+
+		let mut last_pruning = self.last_pruning.lock().unwrap();
+		*last_pruning = Some(now);
+	}
+
+	fn client_has_open_channel(&self, client_id: &PublicKey) -> bool {
+		self.channel_manager
+			.get_cm()
+			.list_channels()
+			.iter()
+			.any(|c| c.is_usable && c.counterparty.node_id == *client_id)
+	}
+}
+
+impl<CM: Deref, TP: Deref> LSPSProtocolMessageHandler for LSPS5ServiceHandler<CM, TP>
+where
+	CM::Target: AChannelManager,
+	TP::Target: TimeProvider,
+{
+	type ProtocolMessage = LSPS5Message;
+	const PROTOCOL_NUMBER: Option<u16> = Some(5);
+
+	fn handle_message(
+		&self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey,
+	) -> Result<(), LightningError> {
+		match message {
+			LSPS5Message::Request(request_id, request) => {
+				let res = match request {
+					LSPS5Request::SetWebhook(params) => {
+						self.handle_set_webhook(*counterparty_node_id, request_id, params)
+					},
+					LSPS5Request::ListWebhooks(params) => {
+						self.handle_list_webhooks(*counterparty_node_id, request_id, params)
+					},
+					LSPS5Request::RemoveWebhook(params) => {
+						self.handle_remove_webhook(*counterparty_node_id, request_id, params)
+					},
+				};
+				res
+			},
+			_ => {
+				debug_assert!(
+					false,
+					"Service handler received LSPS5 response message. This should never happen."
+				);
+				let err = format!(
+					"Service handler received LSPS5 response message from node {:?}. This should never happen.",
+					counterparty_node_id
+				);
+				Err(LightningError { err, action: ErrorAction::IgnoreAndLog(Level::Info) })
+			},
+		}
+	}
+}
diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs
new file mode 100644
index 00000000000..58a8bbe371d
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/url_utils.rs
@@ -0,0 +1,227 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! URL utilities for LSPS5 webhook notifications.
+
+use super::msgs::LSPS5ProtocolError;
+
+use lightning_types::string::UntrustedString;
+
+use alloc::string::String;
+
+/// Represents a parsed URL for LSPS5 webhook notifications.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct LSPSUrl {
+	url: UntrustedString,
+}
+
+impl LSPSUrl {
+	/// Parses a URL string into a URL instance.
+	///
+	/// # Arguments
+	/// * `url_str` - The URL string to parse
+	///
+	/// # Returns
+	/// A Result containing either the parsed URL or an error message.
+	pub fn parse(url_str: String) -> Result<Self, LSPS5ProtocolError> {
+		if url_str.chars().any(|c| !Self::is_valid_url_char(c)) {
+			return Err(LSPS5ProtocolError::UrlParse);
+		}
+
+		let (scheme, remainder) =
+			url_str.split_once("://").ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
+
+		if !scheme.eq_ignore_ascii_case("https") {
+			return Err(LSPS5ProtocolError::UnsupportedProtocol);
+		}
+
+		let host_section = remainder
+			.split(['/', '?', '#'])
+			.next()
+			.ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
+
+		let host_without_auth = host_section
+			.split('@')
+			.next_back()
+			.filter(|s| !s.is_empty())
+			.ok_or_else(|| (LSPS5ProtocolError::UrlParse))?;
+
+		if host_without_auth.is_empty()
+			|| host_without_auth.chars().any(|c| !Self::is_valid_host_char(c))
+		{
+			return Err(LSPS5ProtocolError::UrlParse);
+		}
+
+		match host_without_auth.rsplit_once(':') {
+			Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse),
+			Some((_, port)) => {
+				if !port.is_empty() && port.parse::<u16>().is_err() {
+					return Err(LSPS5ProtocolError::UrlParse);
+				}
+			},
+			None => {},
+		};
+
+		Ok(LSPSUrl { url: UntrustedString(url_str) })
+	}
+
+	/// Returns URL length.
+	pub fn url_length(&self) -> usize {
+		self.url.0.chars().count()
+	}
+
+	/// Returns the full URL string.
+	pub fn url(&self) -> &str {
+		self.url.0.as_str()
+	}
+
+	fn is_valid_url_char(c: char) -> bool {
+		c.is_ascii_alphanumeric()
+			|| matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=')
+	}
+
+	fn is_valid_host_char(c: char) -> bool {
+		c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_')
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use super::*;
+	use crate::alloc::string::ToString;
+	use alloc::vec::Vec;
+	use proptest::prelude::*;
+
+	#[test]
+	fn test_extremely_long_url() {
+		let url_str = format!("https://{}/path", "a".repeat(1000)).to_string();
+		let url_chars = url_str.chars().count();
+		let result = LSPSUrl::parse(url_str);
+
+		assert!(result.is_ok());
+		let url = result.unwrap();
+		assert_eq!(url.url.0.chars().count(), url_chars);
+	}
+
+	#[test]
+	fn test_parse_http_url() {
+		let url_str = "http://example.com/path".to_string();
+		let url = LSPSUrl::parse(url_str).unwrap_err();
+		assert_eq!(url, LSPS5ProtocolError::UnsupportedProtocol);
+	}
+
+	#[test]
+	fn valid_lsps_url() {
+		let test_vec: Vec<&'static str> = vec![
+			"https://www.example.org/push?l=1234567890abcopqrstuv&c=best",
+			"https://www.example.com/path",
+			"https://example.org",
+			"https://example.com:8080/path",
+			"https://api.example.com/v1/resources",
+			"https://example.com/page#section1",
+			"https://example.com/search?q=test#results",
+			"https://user:pass@example.com/",
+			"https://192.168.1.1/admin",
+			"https://example.com://path",
+			"https://example.com/path%20with%20spaces",
+			"https://example_example.com/path?query=with&spaces=true",
+		];
+		for url_str in test_vec {
+			let url = LSPSUrl::parse(url_str.to_string());
+			assert!(url.is_ok(), "Failed to parse URL: {}", url_str);
+		}
+	}
+
+	#[test]
+	fn invalid_lsps_url() {
+		let test_vec = vec![
+			"ftp://ftp.example.org/pub/files/document.pdf",
+			"sftp://user:password@sftp.example.com:22/uploads/",
+			"ssh://username@host.com:2222",
+			"lightning://03a.example.com/invoice?amount=10000",
+			"ftp://user@ftp.example.com/files/",
+			"https://例子.测试/path",
+			"a123+-.://example.com",
+			"a123+-.://example.com",
+			"https:\\\\example.com\\path",
+			"https:///whatever",
+			"https://example.com/path with spaces",
+		];
+		for url_str in test_vec {
+			let url = LSPSUrl::parse(url_str.to_string());
+			assert!(url.is_err(), "Expected error for URL: {}", url_str);
+		}
+	}
+
+	#[test]
+	fn parsing_errors() {
+		let test_vec = vec![
+			"example.com/path",
+			"https://bad domain.com/",
+			"https://example.com\0/path",
+			"https://",
+			"ht@ps://example.com",
+			"http!://example.com",
+			"1https://example.com",
+			"https://://example.com",
+			"https://example.com:port/path",
+			"https://:8080/path",
+			"https:",
+			"://",
+			"https://example.com\0/path",
+		];
+		for url_str in test_vec {
+			let url = LSPSUrl::parse(url_str.to_string());
+			assert!(url.is_err(), "Expected error for URL: {}", url_str);
+		}
+	}
+
+	fn host_strategy() -> impl Strategy<Value = String> {
+		prop_oneof![
+			proptest::string::string_regex(
+				"[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)*"
+			)
+			.unwrap(),
+			(0u8..=255u8, 0u8..=255u8, 0u8..=255u8, 0u8..=255u8)
+				.prop_map(|(a, b, c, d)| format!("{}.{}.{}.{}", a, b, c, d))
+		]
+	}
+
+	proptest! {
+		#[test]
+		fn proptest_parse_round_trip(
+			host in host_strategy(),
+			port in proptest::option::of(0u16..=65535u16),
+			path in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
+			query in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
+			fragment in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap())
+		) {
+			let mut url = format!("https://{}", host);
+			if let Some(p) = port {
+				url.push_str(&format!(":{}", p));
+			}
+			if let Some(pth) = &path {
+				url.push('/');
+				url.push_str(pth);
+			}
+			if let Some(q) = &query {
+				url.push('?');
+				url.push_str(q);
+			}
+			if let Some(f) = &fragment {
+				url.push('#');
+				url.push_str(f);
+			}
+
+			let parsed = LSPSUrl::parse(url.clone()).expect("should parse");
+			prop_assert_eq!(parsed.url(), url.as_str());
+			prop_assert_eq!(parsed.url_length(), url.chars().count());
+		}
+	}
+}
diff --git a/lightning-liquidity/src/lsps5/validator.rs b/lightning-liquidity/src/lsps5/validator.rs
new file mode 100644
index 00000000000..e5a690404e7
--- /dev/null
+++ b/lightning-liquidity/src/lsps5/validator.rs
@@ -0,0 +1,220 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! LSPS5 Validator
+
+use super::msgs::LSPS5ClientError;
+use super::service::TimeProvider;
+
+use crate::alloc::string::ToString;
+use crate::lsps0::ser::LSPSDateTime;
+use crate::lsps5::msgs::WebhookNotification;
+use crate::sync::Mutex;
+
+use lightning::util::message_signing;
+
+use bitcoin::secp256k1::PublicKey;
+
+use alloc::collections::VecDeque;
+use alloc::string::String;
+
+use core::ops::Deref;
+use core::time::Duration;
+
+/// Configuration for signature storage.
+#[derive(Clone, Copy, Debug)]
+pub struct SignatureStorageConfig {
+	/// Maximum number of signatures to store.
+	pub max_signatures: usize,
+	/// Retention time for signatures in minutes.
+	pub retention_minutes: Duration,
+}
+
+/// Default retention time for signatures in minutes (LSPS5 spec requires min 20 minutes).
+pub const DEFAULT_SIGNATURE_RETENTION_MINUTES: u64 = 20;
+
+/// Default maximum number of stored signatures.
+pub const DEFAULT_MAX_SIGNATURES: usize = 1000;
+
+impl Default for SignatureStorageConfig {
+	fn default() -> Self {
+		Self {
+			max_signatures: DEFAULT_MAX_SIGNATURES,
+			retention_minutes: Duration::from_secs(DEFAULT_SIGNATURE_RETENTION_MINUTES * 60),
+		}
+	}
+}
+
+/// A utility for validating webhook notifications from an LSP.
+///
+/// In a typical setup, a proxy server receives webhook notifications from the LSP
+/// and then forwards them to the client (e.g., via mobile push notifications).
+/// This validator should be used by the proxy to verify the authenticity and
+/// integrity of the notification before processing or forwarding it.
+///
+/// # Core Capabilities
+///
+///  - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks.
+///
+/// # Usage
+///
+/// The validator requires a `SignatureStore` to track recently seen signatures
+/// to prevent replay attacks. You should create a single `LSPS5Validator` instance
+/// and share it across all requests.
+///
+/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files
+pub struct LSPS5Validator<TP: Deref, SS: Deref>
+where
+	TP::Target: TimeProvider,
+	SS::Target: SignatureStore,
+{
+	time_provider: TP,
+	signature_store: SS,
+}
+
+impl<TP: Deref, SS: Deref> LSPS5Validator<TP, SS>
+where
+	TP::Target: TimeProvider,
+	SS::Target: SignatureStore,
+{
+	/// Creates a new `LSPS5Validator`.
+	pub fn new(time_provider: TP, signature_store: SS) -> Self {
+		Self { time_provider, signature_store }
+	}
+
+	fn verify_notification_signature(
+		&self, counterparty_node_id: PublicKey, signature_timestamp: &LSPSDateTime,
+		signature: &str, notification: &WebhookNotification,
+	) -> Result<(), LSPS5ClientError> {
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+		let diff = signature_timestamp.abs_diff(&now);
+		const MAX_TIMESTAMP_DRIFT_SECS: u64 = 600;
+		if diff > MAX_TIMESTAMP_DRIFT_SECS {
+			return Err(LSPS5ClientError::InvalidTimestamp);
+		}
+
+		let notification_json = serde_json::to_string(notification)
+			.map_err(|_| LSPS5ClientError::SerializationError)?;
+		let message = format!(
+			"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
+			signature_timestamp.to_rfc3339(),
+			notification_json
+		);
+
+		if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) {
+			Ok(())
+		} else {
+			Err(LSPS5ClientError::InvalidSignature)
+		}
+	}
+
+	/// Parse and validate a webhook notification received from an LSP.
+	///
+	/// Verifies the webhook delivery by checking the timestamp is within ±10 minutes,
+	/// ensuring no signature replay within the retention window, and verifying the
+	/// zbase32 LN-style signature against the LSP's node ID.
+	///
+	/// Call this method on your proxy/server before processing any webhook notification
+	/// to ensure its authenticity.
+	///
+	/// # Parameters
+	/// - `counterparty_node_id`: The LSP's public key, used to verify the signature.
+	/// - `timestamp`: ISO8601 time when the LSP created the notification.
+	/// - `signature`: The zbase32-encoded LN signature over timestamp+body.
+	/// - `notification`: The [`WebhookNotification`] received from the LSP.
+	///
+	/// Returns the validated [`WebhookNotification`] or an error for invalid timestamp,
+	/// replay attack, or signature verification failure.
+	///
+	/// [`WebhookNotification`]: super::msgs::WebhookNotification
+	pub fn validate(
+		&self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str,
+		notification: &WebhookNotification,
+	) -> Result<WebhookNotification, LSPS5ClientError> {
+		self.verify_notification_signature(
+			counterparty_node_id,
+			timestamp,
+			signature,
+			notification,
+		)?;
+
+		if self.signature_store.exists(signature)? {
+			return Err(LSPS5ClientError::ReplayAttack);
+		}
+
+		self.signature_store.store(signature)?;
+
+		Ok(notification.clone())
+	}
+}
+
+/// Trait for storing and checking webhook notification signatures to prevent replay attacks.
+pub trait SignatureStore {
+	/// Checks if a signature already exists in the store.
+	fn exists(&self, signature: &str) -> Result<bool, LSPS5ClientError>;
+	/// Stores a new signature.
+	fn store(&self, signature: &str) -> Result<(), LSPS5ClientError>;
+}
+
+/// An in-memory store for webhook notification signatures.
+pub struct InMemorySignatureStore<TP: Deref>
+where
+	TP::Target: TimeProvider,
+{
+	recent_signatures: Mutex<VecDeque<(String, LSPSDateTime)>>,
+	config: SignatureStorageConfig,
+	time_provider: TP,
+}
+
+impl<TP: Deref> InMemorySignatureStore<TP>
+where
+	TP::Target: TimeProvider,
+{
+	/// Creates a new `InMemorySignatureStore`.
+	pub fn new(config: SignatureStorageConfig, time_provider: TP) -> Self {
+		Self {
+			recent_signatures: Mutex::new(VecDeque::with_capacity(config.max_signatures)),
+			config,
+			time_provider,
+		}
+	}
+}
+
+impl<TP: Deref> SignatureStore for InMemorySignatureStore<TP>
+where
+	TP::Target: TimeProvider,
+{
+	fn exists(&self, signature: &str) -> Result<bool, LSPS5ClientError> {
+		let recent_signatures = self.recent_signatures.lock().unwrap();
+		for (stored_sig, _) in recent_signatures.iter() {
+			if stored_sig == signature {
+				return Ok(true);
+			}
+		}
+		Ok(false)
+	}
+
+	fn store(&self, signature: &str) -> Result<(), LSPS5ClientError> {
+		let now =
+			LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
+		let mut recent_signatures = self.recent_signatures.lock().unwrap();
+
+		recent_signatures.push_back((signature.to_string(), now.clone()));
+
+		let retention_secs = self.config.retention_minutes.as_secs();
+		recent_signatures.retain(|(_, ts)| now.abs_diff(ts) <= retention_secs);
+
+		if recent_signatures.len() > self.config.max_signatures {
+			let excess = recent_signatures.len() - self.config.max_signatures;
+			recent_signatures.drain(0..excess);
+		}
+		Ok(())
+	}
+}
diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs
index f4cce6855cd..8218ce68023 100644
--- a/lightning-liquidity/src/manager.rs
+++ b/lightning-liquidity/src/manager.rs
@@ -10,6 +10,11 @@ use crate::lsps0::ser::{
 	LSPS_MESSAGE_TYPE_ID,
 };
 use crate::lsps0::service::LSPS0ServiceHandler;
+use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler};
+use crate::lsps5::msgs::LSPS5Message;
+#[cfg(feature = "time")]
+use crate::lsps5::service::DefaultTimeProvider;
+use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider};
 use crate::message_queue::MessageQueue;
 
 use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler};
@@ -52,6 +57,8 @@ pub struct LiquidityServiceConfig {
 	/// Optional server-side configuration for JIT channels
 	/// should you want to support them.
 	pub lsps2_service_config: Option<LSPS2ServiceConfig>,
+	/// Optional server-side configuration for LSPS5 webhook service.
+	pub lsps5_service_config: Option<LSPS5ServiceConfig>,
 	/// Controls whether the liquidity service should be advertised via setting the feature bit in
 	/// node announcment and the init message.
 	pub advertise_service: bool,
@@ -66,6 +73,8 @@ pub struct LiquidityClientConfig {
 	pub lsps1_client_config: Option<LSPS1ClientConfig>,
 	/// Optional client-side configuration for JIT channels.
 	pub lsps2_client_config: Option<LSPS2ClientConfig>,
+	/// Optional client-side configuration for LSPS5 webhook service.
+	pub lsps5_client_config: Option<LSPS5ClientConfig>,
 }
 
 /// A trivial trait which describes any [`LiquidityManager`].
@@ -85,16 +94,21 @@ pub trait ALiquidityManager {
 	type Filter: Filter + ?Sized;
 	/// A type that may be dereferenced to [`Self::Filter`].
 	type C: Deref<Target = Self::Filter> + Clone;
+	/// A type implementing [`TimeProvider`].
+	type TimeProvider: TimeProvider + ?Sized;
+	/// A type that may be dereferenced to [`Self::TimeProvider`].
+	type TP: Deref<Target = Self::TimeProvider> + Clone;
 	/// Returns a reference to the actual [`LiquidityManager`] object.
-	fn get_lm(&self) -> &LiquidityManager<Self::ES, Self::CM, Self::C>;
+	fn get_lm(&self) -> &LiquidityManager<Self::ES, Self::CM, Self::C, Self::TP>;
 }
 
-impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone> ALiquidityManager
-	for LiquidityManager<ES, CM, C>
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone> ALiquidityManager
+	for LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	type EntropySource = ES::Target;
 	type ES = ES;
@@ -102,7 +116,9 @@ where
 	type CM = CM;
 	type Filter = C::Target;
 	type C = C;
-	fn get_lm(&self) -> &LiquidityManager<ES, CM, C> {
+	type TimeProvider = TP::Target;
+	type TP = TP;
+	fn get_lm(&self) -> &LiquidityManager<ES, CM, C, TP> {
 		self
 	}
 }
@@ -126,11 +142,16 @@ where
 /// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady
 /// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed
 /// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded
-pub struct LiquidityManager<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone>
-where
+pub struct LiquidityManager<
+	ES: Deref + Clone,
+	CM: Deref + Clone,
+	C: Deref + Clone,
+	TP: Deref + Clone,
+> where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	pending_messages: Arc<MessageQueue>,
 	pending_events: Arc<EventQueue>,
@@ -144,28 +165,60 @@ where
 	lsps1_client_handler: Option<LSPS1ClientHandler<ES>>,
 	lsps2_service_handler: Option<LSPS2ServiceHandler<CM>>,
 	lsps2_client_handler: Option<LSPS2ClientHandler<ES>>,
+	lsps5_service_handler: Option<LSPS5ServiceHandler<CM, TP>>,
+	lsps5_client_handler: Option<LSPS5ClientHandler<ES, TP>>,
 	service_config: Option<LiquidityServiceConfig>,
 	_client_config: Option<LiquidityClientConfig>,
 	best_block: RwLock<Option<BestBlock>>,
 	_chain_source: Option<C>,
 }
 
-impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone> LiquidityManager<ES, CM, C>
+#[cfg(feature = "time")]
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone>
+	LiquidityManager<ES, CM, C, Arc<DefaultTimeProvider>>
+where
+	ES::Target: EntropySource,
+	CM::Target: AChannelManager,
+	C::Target: Filter,
+{
+	/// Constructor for the [`LiquidityManager`] using the default system clock
+	pub fn new(
+		entropy_source: ES, channel_manager: CM, chain_source: Option<C>,
+		chain_params: Option<ChainParameters>, service_config: Option<LiquidityServiceConfig>,
+		client_config: Option<LiquidityClientConfig>,
+	) -> Self {
+		let time_provider = Arc::new(DefaultTimeProvider);
+		Self::new_with_custom_time_provider(
+			entropy_source,
+			channel_manager,
+			chain_source,
+			chain_params,
+			service_config,
+			client_config,
+			time_provider,
+		)
+	}
+}
+
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone>
+	LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
-	/// Constructor for the [`LiquidityManager`].
+	/// Constructor for the [`LiquidityManager`] with a custom time provider.
 	///
+	/// This should be used on non-std platforms where access to the system time is not
+	/// available.
 	/// Sets up the required protocol message handlers based on the given
 	/// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`].
-	pub fn new(
+	pub fn new_with_custom_time_provider(
 		entropy_source: ES, channel_manager: CM, chain_source: Option<C>,
 		chain_params: Option<ChainParameters>, service_config: Option<LiquidityServiceConfig>,
-		client_config: Option<LiquidityClientConfig>,
-	) -> Self
-where {
+		client_config: Option<LiquidityClientConfig>, time_provider: TP,
+	) -> Self {
 		let pending_messages = Arc::new(MessageQueue::new());
 		let pending_events = Arc::new(EventQueue::new());
 		let ignored_peers = RwLock::new(new_hash_set());
@@ -198,6 +251,36 @@ where {
 			})
 		});
 
+		let lsps5_client_handler = client_config.as_ref().and_then(|config| {
+			config.lsps5_client_config.as_ref().map(|config| {
+				LSPS5ClientHandler::new_with_time_provider(
+					entropy_source.clone(),
+					Arc::clone(&pending_messages),
+					Arc::clone(&pending_events),
+					config.clone(),
+					time_provider.clone(),
+				)
+			})
+		});
+
+		let lsps5_service_handler = service_config.as_ref().and_then(|config| {
+			config.lsps5_service_config.as_ref().map(|config| {
+				if let Some(number) =
+					<LSPS5ServiceHandler<CM, TP> as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER
+				{
+					supported_protocols.push(number);
+				}
+
+				return LSPS5ServiceHandler::new_with_time_provider(
+					Arc::clone(&pending_events),
+					Arc::clone(&pending_messages),
+					channel_manager.clone(),
+					config.clone(),
+					time_provider,
+				);
+			})
+		});
+
 		let lsps1_client_handler = client_config.as_ref().and_then(|config| {
 			config.lsps1_client_config.as_ref().map(|config| {
 				LSPS1ClientHandler::new(
@@ -252,6 +335,8 @@ where {
 			lsps1_service_handler,
 			lsps2_client_handler,
 			lsps2_service_handler,
+			lsps5_client_handler,
+			lsps5_service_handler,
 			service_config,
 			_client_config: client_config,
 			best_block: RwLock::new(chain_params.map(|chain_params| chain_params.best_block)),
@@ -299,6 +384,20 @@ where {
 		self.lsps2_service_handler.as_ref()
 	}
 
+	/// Returns a reference to the LSPS5 client-side handler.
+	///
+	/// The returned hendler allows to initiate the LSPS5 client-side flow. That is, it allows to
+	pub fn lsps5_client_handler(&self) -> Option<&LSPS5ClientHandler<ES, TP>> {
+		self.lsps5_client_handler.as_ref()
+	}
+
+	/// Returns a reference to the LSPS5 server-side handler.
+	///
+	/// The returned hendler allows to initiate the LSPS5 service-side flow.
+	pub fn lsps5_service_handler(&self) -> Option<&LSPS5ServiceHandler<CM, TP>> {
+		self.lsps5_service_handler.as_ref()
+	}
+
 	/// Returns a [`Future`] that will complete when the next batch of pending messages is ready to
 	/// be processed.
 	///
@@ -424,17 +523,38 @@ where {
 					},
 				}
 			},
+			LSPSMessage::LSPS5(msg @ LSPS5Message::Response(..)) => {
+				match &self.lsps5_client_handler {
+					Some(lsps5_client_handler) => {
+						lsps5_client_handler.handle_message(msg, sender_node_id)?;
+					},
+					None => {
+						return Err(LightningError { err: format!("Received LSPS5 response message without LSPS5 client handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)});
+					},
+				}
+			},
+			LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => {
+				match &self.lsps5_service_handler {
+					Some(lsps5_service_handler) => {
+						lsps5_service_handler.handle_message(msg, sender_node_id)?;
+					},
+					None => {
+						return Err(LightningError { err: format!("Received LSPS5 request message without LSPS5 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)});
+					},
+				}
+			},
 		}
 		Ok(())
 	}
 }
 
-impl<ES: Deref + Clone + Clone, CM: Deref + Clone, C: Deref + Clone> CustomMessageReader
-	for LiquidityManager<ES, CM, C>
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone> CustomMessageReader
+	for LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	type CustomMessage = RawLSPSMessage;
 
@@ -450,12 +570,13 @@ where
 	}
 }
 
-impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone> CustomMessageHandler
-	for LiquidityManager<ES, CM, C>
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone> CustomMessageHandler
+	for LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	fn handle_custom_message(
 		&self, msg: Self::CustomMessage, sender_node_id: PublicKey,
@@ -562,11 +683,13 @@ where
 	}
 }
 
-impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone> Listen for LiquidityManager<ES, CM, C>
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone> Listen
+	for LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	fn filtered_block_connected(
 		&self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData,
@@ -599,11 +722,13 @@ where
 	}
 }
 
-impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone> Confirm for LiquidityManager<ES, CM, C>
+impl<ES: Deref + Clone, CM: Deref + Clone, C: Deref + Clone, TP: Deref + Clone> Confirm
+	for LiquidityManager<ES, CM, C, TP>
 where
 	ES::Target: EntropySource,
 	CM::Target: AChannelManager,
 	C::Target: Filter,
+	TP::Target: TimeProvider,
 {
 	fn transactions_confirmed(
 		&self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData,
diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs
index ebf2afdaadd..e547b9dbf50 100644
--- a/lightning-liquidity/tests/common/mod.rs
+++ b/lightning-liquidity/tests/common/mod.rs
@@ -1,16 +1,16 @@
-#![cfg(test)]
+#![cfg(all(test, feature = "time"))]
 // TODO: remove these flags and unused code once we know what we'll need.
 #![allow(dead_code)]
 #![allow(unused_imports)]
 #![allow(unused_macros)]
 
-use lightning::chain::Filter;
-use lightning::sign::{EntropySource, NodeSigner};
-
 use bitcoin::blockdata::constants::{genesis_block, ChainHash};
 use bitcoin::blockdata::transaction::Transaction;
+use bitcoin::secp256k1::SecretKey;
 use bitcoin::Network;
+
 use lightning::chain::channelmonitor::ANTI_REORG_DELAY;
+use lightning::chain::Filter;
 use lightning::chain::{chainmonitor, BestBlock, Confirm};
 use lightning::ln::channelmanager;
 use lightning::ln::channelmanager::ChainParameters;
@@ -19,6 +19,7 @@ use lightning::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init};
 use lightning::ln::peer_handler::{
 	IgnoringMessageHandler, MessageHandler, PeerManager, SocketDescriptor,
 };
+use lightning::sign::{EntropySource, NodeSigner};
 
 use lightning::onion_message::messenger::DefaultMessageRouter;
 use lightning::routing::gossip::{NetworkGraph, P2PGossipSync};
@@ -34,10 +35,13 @@ use lightning::util::persist::{
 	SCORER_PERSISTENCE_SECONDARY_NAMESPACE,
 };
 use lightning::util::test_utils;
+
+use lightning_liquidity::lsps5::service::TimeProvider;
 use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig};
 use lightning_persister::fs_store::FilesystemStore;
 
 use std::collections::{HashMap, VecDeque};
+use std::ops::Deref;
 use std::path::PathBuf;
 use std::sync::atomic::AtomicBool;
 use std::sync::mpsc::SyncSender;
@@ -128,14 +132,21 @@ pub(crate) struct Node {
 					Arc<KeysManager>,
 					Arc<ChannelManager>,
 					Arc<dyn Filter + Send + Sync>,
+					Arc<dyn TimeProvider + Send + Sync>,
 				>,
 			>,
 			Arc<KeysManager>,
 			Arc<ChainMonitor>,
 		>,
 	>,
-	pub(crate) liquidity_manager:
-		Arc<LiquidityManager<Arc<KeysManager>, Arc<ChannelManager>, Arc<dyn Filter + Send + Sync>>>,
+	pub(crate) liquidity_manager: Arc<
+		LiquidityManager<
+			Arc<KeysManager>,
+			Arc<ChannelManager>,
+			Arc<dyn Filter + Send + Sync>,
+			Arc<dyn TimeProvider + Send + Sync>,
+		>,
+	>,
 	pub(crate) chain_monitor: Arc<ChainMonitor>,
 	pub(crate) kv_store: Arc<FilesystemStore>,
 	pub(crate) tx_broadcaster: Arc<test_utils::TestBroadcaster>,
@@ -403,6 +414,7 @@ fn get_full_filepath(filepath: String, filename: String) -> String {
 pub(crate) fn create_liquidity_node(
 	i: usize, persist_dir: &str, network: Network, service_config: Option<LiquidityServiceConfig>,
 	client_config: Option<LiquidityClientConfig>,
+	time_provider: Arc<dyn TimeProvider + Send + Sync>,
 ) -> Node {
 	let tx_broadcaster = Arc::new(test_utils::TestBroadcaster::new(network));
 	let fee_estimator = Arc::new(test_utils::TestFeeEstimator::new(253));
@@ -455,15 +467,16 @@ pub(crate) fn create_liquidity_node(
 		Some(Arc::clone(&chain_source)),
 		Arc::clone(&logger),
 	));
-
-	let liquidity_manager = Arc::new(LiquidityManager::new(
+	let liquidity_manager = Arc::new(LiquidityManager::new_with_custom_time_provider(
 		Arc::clone(&keys_manager),
 		Arc::clone(&channel_manager),
 		None::<Arc<dyn Filter + Send + Sync>>,
-		Some(chain_params),
+		Some(chain_params.clone()),
 		service_config,
 		client_config,
+		time_provider,
 	));
+
 	let msg_handler = MessageHandler {
 		chan_handler: Arc::new(test_utils::TestChannelMessageHandler::new(
 			ChainHash::using_genesis_block(Network::Testnet),
@@ -493,14 +506,29 @@ pub(crate) fn create_liquidity_node(
 }
 
 pub(crate) fn create_service_and_client_nodes(
-	persist_dir: &str, service_config: LiquidityServiceConfig, client_config: LiquidityClientConfig,
+	persist_dir: &str, service_config: LiquidityServiceConfig,
+	client_config: LiquidityClientConfig, time_provider: Arc<dyn TimeProvider + Send + Sync>,
 ) -> (Node, Node) {
 	let persist_temp_path = env::temp_dir().join(persist_dir);
 	let persist_dir = persist_temp_path.to_string_lossy().to_string();
 	let network = Network::Bitcoin;
 
-	let service_node = create_liquidity_node(1, &persist_dir, network, Some(service_config), None);
-	let client_node = create_liquidity_node(2, &persist_dir, network, None, Some(client_config));
+	let service_node = create_liquidity_node(
+		1,
+		&persist_dir,
+		network,
+		Some(service_config),
+		None,
+		Arc::clone(&time_provider),
+	);
+	let client_node = create_liquidity_node(
+		2,
+		&persist_dir,
+		network,
+		None,
+		Some(client_config),
+		Arc::clone(&time_provider),
+	);
 
 	service_node
 		.channel_manager
diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs
index 50444971746..60ec1224c3e 100644
--- a/lightning-liquidity/tests/lsps0_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps0_integration_tests.rs
@@ -1,7 +1,11 @@
-#![cfg(all(test, feature = "std"))]
+#![cfg(all(test, feature = "time"))]
 
 mod common;
 
+use std::sync::Arc;
+use std::time::Duration;
+
+use bitcoin::secp256k1::SecretKey;
 use common::{create_service_and_client_nodes, get_lsps_message};
 
 use lightning_liquidity::events::LiquidityEvent;
@@ -12,6 +16,8 @@ use lightning_liquidity::lsps1::client::LSPS1ClientConfig;
 use lightning_liquidity::lsps1::service::LSPS1ServiceConfig;
 use lightning_liquidity::lsps2::client::LSPS2ClientConfig;
 use lightning_liquidity::lsps2::service::LSPS2ServiceConfig;
+use lightning_liquidity::lsps5::client::LSPS5ClientConfig;
+use lightning_liquidity::lsps5::service::{DefaultTimeProvider, LSPS5ServiceConfig};
 use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig};
 
 use lightning::ln::peer_handler::CustomMessageHandler;
@@ -22,28 +28,39 @@ fn list_protocols_integration_test() {
 	let lsps2_service_config = LSPS2ServiceConfig { promise_secret };
 	#[cfg(lsps1_service)]
 	let lsps1_service_config = LSPS1ServiceConfig { supported_options: None, token: None };
+	let signing_key = SecretKey::from_slice(&[42; 32]).unwrap();
+	let mut lsps5_service_config = LSPS5ServiceConfig {
+		max_webhooks_per_client: 10,
+		signing_key,
+		notification_cooldown_hours: Duration::from_secs(3600),
+	};
+	lsps5_service_config.signing_key = signing_key;
 	let service_config = LiquidityServiceConfig {
 		#[cfg(lsps1_service)]
 		lsps1_service_config: Some(lsps1_service_config),
 		lsps2_service_config: Some(lsps2_service_config),
+		lsps5_service_config: Some(lsps5_service_config),
 		advertise_service: true,
 	};
 
 	let lsps2_client_config = LSPS2ClientConfig::default();
 	#[cfg(lsps1_service)]
 	let lsps1_client_config: LSPS1ClientConfig = LSPS1ClientConfig { max_channel_fees_msat: None };
+	let lsps5_client_config = LSPS5ClientConfig::default();
 	let client_config = LiquidityClientConfig {
 		#[cfg(lsps1_service)]
 		lsps1_client_config: Some(lsps1_client_config),
 		#[cfg(not(lsps1_service))]
 		lsps1_client_config: None,
 		lsps2_client_config: Some(lsps2_client_config),
+		lsps5_client_config: Some(lsps5_client_config),
 	};
 
 	let (service_node, client_node) = create_service_and_client_nodes(
 		"list_protocols_integration_test",
 		service_config,
 		client_config,
+		Arc::new(DefaultTimeProvider),
 	);
 
 	let service_node_id = service_node.channel_manager.get_our_node_id();
@@ -77,11 +94,12 @@ fn list_protocols_integration_test() {
 			{
 				assert!(protocols.contains(&1));
 				assert!(protocols.contains(&2));
-				assert_eq!(protocols.len(), 2);
+				assert!(protocols.contains(&5));
+				assert_eq!(protocols.len(), 3);
 			}
 
 			#[cfg(not(lsps1_service))]
-			assert_eq!(protocols, vec![2]);
+			assert_eq!(protocols, vec![2, 5]);
 		},
 		_ => panic!("Unexpected event"),
 	}
diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs
index a2721cab1de..a0677358b10 100644
--- a/lightning-liquidity/tests/lsps2_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps2_integration_tests.rs
@@ -1,4 +1,4 @@
-#![cfg(all(test, feature = "std"))]
+#![cfg(all(test, feature = "std", feature = "time"))]
 
 mod common;
 
@@ -23,6 +23,7 @@ use lightning::util::logger::Logger;
 
 use lightning_invoice::{Bolt11Invoice, InvoiceBuilder, RoutingFees};
 
+use lightning_liquidity::lsps5::service::DefaultTimeProvider;
 use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig};
 use lightning_types::payment::PaymentHash;
 
@@ -31,6 +32,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
 use bitcoin::Network;
 
 use std::str::FromStr;
+use std::sync::Arc;
 use std::time::Duration;
 
 const MAX_PENDING_REQUESTS_PER_PEER: usize = 10;
@@ -46,6 +48,7 @@ fn setup_test_lsps2(
 		#[cfg(lsps1_service)]
 		lsps1_service_config: None,
 		lsps2_service_config: Some(lsps2_service_config),
+		lsps5_service_config: None,
 		advertise_service: true,
 	};
 
@@ -53,10 +56,15 @@ fn setup_test_lsps2(
 	let client_config = LiquidityClientConfig {
 		lsps1_client_config: None,
 		lsps2_client_config: Some(lsps2_client_config),
+		lsps5_client_config: None,
 	};
 
-	let (service_node, client_node) =
-		create_service_and_client_nodes(persist_dir, service_config, client_config);
+	let (service_node, client_node) = create_service_and_client_nodes(
+		persist_dir,
+		service_config,
+		client_config,
+		Arc::new(DefaultTimeProvider),
+	);
 
 	let secp = bitcoin::secp256k1::Secp256k1::new();
 	let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key);
diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs
new file mode 100644
index 00000000000..2ff987f5329
--- /dev/null
+++ b/lightning-liquidity/tests/lsps5_integration_tests.rs
@@ -0,0 +1,1103 @@
+#![cfg(all(test, feature = "time"))]
+
+mod common;
+
+use common::{create_service_and_client_nodes, get_lsps_message, Node};
+
+use bitcoin::secp256k1::SecretKey;
+use lightning::ln::peer_handler::CustomMessageHandler;
+use lightning::util::hash_tables::{HashMap, HashSet};
+use lightning_liquidity::events::LiquidityEvent;
+use lightning_liquidity::lsps0::ser::LSPSDateTime;
+use lightning_liquidity::lsps5::client::LSPS5ClientConfig;
+use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent};
+use lightning_liquidity::lsps5::msgs::{
+	LSPS5AppName, LSPS5ClientError, LSPS5ProtocolError, LSPS5WebhookUrl, WebhookNotification,
+	WebhookNotificationMethod,
+};
+use lightning_liquidity::lsps5::service::{DefaultTimeProvider, LSPS5ServiceConfig, TimeProvider};
+use lightning_liquidity::lsps5::service::{
+	MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS,
+};
+use lightning_liquidity::lsps5::validator::{
+	InMemorySignatureStore, LSPS5Validator, SignatureStorageConfig,
+};
+use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig};
+use std::sync::{Arc, RwLock};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+/// Default maximum number of webhooks allowed per client.
+pub(crate) const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10;
+/// Default notification cooldown time in hours.
+pub(crate) const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(24 * 60 * 60);
+
+type TestValidator = LSPS5Validator<
+	Arc<dyn TimeProvider + Send + Sync>,
+	Arc<InMemorySignatureStore<Arc<dyn TimeProvider + Send + Sync>>>,
+>;
+
+pub(crate) fn lsps5_test_setup(
+	time_provider: Arc<dyn TimeProvider + Send + Sync>, max_signatures: Option<usize>,
+) -> (bitcoin::secp256k1::PublicKey, bitcoin::secp256k1::PublicKey, Node, Node, TestValidator) {
+	let signing_key = SecretKey::from_slice(&[42; 32]).unwrap();
+	let mut lsps5_service_config = LSPS5ServiceConfig {
+		max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT,
+		signing_key,
+		notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS,
+	};
+	lsps5_service_config.signing_key = signing_key;
+	let service_config = LiquidityServiceConfig {
+		#[cfg(lsps1_service)]
+		lsps1_service_config: None,
+		lsps2_service_config: None,
+		lsps5_service_config: Some(lsps5_service_config),
+		advertise_service: true,
+	};
+
+	let lsps5_client_config = LSPS5ClientConfig::default();
+
+	let client_config = LiquidityClientConfig {
+		lsps1_client_config: None,
+		lsps2_client_config: None,
+		lsps5_client_config: Some(lsps5_client_config),
+	};
+
+	let (service_node, client_node) = create_service_and_client_nodes(
+		"webhook_registration_flow",
+		service_config,
+		client_config,
+		time_provider.clone(),
+	);
+
+	let secp = bitcoin::secp256k1::Secp256k1::new();
+	let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key);
+	let client_node_id = client_node.channel_manager.get_our_node_id();
+
+	let mut signature_config = SignatureStorageConfig::default();
+	if let Some(max_signatures) = max_signatures {
+		signature_config.max_signatures = max_signatures;
+	}
+
+	let signature_store =
+		Arc::new(InMemorySignatureStore::new(signature_config, time_provider.clone()));
+	let validator = LSPS5Validator::new(time_provider, signature_store);
+
+	(service_node_id, client_node_id, service_node, client_node, validator)
+}
+
+struct MockTimeProvider {
+	current_time: RwLock<Duration>,
+}
+
+impl MockTimeProvider {
+	fn new(seconds_since_epoch: u64) -> Self {
+		Self { current_time: RwLock::new(Duration::from_secs(seconds_since_epoch)) }
+	}
+
+	fn advance_time(&self, seconds: u64) {
+		let mut time = self.current_time.write().unwrap();
+		*time += Duration::from_secs(seconds);
+	}
+
+	fn rewind_time(&self, seconds: u64) {
+		let mut time = self.current_time.write().unwrap();
+		*time = time.checked_sub(Duration::from_secs(seconds)).unwrap_or_default();
+	}
+}
+
+impl TimeProvider for MockTimeProvider {
+	fn duration_since_epoch(&self) -> Duration {
+		*self.current_time.read().unwrap()
+	}
+}
+
+fn extract_ts_sig(headers: &HashMap<String, String>) -> (LSPSDateTime, String) {
+	let timestamp = headers
+		.get("x-lsps5-timestamp")
+		.expect("missing x-lsps5-timestamp header")
+		.parse::<LSPSDateTime>()
+		.expect("failed to parse x-lsps5-timestamp header");
+
+	let signature =
+		headers.get("x-lsps5-signature").expect("missing x-lsps5-signature header").to_owned();
+	(timestamp, signature)
+}
+
+#[test]
+fn webhook_registration_flow() {
+	let (service_node_id, client_node_id, service_node, client_node, _) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+
+	let raw_app_name = "My LSPS-Compliant Lightning Client";
+	let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap();
+	let raw_webhook_url = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best";
+	let webhook_url = LSPS5WebhookUrl::from_string(raw_webhook_url.to_string()).unwrap();
+
+	let request_id = client_handler
+		.set_webhook(service_node_id, raw_app_name.to_string(), raw_webhook_url.to_string())
+		.expect("Failed to send set_webhook request");
+	let set_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_request, client_node_id)
+		.unwrap();
+
+	let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap();
+	match webhook_notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			counterparty_node_id,
+			app_name: an,
+			url,
+			notification,
+			headers,
+		}) => {
+			assert_eq!(counterparty_node_id, client_node_id);
+			assert_eq!(an, app_name.clone());
+			assert_eq!(url, webhook_url);
+			let (timestamp, signature) = extract_ts_sig(&headers);
+
+			assert!(timestamp.to_rfc3339().len() > 0, "Timestamp should not be empty");
+			assert!(signature.len() > 0, "Signature should not be empty");
+			assert_eq!(
+				headers.len(),
+				3,
+				"Should have 3 headers (Content-Type, timestamp, signature)"
+			);
+			assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered);
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	}
+	let set_webhook_response = get_lsps_message!(service_node, client_node_id);
+
+	client_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_response, service_node_id)
+		.unwrap();
+
+	let webhook_registered_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhook_registered_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered {
+			num_webhooks,
+			max_webhooks,
+			no_change,
+			counterparty_node_id: lsp,
+			app_name: an,
+			url,
+			request_id: req_id,
+		}) => {
+			assert_eq!(num_webhooks, 1);
+			assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT);
+			assert_eq!(no_change, false);
+			assert_eq!(lsp, service_node_id);
+			assert_eq!(an, app_name.clone());
+			assert_eq!(url, webhook_url);
+			assert_eq!(req_id, request_id);
+		},
+		_ => panic!("Unexpected event"),
+	}
+
+	let list_request_id = client_handler.list_webhooks(service_node_id);
+	let list_webhooks_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(list_webhooks_request, client_node_id)
+		.unwrap();
+
+	let list_webhooks_response = get_lsps_message!(service_node, client_node_id);
+
+	client_node
+		.liquidity_manager
+		.handle_custom_message(list_webhooks_response, service_node_id)
+		.unwrap();
+
+	let webhooks_list_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhooks_list_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed {
+			counterparty_node_id: lsp,
+			app_names,
+			max_webhooks,
+			request_id,
+		}) => {
+			assert_eq!(lsp, service_node_id);
+			assert_eq!(app_names, vec![app_name.clone()]);
+			assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT);
+			assert_eq!(request_id, list_request_id);
+		},
+		_ => panic!("Unexpected event"),
+	}
+
+	let raw_updated_webhook_url = "https://www.example.org/push?l=updatedtoken&c=best";
+	let updated_webhook_url =
+		LSPS5WebhookUrl::from_string(raw_updated_webhook_url.to_string()).unwrap();
+	let _ = client_handler
+		.set_webhook(service_node_id, raw_app_name.to_string(), raw_updated_webhook_url.to_string())
+		.expect("Failed to send update webhook request");
+
+	let set_webhook_update_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_update_request, client_node_id)
+		.unwrap();
+
+	let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap();
+	match webhook_notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url, ..
+		}) => {
+			assert_eq!(url, updated_webhook_url);
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	}
+
+	let set_webhook_update_response = get_lsps_message!(service_node, client_node_id);
+
+	client_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_update_response, service_node_id)
+		.unwrap();
+
+	let webhook_update_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhook_update_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered {
+			counterparty_node_id,
+			app_name: an,
+			url,
+			..
+		}) => {
+			assert_eq!(counterparty_node_id, service_node_id);
+			assert_eq!(an, app_name);
+			assert_eq!(url, updated_webhook_url);
+		},
+		_ => panic!("Unexpected event"),
+	}
+
+	let remove_request_id = client_handler
+		.remove_webhook(service_node_id, app_name.to_string())
+		.expect("Failed to send remove_webhook request");
+	let remove_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(remove_webhook_request, client_node_id)
+		.unwrap();
+
+	let remove_webhook_response = get_lsps_message!(service_node, client_node_id);
+
+	client_node
+		.liquidity_manager
+		.handle_custom_message(remove_webhook_response, service_node_id)
+		.unwrap();
+
+	let webhook_removed_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhook_removed_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRemoved {
+			counterparty_node_id,
+			app_name: an,
+			request_id,
+		}) => {
+			assert_eq!(counterparty_node_id, service_node_id);
+			assert_eq!(an, app_name);
+			assert_eq!(request_id, remove_request_id);
+		},
+		_ => panic!("Unexpected event"),
+	}
+}
+
+#[test]
+fn webhook_error_handling_test() {
+	let (service_node_id, client_node_id, service_node, client_node, _) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+
+	// TEST 1: URL too long error
+	let app_name = "Error Test App";
+
+	let long_url = format!("https://example.org/{}", "a".repeat(1024));
+
+	let result = client_handler.set_webhook(service_node_id, app_name.to_string(), long_url);
+
+	assert!(result.is_err(), "Expected error due to URL length");
+	let error = result.unwrap_err();
+	assert!(error == LSPS5ProtocolError::WebhookUrlTooLong.into());
+
+	// TEST 2: Invalid URL format error
+	let invalid_url = "not-a-valid-url";
+	let result =
+		client_handler.set_webhook(service_node_id, app_name.to_string(), invalid_url.to_string());
+	assert!(result.is_err(), "Expected error due to invalid URL format");
+	let error = result.unwrap_err();
+	assert_eq!(error, LSPS5ProtocolError::UrlParse.into());
+
+	// TEST 3: Unsupported protocol error (not HTTPS)
+	let http_url = "http://example.org/webhook";
+	let result =
+		client_handler.set_webhook(service_node_id, app_name.to_string(), http_url.to_string());
+	assert!(result.is_err(), "Expected error due to non-HTTPS protocol");
+	let error = result.unwrap_err();
+	assert_eq!(error, LSPS5ProtocolError::UnsupportedProtocol.into());
+
+	// TEST 4: App name too long
+	let long_app_name = "A".repeat(65);
+	let valid_url = "https://example.org/webhook";
+	let result = client_handler.set_webhook(service_node_id, long_app_name, valid_url.to_string());
+	assert!(result.is_err(), "Expected error due to app name too long");
+	let error = result.unwrap_err();
+	assert!(error == LSPS5ProtocolError::AppNameTooLong.into());
+
+	// TEST 5: Too many webhooks - register the max number and then try one more
+	let valid_app_name_base = "Valid App";
+	let valid_url = "https://example.org/webhook";
+	for i in 0..DEFAULT_MAX_WEBHOOKS_PER_CLIENT {
+		let app_name = format!("{} {}", valid_app_name_base, i);
+		let _ = client_handler
+			.set_webhook(service_node_id, app_name, valid_url.to_string())
+			.expect("Should be able to register webhook");
+
+		let request = get_lsps_message!(client_node, service_node_id);
+		service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap();
+
+		let response = get_lsps_message!(service_node, client_node_id);
+		client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap();
+
+		let _ = client_node.liquidity_manager.next_event().unwrap();
+	}
+
+	// Now try to add one more webhook - should fail with too many webhooks error
+	let raw_one_too_many = format!("{} {}", valid_app_name_base, DEFAULT_MAX_WEBHOOKS_PER_CLIENT);
+	let one_too_many = LSPS5AppName::from_string(raw_one_too_many.to_string()).unwrap();
+	let _ = client_handler
+		.set_webhook(service_node_id, raw_one_too_many.clone(), valid_url.to_string())
+		.expect("Request should send but will receive error response");
+
+	let request = get_lsps_message!(client_node, service_node_id);
+	let result = service_node.liquidity_manager.handle_custom_message(request, client_node_id);
+	assert!(result.is_err(), "Server should return an error for too many webhooks");
+
+	let response = get_lsps_message!(service_node, client_node_id);
+
+	client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap();
+
+	let event = client_node.liquidity_manager.next_event().unwrap();
+	match event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistrationFailed {
+			error,
+			app_name,
+			..
+		}) => {
+			let error_to_check = LSPS5ProtocolError::TooManyWebhooks;
+			assert_eq!(error, error_to_check.into());
+			assert_eq!(app_name, one_too_many);
+		},
+		_ => panic!("Expected WebhookRegistrationFailed event, got {:?}", event),
+	}
+
+	// TEST 6: Remove a non-existent webhook
+	let raw_nonexistent_app = "NonexistentApp";
+	let nonexistent_app = LSPS5AppName::from_string(raw_nonexistent_app.to_string()).unwrap();
+	let _ = client_handler
+		.remove_webhook(service_node_id, raw_nonexistent_app.to_string())
+		.expect("Remove webhook request should send successfully");
+
+	let request = get_lsps_message!(client_node, service_node_id);
+	let result = service_node.liquidity_manager.handle_custom_message(request, client_node_id);
+	assert!(result.is_err(), "Server should return an error for non-existent webhook");
+
+	let response = get_lsps_message!(service_node, client_node_id);
+
+	client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap();
+
+	let event = client_node.liquidity_manager.next_event().unwrap();
+	match event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRemovalFailed {
+			error,
+			app_name,
+			..
+		}) => {
+			assert_eq!(error, LSPS5ProtocolError::AppNameNotFound.into());
+			assert_eq!(app_name, nonexistent_app);
+		},
+		_ => panic!("Expected WebhookRemovalFailed event, got {:?}", event),
+	}
+}
+
+#[test]
+fn webhook_notification_delivery_test() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "Webhook Test App";
+	let webhook_url = "https://www.example.org/push?token=test123";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let set_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_request, client_node_id)
+		.unwrap();
+
+	let notification_event = service_node.liquidity_manager.next_event().unwrap();
+	let (timestamp_value, signature_value, notification) = match notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url,
+			headers,
+			notification,
+			..
+		}) => {
+			let (timestamp, signature) = extract_ts_sig(&headers);
+			assert_eq!(url.as_str(), webhook_url);
+			assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered);
+			(timestamp, signature, notification)
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	};
+
+	let set_webhook_response = get_lsps_message!(service_node, client_node_id);
+	client_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_response, service_node_id)
+		.unwrap();
+
+	let _ = client_node.liquidity_manager.next_event().unwrap();
+
+	let result =
+		validator.validate(service_node_id, &timestamp_value, &signature_value, &notification);
+	assert!(
+		result.is_ok(),
+		"Client should be able to parse and validate the webhook_registered notification"
+	);
+
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+
+	let payment_notification_event = service_node.liquidity_manager.next_event().unwrap();
+	let (payment_timestamp, payment_signature, notification) = match payment_notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url,
+			headers,
+			notification,
+			..
+		}) => {
+			let (timestamp, signature) = extract_ts_sig(&headers);
+			assert_eq!(url.as_str(), webhook_url);
+			assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming);
+			(timestamp, signature, notification)
+		},
+		_ => panic!("Expected SendWebhookNotification event for payment_incoming"),
+	};
+
+	let result =
+		validator.validate(service_node_id, &payment_timestamp, &payment_signature, &notification);
+	assert!(
+		result.is_ok(),
+		"Client should be able to parse and validate the payment_incoming notification"
+	);
+
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+
+	assert!(
+		service_node.liquidity_manager.next_event().is_none(),
+		"No event should be emitted due to cooldown"
+	);
+
+	let timeout_block = 700000; // Some future block height
+	let _ = service_handler.notify_expiry_soon(client_node_id, timeout_block);
+
+	let expiry_notification_event = service_node.liquidity_manager.next_event().unwrap();
+	match expiry_notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			notification,
+			..
+		}) => {
+			assert!(matches!(
+				notification.method,
+				WebhookNotificationMethod::LSPS5ExpirySoon { timeout } if timeout == timeout_block
+			));
+		},
+		_ => panic!("Expected SendWebhookNotification event for expiry_soon"),
+	};
+}
+
+#[test]
+fn multiple_webhooks_notification_test() {
+	let (service_node_id, client_node_id, service_node, client_node, _) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let webhooks = vec![
+		("Mobile App", "https://www.example.org/mobile-push?token=abc123"),
+		("Desktop App", "https://www.example.org/desktop-push?token=def456"),
+		("Web App", "https://www.example.org/web-push?token=ghi789"),
+	];
+
+	for (app_name, webhook_url) in &webhooks {
+		let _ = client_handler
+			.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+			.expect("Register webhook request should succeed");
+		let set_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+		service_node
+			.liquidity_manager
+			.handle_custom_message(set_webhook_request, client_node_id)
+			.unwrap();
+
+		// Consume SendWebhookNotification event for webhook_registered
+		let _ = service_node.liquidity_manager.next_event().unwrap();
+
+		let set_webhook_response = get_lsps_message!(service_node, client_node_id);
+		client_node
+			.liquidity_manager
+			.handle_custom_message(set_webhook_response, service_node_id)
+			.unwrap();
+
+		let _ = client_node.liquidity_manager.next_event().unwrap();
+	}
+
+	let _ = service_handler.notify_liquidity_management_request(client_node_id);
+
+	let mut seen_webhooks = HashSet::default();
+
+	for _ in 0..3 {
+		let notification_event = service_node.liquidity_manager.next_event().unwrap();
+		match notification_event {
+			LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+				url,
+				notification,
+				..
+			}) => {
+				seen_webhooks.insert(url.as_str().to_string());
+
+				assert_eq!(
+					notification.method,
+					WebhookNotificationMethod::LSPS5LiquidityManagementRequest
+				);
+			},
+			_ => panic!("Expected SendWebhookNotification event"),
+		}
+	}
+
+	for (_, webhook_url) in &webhooks {
+		assert!(
+			seen_webhooks.contains(*webhook_url),
+			"Webhook URL {} should have been called",
+			webhook_url
+		);
+	}
+
+	let new_app = "New App";
+	let new_webhook = "https://www.example.org/new-push?token=xyz789";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, new_app.to_string(), new_webhook.to_string())
+		.expect("Register new webhook request should succeed");
+	let set_webhook_request = get_lsps_message!(client_node, service_node_id);
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_request, client_node_id)
+		.unwrap();
+
+	let notification_event = service_node.liquidity_manager.next_event().unwrap();
+	match notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url,
+			notification,
+			..
+		}) => {
+			assert_eq!(url.as_str(), new_webhook);
+			assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered);
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	}
+}
+
+#[test]
+fn idempotency_set_webhook_test() {
+	let (service_node_id, client_node_id, service_node, client_node, _) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+
+	let app_name = "Idempotency Test App";
+	let webhook_url = "https://www.example.org/webhook?token=test123";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("First webhook registration should succeed");
+	let set_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_request, client_node_id)
+		.unwrap();
+
+	let notification_event = service_node.liquidity_manager.next_event().unwrap();
+	match notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { .. }) => {},
+		_ => panic!("Expected SendWebhookNotification event"),
+	}
+
+	let set_webhook_response = get_lsps_message!(service_node, client_node_id);
+	client_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_response, service_node_id)
+		.unwrap();
+
+	let webhook_registered_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhook_registered_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { no_change, .. }) => {
+			assert_eq!(no_change, false, "First registration should have no_change=false");
+		},
+		_ => panic!("Unexpected event"),
+	}
+
+	// Now register the SAME webhook AGAIN (should be idempotent)
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Second identical webhook registration should succeed");
+	let set_webhook_request_again = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_request_again, client_node_id)
+		.unwrap();
+
+	assert!(
+		service_node.liquidity_manager.next_event().is_none(),
+		"No notification should be sent for idempotent operation"
+	);
+
+	let set_webhook_response_again = get_lsps_message!(service_node, client_node_id);
+	client_node
+		.liquidity_manager
+		.handle_custom_message(set_webhook_response_again, service_node_id)
+		.unwrap();
+
+	let webhook_registered_again_client_event = client_node.liquidity_manager.next_event().unwrap();
+	match webhook_registered_again_client_event {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { no_change, .. }) => {
+			assert_eq!(no_change, true, "Second identical registration should have no_change=true");
+		},
+		_ => panic!("Expected WebhookRegistered event for second registration"),
+	}
+
+	let updated_webhook_url = "https://www.example.org/webhook?token=updated456";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), updated_webhook_url.to_string())
+		.expect("Update webhook request should succeed");
+	let update_webhook_request = get_lsps_message!(client_node, service_node_id);
+
+	service_node
+		.liquidity_manager
+		.handle_custom_message(update_webhook_request, client_node_id)
+		.unwrap();
+
+	// For an update, a SendWebhookNotification event SHOULD be emitted
+	let notification_update_event = service_node.liquidity_manager.next_event().unwrap();
+	match notification_update_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url, ..
+		}) => {
+			assert_eq!(url.as_str(), updated_webhook_url);
+		},
+		_ => panic!("Expected SendWebhookNotification event for update"),
+	}
+}
+
+#[test]
+fn replay_prevention_test() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "Replay Prevention Test App";
+	let webhook_url = "https://www.example.org/webhook?token=replay123";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let request = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap();
+
+	// Consume initial SendWebhookNotification event
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let response = get_lsps_message!(service_node, client_node_id);
+	client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap();
+
+	let _ = client_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+
+	let notification_event = service_node.liquidity_manager.next_event().unwrap();
+	let (timestamp, signature, body) = match notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			headers,
+			notification,
+			..
+		}) => {
+			let (timestamp, signature) = extract_ts_sig(&headers);
+			(timestamp, signature, notification)
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	};
+
+	let result = validator.validate(service_node_id, &timestamp, &signature, &body);
+	assert!(result.is_ok(), "First verification should succeed");
+
+	// Try again with same timestamp and signature (simulate replay attack)
+	let replay_result = validator.validate(service_node_id, &timestamp, &signature, &body);
+
+	// This should now fail since we've implemented replay prevention
+	assert!(replay_result.is_err(), "Replay attack should be detected and rejected");
+
+	let err = replay_result.unwrap_err();
+	assert_eq!(err, LSPS5ClientError::ReplayAttack);
+}
+
+#[test]
+fn stale_webhooks() {
+	let mock_time_provider = Arc::new(MockTimeProvider::new(1000));
+	let time_provider = Arc::<MockTimeProvider>::clone(&mock_time_provider);
+	let (service_node_id, client_node_id, service_node, client_node, _) =
+		lsps5_test_setup(time_provider, None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+
+	let raw_app_name = "StaleApp";
+	let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap();
+	let raw_webhook_url = "https://example.org/stale";
+	let _ = LSPS5WebhookUrl::from_string(raw_webhook_url.to_string()).unwrap();
+	let _ = client_handler
+		.set_webhook(service_node_id, raw_app_name.to_string(), raw_webhook_url.to_string())
+		.unwrap();
+	let req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(req, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let resp = get_lsps_message!(service_node, client_node_id);
+	client_node.liquidity_manager.handle_custom_message(resp, service_node_id).unwrap();
+	let _ = client_node.liquidity_manager.next_event().unwrap();
+
+	// LIST before prune -> should contain our webhook
+	let _ = client_handler.list_webhooks(service_node_id);
+	let list_req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(list_req, client_node_id).unwrap();
+
+	let list_resp = get_lsps_message!(service_node, client_node_id);
+	client_node.liquidity_manager.handle_custom_message(list_resp, service_node_id).unwrap();
+	let list_cli = client_node.liquidity_manager.next_event().unwrap();
+	match list_cli {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed { app_names, .. }) => {
+			assert_eq!(app_names, vec![app_name.clone()]);
+		},
+		_ => panic!("Expected WebhooksListed before prune (client)"),
+	}
+
+	mock_time_provider.advance_time(
+		MIN_WEBHOOK_RETENTION_DAYS.as_secs() + PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs(),
+	);
+
+	// LIST calls prune before executing -> should be empty after advancing time
+	let _ = client_handler.list_webhooks(service_node_id);
+	let list_req2 = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(list_req2, client_node_id).unwrap();
+
+	let list_resp2 = get_lsps_message!(service_node, client_node_id);
+	client_node.liquidity_manager.handle_custom_message(list_resp2, service_node_id).unwrap();
+	let list_cli2 = client_node.liquidity_manager.next_event().unwrap();
+	match list_cli2 {
+		LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed { app_names, .. }) => {
+			assert!(app_names.is_empty(), "Expected no webhooks after prune (client)");
+		},
+		_ => panic!("Expected WebhooksListed after prune (client)"),
+	}
+}
+
+#[test]
+fn test_all_notifications() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "OnionApp";
+	let webhook_url = "https://www.example.org/onion";
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let set_req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_onion_message_incoming(client_node_id);
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+	let _ = service_handler.notify_expiry_soon(client_node_id, 1000);
+	let _ = service_handler.notify_liquidity_management_request(client_node_id);
+
+	let expected_notifications = vec![
+		WebhookNotificationMethod::LSPS5OnionMessageIncoming,
+		WebhookNotificationMethod::LSPS5PaymentIncoming,
+		WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 1000 },
+		WebhookNotificationMethod::LSPS5LiquidityManagementRequest,
+	];
+
+	for expected_method in expected_notifications {
+		let event = service_node.liquidity_manager.next_event().unwrap();
+		if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			url,
+			headers,
+			notification,
+			..
+		}) = event
+		{
+			assert_eq!(url.as_str(), webhook_url);
+			assert_eq!(notification.method, expected_method);
+			let (timestamp, signature) = extract_ts_sig(&headers);
+
+			let parse_result =
+				validator.validate(service_node_id, &timestamp, &signature, &notification);
+			assert!(parse_result.is_ok(), "Failed to parse {:?} notification", expected_method);
+		} else {
+			panic!("Unexpected event: {:?}", event);
+		}
+	}
+}
+
+#[test]
+fn test_tampered_notification() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "OnionApp";
+	let webhook_url = "https://www.example.org/onion";
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let set_req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_expiry_soon(client_node_id, 700000);
+
+	let event = service_node.liquidity_manager.next_event().unwrap();
+	if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+		url: _,
+		headers,
+		notification,
+		..
+	}) = event
+	{
+		let notification_json = serde_json::to_string(&notification).unwrap();
+		let mut json_value: serde_json::Value = serde_json::from_str(&notification_json).unwrap();
+		json_value["params"]["timeout"] = serde_json::json!(800000);
+		let tampered_timeout_json = json_value.to_string();
+
+		let tampered_notification: WebhookNotification =
+			serde_json::from_str(&tampered_timeout_json).unwrap();
+		let (timestamp, signature) = extract_ts_sig(&headers);
+		let tampered_result =
+			validator.validate(service_node_id, &timestamp, &signature, &tampered_notification);
+		assert_eq!(tampered_result.unwrap_err(), LSPS5ClientError::InvalidSignature);
+	} else {
+		panic!("Unexpected event: {:?}", event);
+	}
+
+	assert!(client_node.liquidity_manager.next_event().is_none());
+}
+
+#[test]
+fn test_bad_signature_notification() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "OnionApp";
+	let webhook_url = "https://www.example.org/onion";
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.unwrap();
+	let set_req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_onion_message_incoming(client_node_id);
+
+	let event = service_node.liquidity_manager.next_event().unwrap();
+	if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+		url: _,
+		headers,
+		notification,
+		..
+	}) = event
+	{
+		let (timestamp, _) = extract_ts_sig(&headers);
+
+		let invalid_signature = "xdtk1zf63sfn81r6qteymy73mb1b7dspj5kwx46uxwd6c3pu7y3bto";
+		let bad_signature_result =
+			validator.validate(service_node_id, &timestamp, &invalid_signature, &notification);
+		assert!(bad_signature_result.unwrap_err() == LSPS5ClientError::InvalidSignature);
+	} else {
+		panic!("Unexpected event: {:?}", event);
+	}
+
+	assert!(client_node.liquidity_manager.next_event().is_none());
+}
+
+#[test]
+fn test_timestamp_notification_window_validation() {
+	let mock_time_provider = Arc::new(MockTimeProvider::new(
+		SystemTime::now()
+			.duration_since(UNIX_EPOCH)
+			.expect("system time before Unix epoch")
+			.as_secs(),
+	));
+	let time_provider = Arc::<MockTimeProvider>::clone(&mock_time_provider);
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(time_provider, None);
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "OnionApp";
+	let webhook_url = "https://www.example.org/onion";
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let set_req = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_onion_message_incoming(client_node_id);
+
+	let expected_method = WebhookNotificationMethod::LSPS5OnionMessageIncoming;
+
+	let event = service_node.liquidity_manager.next_event().unwrap();
+	if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+		url,
+		notification,
+		headers,
+		..
+	}) = event
+	{
+		assert_eq!(url.as_str(), webhook_url);
+		assert_eq!(notification.method, expected_method);
+		let (timestamp, signature) = extract_ts_sig(&headers);
+
+		// 1) future timestamp
+		mock_time_provider.advance_time(60 * 60);
+		let err_past =
+			validator.validate(service_node_id, &timestamp, &signature, &notification).unwrap_err();
+		assert!(
+			matches!(err_past, LSPS5ClientError::InvalidTimestamp),
+			"Expected InvalidTimestamp error variant, got {:?}",
+			err_past
+		);
+
+		// 2) Past timestamp
+		mock_time_provider.rewind_time(60 * 60 * 2);
+		let err_future =
+			validator.validate(service_node_id, &timestamp, &signature, &notification).unwrap_err();
+		assert!(
+			matches!(err_future, LSPS5ClientError::InvalidTimestamp),
+			"Expected InvalidTimestamp error variant, got {:?}",
+			err_future
+		);
+	} else {
+		panic!("Unexpected event: {:?}", event);
+	}
+
+	assert!(client_node.liquidity_manager.next_event().is_none());
+}
+
+#[test]
+fn test_notify_without_webhooks_does_nothing() {
+	let (_, client_node_id, service_node, _, _) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), None);
+
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	// without ever registering a webhook -> both notifiers should early-return
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+	assert!(service_node.liquidity_manager.next_event().is_none());
+
+	let _ = service_handler.notify_onion_message_incoming(client_node_id);
+	assert!(service_node.liquidity_manager.next_event().is_none());
+}
+
+#[test]
+fn no_replay_error_when_signature_storage_is_disabled() {
+	let (service_node_id, client_node_id, service_node, client_node, validator) =
+		lsps5_test_setup(Arc::new(DefaultTimeProvider), Some(0));
+
+	let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
+	let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
+
+	let app_name = "test app";
+	let webhook_url = "https://www.example.org/webhook?token=replay123";
+
+	let _ = client_handler
+		.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
+		.expect("Register webhook request should succeed");
+	let request = get_lsps_message!(client_node, service_node_id);
+	service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap();
+
+	// consume initial SendWebhookNotification
+	let _ = service_node.liquidity_manager.next_event().unwrap();
+
+	let response = get_lsps_message!(service_node, client_node_id);
+	client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap();
+
+	let _ = client_node.liquidity_manager.next_event().unwrap();
+
+	let _ = service_handler.notify_payment_incoming(client_node_id);
+
+	let notification_event = service_node.liquidity_manager.next_event().unwrap();
+	let (timestamp, signature, body) = match notification_event {
+		LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
+			headers,
+			notification,
+			..
+		}) => {
+			let (timestamp, signature) = extract_ts_sig(&headers);
+			(timestamp, signature, notification)
+		},
+		_ => panic!("Expected SendWebhookNotification event"),
+	};
+
+	// max_signatures is set to 0, so there is no replay attack prevention
+	// and the same notification can be parsed multiple times without error
+	for _ in 0..4 {
+		let result = validator.validate(service_node_id, &timestamp, &signature, &body);
+		assert!(result.is_ok(), "Verification should succeed because storage is disabled");
+	}
+}