The notification platform's apps. The first is the notification bridge: one API over ntfy and Telegram. Producers submit and dismiss notifications through it; it delivers them to ntfy and Telegram per registration.
Deployed by NotificationsDeploy; its specs and slices live in NotificationsSpecs.
A sender holds the bearer token of its registration. The registration, set up by the
deployment, says where its notifications go: an ntfy topic with the registration's icon, one or
more Telegram chats reached by the registration's own bot, or both. Those are the registration's
channels. A sender never names itself or its channels: the token selects them. How a deployment
configures the bridge is in docs/bridge-configuration.md.
All on port 8090.
| Endpoint | Token | What it does |
|---|---|---|
POST /v1/submit |
yes | Shows a notification, or updates one by its sequence id |
POST /v1/dismiss |
yes | Takes a notification down by its sequence id |
GET /healthz |
no | Answers 200 with the text ok |
GET /metrics |
no | Prometheus metrics (see Observability) |
Submit and dismiss take Authorization: Bearer <token> and a JSON object as the body. The scheme
is case-insensitive. A missing header, another scheme or a token no registration holds is refused
with 401 before the body is read.
curl -sS "$NOTIFICATIONS_URL/v1/submit" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"sequence_id": "build-1234", "priority": "high", "title": "Build 1234 failed",
"message": "**deploy** failed at step `helm upgrade`"}'POST /v1/submit:
| Field | Type | Limit | Meaning | |
|---|---|---|---|---|
message |
string | required | 4096 characters, not blank | The text, as Markdown |
title |
string | optional | 250 characters | Plain text; "" is no title |
priority |
string | optional | min, low, default, high or max |
Defaults to default |
sequence_id |
string | optional | 1 to 256 characters | What a later submit or dismiss names it by |
url |
string | optional | 2048 characters, http or https with a host, visible ASCII |
The notification's link: what a tap opens on ntfy, and an Open button on Telegram |
- The limits count characters (Unicode code points), not bytes.
- A field sent as
nullis taken as omitted. - A field the table does not list is refused, and so is a string that holds a lone surrogate
(
"\ud800"), which has no UTF-8 form. - The priority names are lower case:
"HIGH"is refused. - A
urlholds only!to~: anything else, a space included, is percent-encoded. Beyond that, the bridge checks only its scheme and that it has a host.
POST /v1/dismiss takes one field, the required sequence_id, 1 to 256 characters:
{"sequence_id": "build-1234"}| Status | When | Body |
|---|---|---|
200 |
The body was valid and each of the registration's channels was tried | The per-channel result |
400 |
The body is not JSON or not an object, holds an unknown field or a value of the wrong type, lacks the message or the sequence id it needs, has a blank message, a sequence_id outside 1 to 256 characters, a title over 250, a url over 2048 characters, holding anything but visible ASCII or lacking an http or https scheme or a host, an unknown priority or a string with a lone surrogate |
{"error": "<why>"} |
401 |
The token is missing, not a bearer token, or unknown. The answer carries WWW-Authenticate: Bearer |
{"error": "unknown bearer token"} |
413 |
The message is over 4096 characters and the rest of the body is valid | {"error": "<why>"} |
404, 405 |
Another path, or another method | Plain text |
A refused request reaches no channel. A 200 does not mean that anything was delivered: it carries
each channel's result, and a channel that failed says so there.
A 200 answers a JSON object with one key per channel the registration has, ntfy and
telegram. A channel the registration does not have has no key. Each value is ok or
error: <why>:
{"ntfy": "ok", "telegram": "error: -100123456789: send: HTTP 403: Forbidden: bot was kicked from the group chat"}- ntfy's why is
<step>: HTTP <status>: <ntfy's error>when ntfy refused the call, or<step>: <exception>: <text>when it was not reached. The step ispublish, or on a dismissclearordelete, as the registration'sntfy.dismisssays. - Telegram's why names each chat that failed:
<chat id>: <step>: HTTP <status>: <Telegram's description>, or<chat id>: <step>: <exception>: <text>. The step issend,editordelete. Telegram isokonly when every chat took the call. - A call that fails in more than one step lists each, joined by
;.
Each channel is tried on its own, ntfy first and then each chat in turn, and a failure in one never
costs another. Nothing is queued and nothing is retried, but for a Telegram call whose Open button
Telegram refused, which is made once more without it (see Rendering). Each failure is
counted in /metrics and logged.
A call to ntfy or Telegram gives up when it stalls for 10 seconds, and the calls run one after the other. A submit makes one call to ntfy and at most four to Telegram per chat, and a dismiss one and two. A sender that waits for the result sets its own timeout above that, or it can give up on a notification that was delivered: with one chat, a submit can take about a minute when both channels are slow.
- One-shot. A submit with no
sequence_idshows a new notification on ntfy and sends a new message to every chat. It cannot be updated or dismissed. - Update. The first submit under a sequence id shows its notification. A later submit under the same id updates that notification instead of adding one: see Priorities for when that alerts.
- Dismiss. A dismiss takes the notification down on ntfy as the registration's
ntfy.dismisssays (docs/bridge-configuration.md): a clear, the default, takes it out of the phone's drawer and leaves it in the ntfy app's list, marked read, and a delete takes it out of the list as well. It deletes the message in each chat. The sequence id is then free: a submit under it starts a new notification.
A sequence id belongs to its registration: two registrations that use the same id never touch each other's notifications, also when they share an ntfy topic.
The bridge keeps no title, no text and no url, only what an update needs: per channel, the priority of the sequence and where it is shown. That is kept until a dismiss, or until the sequence has gone 90 days without a submit. After that the bridge has forgotten it. A submit under the id then sends a new Telegram message and leaves the old one, and a dismiss leaves that message. On ntfy, a forgotten sequence is still updated, and cleared or deleted, in place.
- A dismiss for a sequence id the bridge holds nothing for still clears or deletes on ntfy, as
the registration says, and is
okon Telegram without a call. - When Telegram refuses both the delete and the fallback edit of a dismiss (below), the result says so, and the dismiss can be repeated.
- Resubmitting a sequence is safe: where a channel already shows it, the resubmit updates it, and where a channel failed to show it, the resubmit tries again. A one-shot resubmitted is a second notification.
- A rise's take-down of the old Telegram message is not retried. When Telegram's delete and its fallback edit both fail (see Priorities), and the new message goes out, the result says so and the old one stays beside the new: a resubmit or a dismiss acts on the new one only.
- Submits and dismisses of one sequence take turns: the second waits until the first has its result.
| Priority | ntfy priority | Telegram mark | Telegram notifies |
|---|---|---|---|
min |
1 | ⚪ | no, sent silently |
low |
2 | 🔵 | no, sent silently |
default |
3 | none | yes |
high |
4 | 🟠 | yes |
max |
5 | 🚨 | yes |
On ntfy, the phone app decides what each priority does, by ntfy's priority scale. On Telegram, the mark starts the first line of the message, before the title.
On ntfy, a submit under a sequence id replaces the notification under its id, at any priority, and takes the new priority. The Android app alerts on it only when the notification no longer shows in the drawer: while it does, an update is silent, also at a higher priority.
On Telegram, a submit under a sequence id compares its priority with the one the sequence has:
- The same or lower: in place, silently. Telegram edits the message, which never notifies. The mark follows the new priority.
- Higher: a rise, which notifies. Telegram deletes the message and sends it again, and the new
message notifies unless its priority is
lowormin.
The sequence takes the new priority either way, so a sequence that went down can rise again.
Telegram refuses to delete a bot's message that is more than 48 hours old in a private chat. When it refuses the delete of a rise, the old message is edited to Superseded. and the new one is sent all the same. When it refuses the delete of a dismiss, the message is edited to Dismissed.
- ntfy gets the message as Markdown (
X-Markdown: yes), the title as the notification's title, the priority, the registration's icon, and the url as what a tap opens (X-Click). - Telegram gets the message as Telegram HTML, parsed as CommonMark plus
~~strikethrough~~:- bold, italic, strikethrough, inline code, code blocks, links and blockquotes keep their form;
- a heading is a bold line, a list item a
•line, or a numbered one in an ordered list; - an image is a link to it, under its alt text, and a thematic break is
———; - HTML in the message shows as text;
- the first line is the mark and the title in bold; with no title, the mark starts the message;
- link previews are off;
- a message over Telegram's limit of 4096 characters, as shown, is cut and ends in
…; - the url is one button under the message,
Open.
Each submit shows the url it carries, and only that one: an update without a url leaves the ntfy
notification no click and takes the Open button off the Telegram message. The Superseded. and
Dismissed. edits carry no button. When Telegram refuses a send or an edit that carries the button
with a 400, other than for a message that is unchanged or gone, the bridge makes that call once
more without it, and the chat's result is that second call's: the message lands without Open, and
the ntfy click is unaffected. So a url Telegram does not take for a button costs the button, not
the message, and the refusal is logged (see Observability).
ntfy keeps a message as notification text only while it is under ntfy's message size limit, 4096
bytes of UTF-8 by default. The bridge cuts the message under that limit, on a character boundary,
ending in …, so a message near 4096 characters, or a shorter one in a non-Latin script, still
arrives as text. A cut inside a Markdown construct leaves it open, and ntfy shows it as text.
/metricsexposesnotifications_total{registration,channel,outco 5C5F me}, each submit and dismiss counted per channel, withoutcomeokorerror, andnotifications_open_rows{registration, channel}, the rows of state the bridge holds: one per sequence on ntfy, one per sequence and chat on Telegram.- Each submit and dismiss logs one JSON line to stderr: the registration, the sequence id and each channel's result, or for a refused one, its status and why.
- A refused Open button logs one more, at
warning:"event": "button refused", with theregistration, thesequence_id(nullfor a one-shot), thechatand Telegram's refusal aserror, quoted as the result quotes it.
A uv workspace: one member directory per deployable app, each its own uv project with its own
Dockerfile. The root pyproject.toml holds the one ruff and pytest configuration, and the
workspace's uv.lock sits beside it.
bridge/: the notification bridge, the packagenotification_bridge, its tests and itsDockerfile.Jenkinsfileandrun-suite: CI. On each push tomain, Jenkins runs the suite throughrun-suite, builds the bridge's image,registry:5000/notification-bridge, and pins its tag into NotificationsDeploy.docs/bridge-configuration.md: the bridge's environment, registrations file, state and image.docs/architecture/architecture.yaml: the bridge's architecture model, the product, the API it exposes and what it consumes.
kc project setup syncs the workspace into .venv; kc project lint runs ruff's check and format
check over the whole tree, and kc project test runs the pytest suite over the whole tree, with
fakes in place of ntfy and Telegram.
cexec aac-tools arch-validate docs/architecture/architecture.yaml validates the architecture model.
That the bridge's edge to ntfy resolves is proved by NotificationsDeploy's kc project test, which
reads this file from a checkout beside this one.