Custom Notification Decorator
Apprise supports lightweight, file-based notification handlers via the @notify() decorator.
This is ideal when you want a quick integration without creating and publishing a full Apprise plugin.
The decorator system works by importing a Python module and collecting any functions registered with @notify().
Those functions are then exposed as Apprise schemas.
When to Use a Decorator
Section titled “When to Use a Decorator”Use a decorator-based handler when:
- You need a quick, local integration for personal or controlled environments.
- You want to glue Apprise to local automation, scripts, or a private service.
- You do not want to maintain a full plugin lifecycle (tests, packaging, release cadence).
Avoid a decorator-based handler when:
- You want to ship a service to Apprise itself (upstream contribution).
- You need strict parsing, templates, native URL reversal, batching, or robust retry logic.
- You require long-term stability, public distribution, or third-party usage.
Critical Security Note
Section titled “Critical Security Note”The decorator system requires importing a Python module to discover the decorated functions. Importing a module executes code at import time.
That means:
- Any code at module scope will run, not just the decorated function.
- Any imports inside that module will execute.
- Any side effects, file access, network calls, or subprocess calls can occur during load.
Only use decorator-based handlers for trusted code in controlled environments. Do not point Apprise at a shared or untrusted filesystem location.
Decorator vs Full Plugin
Section titled “Decorator vs Full Plugin”Here is the practical decision rule.
| Capability | @notify() Decorator | Full Apprise Plugin |
|---|---|---|
| Time to implement | Minutes | Hours to days |
| Packaging and distribution | Local file | PyPI, distro packages, upstream |
| URL templates and validation | Minimal | Built-in patterns (tokens, args, and strict parsing) |
Privacy handling in url() | Manual | Standardized (PrivacyMode.Secret) |
| Native URL reversal | Rare | Expected when feasible |
| Attachments support | You implement | Built-in patterns (AppriseAttachment) |
| Throttling, retry patterns | You implement | Standardized (throttle(), request helpers) |
| Testability | Ad-hoc | Built-in patterns (AppriseURLTester, and error paths) |
| Best for | Private automation | Upstream services and public use |
If your handler is likely to be reused by other people, becomes business-critical, or needs strong URL parsing, an Apprise plugin is usually more appropriate.
Basic Usage
Section titled “Basic Usage”At minimum, you must define a unique schema and a Python function.
from apprise.decorators import notify
# Maps 'foobar://' to this function and prints to stdout@notify(on="foobar")def my_wrapper(body, title, notify_type, *args, **kwargs): print(f"{notify_type}: {title} - {body}")Once loaded, Apprise can trigger this function using:
apprise -b "Hello world" foobar://Return Values
Section titled “Return Values”A decorated function should return:
Trueto indicate successFalseto indicate failureNone(or no return), which is treated as success
Failures propagate back to Apprise and affect overall notification status.
Function Signature
Section titled “Function Signature”Your wrapper function may accept the following parameters.
| Parameter | Required | Description |
|---|---|---|
body | Yes | Notification body |
title | No | Notification title |
notify_type | No | One of info, success, warning, failure |
body_format | No | The format this send resolved to (text, html, or markdown) — never None, even if the caller declared nothing |
body_passthrough | No | True if no source was declared and automatic format conversion was skipped; otherwise False |
meta | No | Parsed and merged URL metadata |
attach | No | List of AppriseAttachment objectsSee Attachments for how to read, validate, and forward files from decorators. |
*args | Yes | Required for forward compatibility |
**kwargs | Yes | Required for forward compatibility |
Always include *args and **kwargs to remain compatible with future Apprise releases.
Declaring a Format
Section titled “Declaring a Format”By default, your function receives the body without automatic format conversion.
To have Apprise convert it first, add body_format= to @notify():
from apprise import NotifyFormatfrom apprise.decorators import notify
@notify(on="foobar", body_format=NotifyFormat.MARKDOWN)def my_wrapper(body, title, notify_type, *args, **kwargs): # body already arrives as Markdown ...body_format= accepts one format or a tuple of supported formats. The first
format in the tuple is the default. See
Message Format Conversion for more examples.
Without body_format=, Apprise does not convert the body. Your function still
receives body_format and body_passthrough in kwargs. body_format reports
the caller’s format, or TEXT when the caller did not provide one.
Pass-through does not bypass the normal delivery pipeline. The body may already
have been split or truncated according to the configured overflow mode, and the
wrapper may still need to escape or package it safely for its destination. Use
body_passthrough to avoid making source-format assumptions.
A minimal wrapper may look like:
from apprise.decorators import notify
@notify(on="foobar")def my_wrapper(body, *args, **kwargs): print(body)The meta Dictionary
Section titled “The meta Dictionary”The meta parameter provides a fully parsed and merged view of:
- The decorator declaration URL
- The user-supplied initialization URL
Example structure:
{ "schema": "foobar", "url": "foobar://user:pass@host:80/path?key=value", "host": "host", "user": "user", "password": "pass", "port": 80, "path": "/", "fullpath": "/path", "query": "key=value", "qsd": {"key": "value"}, "verify": True, "asset": AppriseAsset(), "tag": set(),}Only fields present in the URL are included. At minimum, schema, url, verify, asset, and tag are always present.
The common URL options work the same way they do for any other service, and their values also appear in meta. This includes user=, pass=, verify=, redirect=, rto=, cto=, format=, overflow=, emojis=, tz=, store=, retry=, wait=, and optional=. For example, foobar://host?retry=2 calls your function up to three times while it keeps returning False. The decorator gets the same meta whether the URL is added directly, loaded from a TEXT configuration, or loaded from a YAML configuration.
In a YAML configuration, any settings written under the URL also appear in meta, and so do template variables once they are supplied:
template: - hosturls: - foobar://${HOST}/?key=value: custom: abcHere meta["custom"] is "abc", and meta["host"] is whatever value was given for host.
Complex Declarations
Section titled “Complex Declarations”The decorator can preload defaults by specifying a full URL.
@notify(on="foobar://localhost:234?notify_on_complete=0")def my_wrapper(body, meta, *args, **kwargs): passUsers may override these values at runtime:
apprise -b "override" foobar://example.com?notify_on_complete=1Merged result:
{ "schema": "foobar", "url": "foobar://example.com:234?notify_on_complete=1", "host": "example.com", "port": 234, "qsd": { "notify_on_complete": "1" }}Plugin Examples
Section titled “Plugin Examples”This example appends a time to the end of a file when called
Usage: demo://
from apprise.decorators import notifyimport logging
@notify(on="demo")def my_wrapper(body, title, notify_type, *args, **kwargs):
# Configure the logging system logging.basicConfig( filename='/tmp/my-demo.log', level=logging.DEBUG, filemode='w', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Create a logger instance logger = logging.getLogger(__name__)
# Log a simple message at the INFO level logger.info("f{title} - {body}")This example sends a JSON payload to a remote server. It demonstrates parsing URL tokens (like apikey).
Usage: myapi://hostname/apikey
from apprise.decorators import notifyimport requests
@notify(on="demo://localhost/")def my_wrapper(body, title, notify_type, *args, **kwargs): print(f"{notify_type}: {title} - {body}") # Get our host and prepare our url to post to host = meta.get('host') url = f'https://{host}/api/v1/send'
# Get our API Key or abort apikey = meta.get('path', '').split('/')[0] if not apikey: return False
# Prepare a payload we want to use: payload = {'key': apikey, 'msg': body}
try: requests.post(url, json=payload)
except (requests.RequestException) as e: return False
return Truefrom __future__ import annotations
import subprocessfrom apprise.decorators import notify
# Only allow a very small set of commands.# Never build these from user input.ALLOWED = { "puppet": ["/usr/bin/puppet", "agent", "-t"], "reload-nginx": ["/usr/bin/systemctl", "reload", "nginx"],}
@notify(schema="ops")def ops_notify( body: str, title: str = "", *, meta: dict | None = None, **kwargs,) -> bool: """ Example usage: ops://?allow=yes
Trigger pattern: title: "run: puppet" -> runs puppet agent -t title: "run: reload-nginx" -> reloads nginx """ meta = meta or {} qsd = meta.get("qsd") or {}
# Guardrail, require explicit opt-in in the URL. allow = str(qsd.get("allow", "")).lower() in ("1", "yes", "true", "on") if not allow: return True
t = (title or "").strip().lower() if not t.startswith("run:"): return True
key = t.split(":", 1)[1].strip() cmd = ALLOWED.get(key) if not cmd: return False
try: proc = subprocess.run( cmd, check=False, capture_output=True, text=True, timeout=60, ) return proc.returncode == 0
except (OSError, subprocess.TimeoutExpired): return Falsefrom __future__ import annotations
import reimport subprocess
from apprise.decorators import notify
# Safety notes:# - Prefer database roles with least privilege.# - Avoid executing arbitrary SQL from the notification body.# - Use a strict mapping from keywords to predefined SQL statements.# - This example calls `psql` to avoid adding a Python dependency.
SQL = { "vacuum": "VACUUM (ANALYZE);", "health": "SELECT 1;", "locks": "SELECT count(*) FROM pg_locks;",}
_KEYWORD = re.compile(r"^db:\s*(?P<key>[a-z0-9_-]+)\s*$", re.IGNORECASE)
# Initializes pg://@notify(on="pg://pgsql.mydomain.com")def pg_hook(body, title, notify_type, meta, *args, **kwargs): m = _KEYWORD.match((title or "").strip()) if not m: return True
key = m.group("key").lower() sql = SQL.get(key) if not sql: return False
# Construct our postgresql:// URL based on the (pg://) one passed in dsn = 'postgresql://' + meta.get('url')[len('pg://'):]
# Call psql safely, no shell argv = [ "psql", dsn, "-v", "ON_ERROR_STOP=1", "-X", "-q", "-c", sql, ] proc = subprocess.run(argv, check=False, text=True, capture_output=True, timeout=60)
if proc.stdout: print(proc.stdout.strip()) if proc.stderr: print(proc.stderr.strip())
return proc.returncode == 0Defaults defined in the decorator persist unless explicitly overridden.
Plugin Registration Behaviour
Section titled “Plugin Registration Behaviour”Internally, the decorator:
- Registers a dynamic
NotifyBasewrapper - Binds your function to the schema
- Enforces schema uniqueness
- Supports attachments and persistent storage automatically
If a schema already exists, Apprise logs a warning and skips registration.
Loading Custom Decorators
Section titled “Loading Custom Decorators”Apprise CLI
Section titled “Apprise CLI”By default, Apprise scans:
- Linux and macOS:
~/.apprise/plugins~/.config/apprise/plugins
- Windows:
%APPDATA%/Apprise/plugins%LOCALAPPDATA%/Apprise/plugins
Override using:
apprise -P /custom/plugin/path -b "test" foobar://Python API
Section titled “Python API”Provide plugin paths through AppriseAsset:
from apprise import Apprise, AppriseAsset
asset = AppriseAsset(plugin_paths=[ "/path/to/plugins", "/path/to/plugin.py",])
aobj = Apprise(asset=asset)aobj.add("foobar://")aobj.notify("Hello")Directory scanning rules:
- Hidden files and directories are ignored
- A directory with
__init__.pyloads only that file - Directories without
__init__.pyload all.pyfiles at that level - Scanning is non-recursive
Restrictions and Best Practices
Section titled “Restrictions and Best Practices”- Schemas must be unique
- Keep modules import-safe. Do not perform network calls or long-running work at import time.
- Validate and sanitize
metavalues before using them. - Prefer allowlists over dynamic execution.
- Put secrets in environment variables or secret stores, not in URLs.
- If you need strong parsing, batching, attachments, or upstream contribution, write a full plugin instead.
Summary
Section titled “Summary”The Apprise decorator API provides a fast, flexible way to extend Apprise with custom logic while preserving the familiar schema:// interface. It is ideal for internal workflows, automation hooks, and tightly integrated systems.
Questions or Feedback?
Technical Issues
Having trouble with the code? Open an issue on GitHub: