diff --git a/README.md b/README.md
index d6a96523..e7532ad4 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,7 @@ The table below identifies the services this tool supports and some example serv
| [SendGrid](https://github.com/caronc/apprise/wiki/Notify_sendgrid) | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/
sendgrid://APIToken:FromEmail/ToEmail
sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/
| [ServerChan](https://github.com/caronc/apprise/wiki/Notify_serverchan) | schan:// | (TCP) 443 | schan://sendkey/
| [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
+| [SIGNL4](https://github.com/caronc/apprise/wiki/Notify_signl4) | signl4:// | (TCP) 80 or 443 | signl4://hostname
| [SimplePush](https://github.com/caronc/apprise/wiki/Notify_simplepush) | spush:// | (TCP) 443 | spush://apikey
spush://salt:password@apikey
spush://apikey?event=Apprise
| [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN
| [SMTP2Go](https://github.com/caronc/apprise/wiki/Notify_smtp2go) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey
smtp2go://user@hostname/apikey/email
smtp2go://user@hostname/apikey/email1/email2/emailN
smtp2go://user@hostname/apikey/?name="From%20User"
diff --git a/apprise/plugins/signl4.py b/apprise/plugins/signl4.py
new file mode 100644
index 00000000..fab1eeeb
--- /dev/null
+++ b/apprise/plugins/signl4.py
@@ -0,0 +1,350 @@
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2025, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# API Refererence:
+# - https://docs.signl4.com/integrations/webhook/webhook.html
+#
+
+from json import dumps
+from typing import Any, Optional
+
+import requests
+
+from ..common import NotifyType
+from ..locale import gettext_lazy as _
+from ..url import PrivacyMode
+from ..utils.parse import parse_bool, validate_regex
+from .base import NotifyBase
+
+
+class NotifySIGNL4(NotifyBase):
+ """
+ A wrapper for SIGNL4 Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = "SIGNL4"
+
+ # The services URL
+ service_url = "https://signl4.com/"
+
+ # Secure Protocol
+ secure_protocol = "signl4"
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = "https://github.com/caronc/apprise/wiki/Notify_signl4"
+
+ # Our event action type
+ event_action = "trigger"
+
+ # Our default notification URL
+ notify_url = "https://connect.signl4.com/webhook/{secret}/"
+
+ # Define object templates
+ templates = (
+ "{schema}://{secret}",
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ # SIGNL4 team or integration secret
+ "secret": {
+ "name": _("Secret"),
+ "type": "string",
+ "private": True,
+ "required": True
+ },
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ "service": {
+ "name": _("Service"),
+ "type": "string",
+ },
+ "location": {
+ "name": _("Location"),
+ "type": "string",
+ },
+ "alerting_scenario": {
+ "name": _("Alerting Scenario"),
+ "type": "string",
+ },
+ "filtering": {
+ "name": _("Filtering"),
+ "type": "bool",
+ "default": False,
+ },
+ "external_id": {
+ "name": _("External ID"),
+ "type": "string",
+ },
+ "status": {
+ "name": _("Status"),
+ "type": "string",
+ },
+ })
+
+ def __init__(
+ self,
+ secret: str,
+ service: Optional[str] = None,
+ location: Optional[str] = None,
+ alerting_scenario: Optional[str] = None,
+ filtering: Optional[bool] = None,
+ external_id: Optional[str] = None,
+ status: Optional[str] = None,
+ **kwargs: Any
+ ) -> None:
+ """
+ Initialize SIGNL4 Object
+ """
+ super().__init__(**kwargs)
+
+ # SIGNL4 team or integration secret
+ self.secret = validate_regex(secret)
+ if not self.secret:
+ msg = "An invalid SIGNL4 team or integration secret " \
+ "({}) was specified.".format(secret)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # A service option for notifications
+ self.service = service
+
+ # A location option for notifications
+ self.location = location
+
+ # A alerting_scenario option for notifications
+ self.alerting_scenario = alerting_scenario
+
+ # A filtering option for notifications
+ self.filtering = (
+ self.template_args["filtering"]["default"]
+ if filtering is None
+ else bool(filtering)
+ )
+
+ # A external_id option for notifications
+ self.external_id = external_id
+
+ # A location option for notifications
+ self.status = status
+
+ return
+
+ def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
+ """
+ Send our SIGNL4 Notification
+ """
+
+ # Prepare our headers
+ headers = {
+ "Content-Type": "application/json",
+ }
+
+ # Prepare our persistent_notification.create payload
+ payload = {
+ "title": title if title else self.app_desc,
+ "body": body,
+ "X-S4-SourceSystem": self.app_id,
+ }
+
+ if self.service:
+ payload["X-S4-Service"] = self.service
+
+ if self.alerting_scenario:
+ payload["X-S4-AlertingScenario"] = self.alerting_scenario
+
+ if self.location:
+ payload["X-S4-Location"] = self.location
+
+ if self.filtering:
+ payload["X-S4-Filtering"] = self.filtering
+
+ if self.external_id:
+ payload["X-S4-ExternalID"] = self.external_id
+
+ if self.status:
+ payload["X-S4-Status"] = self.status
+
+ # Prepare our URL
+ notify_url = self.notify_url.format(secret=self.secret)
+
+ self.logger.debug(
+ "SIGNL4 POST URL: %s (cert_verify=%s)",
+ notify_url, self.verify_certificate)
+ self.logger.debug("SIGNL4 Payload: %r", payload)
+
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ try:
+ r = requests.post(
+ notify_url,
+ data=dumps(payload),
+ headers=headers,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ if r.status_code not in (
+ requests.codes.ok, requests.codes.created,
+ requests.codes.accepted):
+ # We had a problem
+ status_str = \
+ NotifySIGNL4.http_response_code_lookup(
+ r.status_code)
+
+ self.logger.warning(
+ "Failed to send SIGNL4 notification: "
+ "{}{}error={}.".format(
+ status_str,
+ ", " if status_str else "",
+ r.status_code))
+
+ self.logger.debug("Response Details:\r\n%r", r.content)
+
+ # Return; we're done
+ return False
+
+ else:
+ self.logger.info("Sent SIGNL4 notification.")
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ "A Connection error occurred sending SIGNL4 "
+ "notification to %s", self.host)
+ self.logger.debug("Socket Exception: %s", str(e))
+
+ # Return; we're done
+ return False
+
+ return True
+
+ @property
+ def url_identifier(self):
+ """
+ Returns all of the identifiers that make this URL unique from
+ another simliar one. Targets or end points should never be identified
+ here.
+ """
+ return (
+ self.secure_protocol, self.secret,
+ )
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ params = {}
+
+ if self.service is not None:
+ params["service"] = self.service
+
+ if self.location is not None:
+ params["location"] = self.location
+
+ if self.alerting_scenario is not None:
+ params["alerting_scenario"] = self.alerting_scenario
+
+ if self.filtering != self.template_args["filtering"]["default"]:
+ # Only add filtering if it is not the default value
+ params["filtering"] = "yes" if self.filtering else "no"
+
+ if self.external_id is not None:
+ params["external_id"] = self.external_id
+
+ if self.status is not None:
+ params["status"] = self.status
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ url = "{schema}://{secret}"
+
+ return url.format(
+ schema=self.secure_protocol,
+ # never encode hostname since we're expecting it to be a valid one
+ secret=self.pprint(
+ self.secret, privacy, mode=PrivacyMode.Secret, safe=""),
+ params=NotifySIGNL4.urlencode(params),
+ )
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to re-instantiate this object.
+ """
+
+ results = NotifyBase.parse_url(url, verify_host=False)
+ if not results:
+ # We're done early as we couldn"t load the results
+ return results
+
+ # The "secret" makes it easier to use yaml configuration
+ if "secret" in results["qsd"] and \
+ len(results["qsd"]["secret"]):
+ results["secret"] = \
+ NotifySIGNL4.unquote(results["qsd"]["secret"])
+ else:
+ results["secret"] = \
+ NotifySIGNL4.unquote(results["host"])
+
+ if "service" in results["qsd"] and len(results["qsd"]["service"]):
+ results["service"] = \
+ NotifySIGNL4.unquote(results["qsd"]["service"])
+
+ if "location" in results["qsd"] and len(results["qsd"]["location"]):
+ results["location"] = \
+ NotifySIGNL4.unquote(results["qsd"]["location"])
+
+ if "alerting_scenario" in results["qsd"] and \
+ len(results["qsd"]["alerting_scenario"]):
+ results["alerting_scenario"] = \
+ NotifySIGNL4.unquote(results["qsd"]["alerting_scenario"])
+
+ if "filtering" in results["qsd"] and len(results["qsd"]["filtering"]):
+ results["filtering"] = \
+ parse_bool(
+ NotifySIGNL4.unquote(
+ results["qsd"]["filtering"],
+ NotifySIGNL4.template_args["filtering"]["default"]))
+
+ if "external_id" in results["qsd"] and \
+ len(results["qsd"]["external_id"]):
+ results["external_id"] = \
+ NotifySIGNL4.unquote(results["qsd"]["external_id"])
+
+ if "status" in results["qsd"] and len(results["qsd"]["status"]):
+ results["status"] = \
+ NotifySIGNL4.unquote(results["qsd"]["status"])
+
+ return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index 68b628db..253ad8d6 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -69,7 +69,7 @@ notification services. It supports sending alerts to platforms such as: \
`ParsePlatform`, `Plivo`, `PopcornNotify`, `Prowl`, `Pushalot`, \
`PushBullet`, `Pushjet`, `PushMe`, `Pushover`, `Pushplus`, `PushSafer`, \
`Pushy`, `PushDeer`, `QQ Push`, `Revolt`, `Reddit`, `Resend`, `Rocket.Chat`, \
-`RSyslog`, `SendGrid`, `ServerChan`, `Seven`, `SFR`, `Signal`, \
+`RSyslog`, `SendGrid`, `ServerChan`, `Seven`, `SFR`, `Signal`, `SIGNL4`, \
`SimplePush`, `Sinch`, `Slack`, `SMPP`, `SMSEagle`, `SMS Manager`, \
`SMTP2Go`, `SparkPost`, `Splunk`, `Spike`, `Spug Push`, `Super Toasty`, \
`Streamlabs`, `Stride`, `Synology Chat`, `Syslog`, `Techulus Push`, \
diff --git a/pyproject.toml b/pyproject.toml
index bca515f9..18f3a576 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -143,6 +143,7 @@ keywords = [
"Seven",
"SFR",
"Signal",
+ "SIGNL4",
"SimplePush",
"Sinch",
"Slack",
diff --git a/tests/test_plugin_signl4.py b/tests/test_plugin_signl4.py
new file mode 100644
index 00000000..a7a35835
--- /dev/null
+++ b/tests/test_plugin_signl4.py
@@ -0,0 +1,173 @@
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2025, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.# BSD 2-Clause License
+
+from json import dumps
+
+# Disable logging for a cleaner testing output
+import logging
+
+from helpers import AppriseURLTester
+
+from apprise.plugins.signl4 import (
+ NotifySIGNL4,
+ NotifyType,
+)
+
+logging.disable(logging.CRITICAL)
+
+SIGNL4_GOOD_RESPONSE = dumps({
+ "eventId": "2516485120936941747_76d5cf30-27d2-4529-84ed-f31a8f2c72b1",
+})
+
+# Our Testing URLs
+apprise_url_tests = (
+ (
+ "signl4://",
+ {
+ # We failed to identify any valid authentication
+ "instance": TypeError,
+ },
+ ),
+ (
+ "signl4://:@/",
+ {
+ # We failed to identify any valid authentication
+ "instance": TypeError,
+ },
+ ),
+ (
+ "signl4://%20%20/",
+ {
+ # invalid secret specified
+ "instance": TypeError,
+ },
+ ),
+ (
+ "signl4://secret/",
+ {
+ # No targets specified; this is allowed
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://?secret=secret",
+ {
+ # No targets specified; this is allowed
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?service=IoT",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?filtering=yes",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?location=40.6413111,-73.7781391",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?alerting_scenario=singl4_ack",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?filtering=False",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/?external_id=ar1234&status=new",
+ {
+ # European Region
+ "instance": NotifySIGNL4,
+ "notify_type": NotifyType.FAILURE,
+ # Our response expected server response
+ "requests_response_text": SIGNL4_GOOD_RESPONSE,
+ },
+ ),
+ (
+ "signl4://secret/",
+ {
+ "instance": NotifySIGNL4,
+ # throw a bizzare code forcing us to fail to look it up
+ "response": False,
+ "requests_response_code": 999,
+ },
+ ),
+ (
+ "signl4://secret/",
+ {
+ "instance": NotifySIGNL4,
+ # Throws a series of i/o exceptions with this flag
+ # is set and tests that we gracfully handle them
+ "test_requests_exceptions": True,
+ },
+ ),
+)
+
+def test_plugin_signl4_urls():
+ """NotifySIGNL4() Apprise URLs."""
+
+ # Run our general tests
+ AppriseURLTester(tests=apprise_url_tests).run_all()