Skip to content

Message Format Pipeline

When writing a plugin, you declare notify_format to tell Apprise what format your upstream service expects. When a notification arrives, Apprise resolves the caller’s declared format (body_format), converts the body if necessary, and hands it — already in notify_format — to your plugin. By the time send() runs, the body is pre-formatted. Your code does not need to re-convert it.

ValueMeaningSet by
body_formatFormat of the incoming messagenotify(), AppriseAsset, CLI, or API caller
notify_formatFormat the plugin delivers to its servicePlugin class; optionally overridden by ?format=

Both values are independent. The same HTML source can become plain text for one plugin, Markdown for another, and stay HTML for a third — all in a single notify() call.

The source format is not always None. It depends on which interface triggers the notification:

InterfaceDefault when no format is declared
CLItext — the CLI always sets a source format; override with --input-format
Python Librarynone — unless body_format is passed to notify() or preset in AppriseAsset
Apprise APInone — unless format is included in the request payload

See Formatting for the end-user perspective on these defaults and how they affect delivery across mixed destinations.

For every matched plugin, Apprise:

  1. Resolves body_format from the notify() call. When the Python Library or Apprise API are used without a format, this is None. When the CLI is used without --input-format, this is TEXT. If body_format is not provided to notify(), it falls back to AppriseAsset.body_format, which defaults to None.
  2. Reads notify_format from the plugin instance (class default or ?format= override).
  3. When body_format is not None, calls convert_between(body_format, notify_format, body) and replaces the body with the result. Results are cached per unique destination format so the same conversion is not repeated for multiple plugins that share a format.
  4. Calls plugin.notify(body=<converted>, body_format=<original_format>). The body that arrives at the plugin is already in notify_format. The pre-conversion content is not preserved or passed separately. body_format carries only the format identifier (e.g. NotifyFormat.HTML) — not a copy of the original body text.
Apprise.notify(body=..., body_format=HTML)
|
| convert_between(HTML, plugin.notify_format, body)
| (skipped when body_format is None)
v
plugin.notify(
body=<already converted to notify_format>,
body_format=HTML, # the original format tag -- not the original body
)

Title conversion follows the same path but only when the plugin has no native title field (title_maxlen <= 0). When title_maxlen > 0, the title is passed unchanged and the plugin is responsible for any further formatting.

When body_format is None (Python Library or API call without a format), Apprise skips conversion entirely:

apobj.notify(body="<b>Hello</b>")

The plugin receives "<b>Hello</b>" unchanged and body_format=None. Declaring a notify_format on the plugin class does not alter this — conversion only runs when the caller declares a source format.

The CLI does not produce this state. It always provides body_format=TEXT unless the caller passes --input-format to override it.

NotifyBase defaults to plain text. Set notify_format when the upstream service accepts a richer format:

from apprise import NotifyFormat
from apprise.plugins.base import NotifyBase
class NotifyExample(NotifyBase):
notify_format = NotifyFormat.MARKDOWN

The effective value is always accessible as self.notify_format.

NotifyFormat.TEXT is the default when a plugin does not declare a format.

Source body_formatBody delivered to the plugin
TEXTUnchanged text
MARKDOWNUnchanged Markdown source (no destructive conversion)
HTMLTags removed; block structure flattened to plain text
NoneBody unchanged; no conversion performed

Markdown-to-text has no conversion step today; the Markdown syntax arrives as-is. If the upstream service still interprets Markdown characters on its text delivery path, escape them inside send().

Without an override, the plugin class’s notify_format is used:

example://host/token

A format= query parameter changes the destination for that plugin instance only:

example://host/token?format=text
example://host/token?format=markdown
example://host/token?format=html

The effective destination is always self.notify_format regardless of whether it came from the class or a URL override. Automatic conversion still requires a non-None source body_format.

When plugin logic must distinguish a URL override from the class default, inspect kwargs before calling the base __init__:

def __init__(self, **kwargs):
# Capture whether the caller explicitly set a format override.
self.format_overridden = "format" in kwargs
super().__init__(**kwargs)

Most plugins only need self.notify_format and do not need to track the override separately.

By the time send() runs, conversion and sizing are already done. What your plugin receives:

  • body — already converted to self.notify_format and sized according to overflow_mode. With SPLIT, each send() call receives one chunk of at most body_maxlen characters. With TRUNCATE, the body is capped at body_maxlen. With UPSTREAM (the default), the body arrives unmodified and may exceed the declared limit.
  • title — truncated to title_maxlen characters. When title_maxlen <= 0, the title has been folded into body by the base class and arrives as an empty string.
  • body_format — the format the content started as (e.g. NotifyFormat.HTML); not the original body text itself

Plugins read body_format to decide whether any extra work is needed. If body_format is HTML and notify_format is MARKDOWN, the body was converted from HTML to standard Markdown by Apprise — and the plugin may need to translate that Markdown into the service’s own syntax. If body_format was already MARKDOWN when the caller sent it, the body is the caller’s own Markdown and can usually be passed through unchanged.

Apprise converts the body to standard Markdown. What the plugin does with that Markdown is entirely its own responsibility. Several real-world services require further work beyond what Apprise’s built-in conversion provides:

  • Slack uses mrkdwn instead of standard Markdown
  • Telegram uses MarkdownV2, its own custom syntax
  • WhatsApp (via Evolution) and Google Chat each have their own format
  • Email can include both HTML and plain text in the same message

Keeping the conversion step generic means a plugin can be removed and all service-specific behavior disappears with it. The examples below show the most practical patterns.

Handle dialect translation directly inside send(). The body has already been converted by the time send() runs — check the two format values to decide whether any extra work is needed. If notify_format is MARKDOWN and body_format is HTML, Apprise converted the body from HTML to standard Markdown before calling your plugin, and you can now translate it to the service’s own syntax. Slack uses this approach:

def send(self, body, title="", notify_type=..., body_format=None, **kwargs):
if (
self.notify_format == NotifyFormat.MARKDOWN
and body_format == NotifyFormat.HTML
):
# Apprise converted this from HTML to standard Markdown.
# Translate it now to the dialect this service expects.
body = self._commonmark_to_service_dialect(body)
# ... rest of send() ...

For a complete real-world example, see the Slack plugin source.

Set overflow_mode on the class to declare how the base class handles content that exceeds body_maxlen or title_maxlen. Each send() call receives content that has already been sized accordingly — do not re-split or re-truncate inside send():

Modesend() callsBody guarantee
UPSTREAM (default)OneUnmodified; may exceed body_maxlen
TRUNCATEOneCapped at body_maxlen
SPLITOne per chunkEach chunk ≤ body_maxlen
from apprise.common import OverflowMode
class NotifyExample(NotifyBase):
body_maxlen = 4096
title_maxlen = 255
overflow_mode = OverflowMode.SPLIT

The ?overflow= URL parameter lets users override the mode per instance, but the class default should match what the upstream API actually accepts.

  • apprise/conversion.py converts between text, HTML, and standard Markdown. It must not contain syntax rules for any specific service.
  • Service-specific syntax, escaping rules, payload fields, and API modes belong exclusively in the plugin file.
  • Removing a plugin file must remove all behavior for that service with no changes required to the shared conversion code.
  • Generic utilities with no service-specific knowledge may live in conversion.py. Current exports: build_backtick_run_index, find_unescaped_run, commonmark_escape_link_url, commonmark_scan_angle_dest, commonmark_emphasis_run, commonmark_force_close_spans, commonmark_prepend_title.

The sections above cover the full picture. This checklist summarizes the key decisions for quick reference:

  1. Declare notify_format on the class to tell Apprise what format to convert to before your plugin runs. Omit it to accept plain text (TEXT).
  2. Do not re-convert the body in send(). It is already in self.notify_format. Use body_format to check what the content originally was, not to trigger another conversion pass.
  3. Check body_format to decide whether service-specific formatting is needed. If your service uses its own Markdown syntax (Slack mrkdwn, Telegram MarkdownV2, WhatsApp), apply that translation only when body_format is HTML — meaning the body was originally HTML and Apprise converted it to standard Markdown.
  4. For multi-format payloads (such as email with separate HTML and text parts), call convert_between() inside send() to derive additional representations from the already-converted body.
  5. Treat ?format= as an optional per-instance override. The effective destination is always self.notify_format regardless of its source. Most plugins do not need to track whether the value came from the class or a URL parameter.
  6. Set overflow_mode, body_maxlen, and title_maxlen on the class to declare the limits and splitting strategy for your upstream service. Each send() call receives content already sized to those limits — no additional changes to the payload is required inside send().
  7. Test the full matrix: body_format=None, each supported source format, URL ?format= overrides, and split or truncated messages.
Questions or Feedback?

Documentation

Notice a typo or an error?

Technical Issues

Having trouble with the code? Open an issue on GitHub:

Made with love from Canada