Skip to content

Delivery & escalation

A push notification arrives instantly — but only if the phone is at hand. Delivery (the Delivery tab in the channel settings) solves the other half of the problem: what to do when nobody read the message.

Each channel can enable additional delivery methods. They work as an escalation: a message goes out over such a method only if the owner has not read it within a given time.

POST /message ──▶ push to every device (instantly)
│
└─ every minute: the delivery worker
├─ message already read? → do nothing
└─ method timeout elapsed? → email / SMS / call /
Slack / Telegram / webhook /
Discord / Teams / Mattermost /
PagerDuty / Opsgenie
MethodWhat you configureWhat arrives
Emailrecipient address, timeoutan email with a link to the message and action buttons
SMSphone number as +79001234567, timeouta short text with the channel name and the title
Callphone number, timeouta voice call with the notification
Slackincoming webhook URL, timeout, min. prioritya message in the Slack channel
Telegrambot token, chat ID, timeout, min. prioritya message from the bot (with inline buttons)
WebhookURL, optional secret, timeout, min. prioritya POST with a JSON body
Discordwebhook URL, timeout, min. priorityan embed card with action links
Teamsworkflow URL (Workflows), timeout, min. priorityan Adaptive Card with action buttons
Mattermostincoming webhook URL, timeout, min. prioritya message with a coloured bar and links
PagerDutyIntegration Key, region, reaction to “read”an incident (Events API v2)
OpsgenieAPI key, region, reaction to “read”an alert (Alerts API)

Every method has its own delivery timeout (readTimeoutSec):

  • 0 — deliver on the next worker run (that is, within about a minute);
  • 300 — if the message has not been read for 5 minutes;
  • the maximum is 23 hours (82800).

The countdown starts from the moment the message was created, not from when the push was delivered. A message counts as read when you open it in the admin UI or the mobile app, when one of the endpoints POST /message/read / POST /application/{id}/message/read is called, or when you press ✓ Read in the email, in Telegram or in another integration. In the Android app the same is done by the Mark read button right in the notification and by the acknowledge button on the alarm-mode screen.

When a method is enabled, the server records a timestamp. Messages created before that moment are not escalated over the new method — otherwise enabling email in the evening would produce a mail about everything that piled up during the day.

Every integration (Slack, Telegram, webhook, Discord, Teams, Mattermost, PagerDuty, Opsgenie) has a min. priority field: messages with a priority below the given value are not sent over that adapter. Handy for keeping only important events in the work chat while the noise stays in the app.

Email, SMS and calls have no priority filter — they are controlled only by the toggle and the timeout.

Within a single run, messages are grouped by “owner + destination”:

  • email — a single message arrives as a regular email, several arrive as one digest email with a list;
  • SMS — one text per number: a title plus a short list of channels and subjects;
  • call — one call per number, regardless of how many messages there are.

Integrations (Slack, Telegram, webhook, Discord, Teams, Mattermost, PagerDuty, Opsgenie) are not grouped: one message — one send.

MethodBehaviour on failure
email and integrationsIdempotent: the delivery mark is written after sending, on failure the next run retries
SMS, callThe mark is written before sending (a duplicate is unacceptable); on error it is rolled back and the attempt is repeated

Each message goes out over each method exactly once: the delivery fact is recorded in a log and never fires twice.

Signed links are added to the email and to the Telegram bot message:

  • ✓ Read — marks the message as read and stops escalation over the remaining methods;
  • answer options — if the message contains a closed question (Ask), the buttons match the answer options.

The other integrations get the same actions: buttons in Slack and Teams, links under the text in Discord and Mattermost, incident links in PagerDuty, lines of the description in Opsgenie.

The links are HMAC-signed and valid for a limited time (for questions — until the answer deadline), so they need no authentication in the UI.

Chat integrations receive the message as a card coloured by priority (red — 9–10, orange — 7–8, yellow — 4–6, blue — 1–3) with a title, the text and a Notifly · channel · P<priority> footer.

ServiceWhere to sendWhat actions look like
Discordchannel webhook: https://discord.com/api/webhooks/<id>/<token>markdown links under the text
TeamsURL of the “Post to a channel when a webhook request is received” workflow (Workflows / Power Automate)Adaptive Card Action.OpenUrl buttons
Mattermostincoming webhook: https://<server>/hooks/<key>markdown links under the text
  • Discord. Discord channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL. Only Discord hosts are accepted (discord.com, discordapp.com). @everyone/@here in the message text do not ping (allowed_mentions is empty).
  • Microsoft Teams. In the Teams channel: … → Workflows → the Post to a channel when a webhook request is received template → pick the team and channel → copy the URL. The message arrives as an Adaptive Card (version 1.4). Legacy Office 365 connectors (*.webhook.office.com) are being retired by Microsoft — such URLs are rejected on save with a hint to switch to Workflows.
  • Mattermost. Integrations → Incoming Webhooks → Add Incoming Webhook. The format is Slack-compatible (an attachment). Mass mentions @channel, @all, @here in the text are neutralised.

Incident integrations open an incident/alert rather than just posting to a chat.

Notifly priorityPagerDuty severityOpsgenie priority
9–10criticalP1
7–8errorP2
4–6warningP3
1–3infoP4
0infoP5
  • PagerDuty — Events API v2. In the PagerDuty service: Integrations → Add integration → Events API V2, copy the Integration Key (32 characters). For accounts in the EU service region choose region eu (events.eu.pagerduty.com). dedup_key = notifly-<message id>: re-sending the same message does not open a second incident. Action buttons arrive as the incident’s links, the text as custom_details.message.
  • Opsgenie — Alerts API (POST /v2/alerts, header Authorization: GenieKey …). In the Opsgenie team: Integrations → Add integration → API, copy the API key; region us (api.opsgenie.com) or eu (api.eu.opsgenie.com). alias = notifly-<message id> — Opsgenie deduplicates open alerts by it. Action links go to the description and to details.

The “When the message is read” field (onRead) closes the loop: once the message is read in Notifly — in the app, the admin UI or via the ✓ Read (Read) link right from the incident — the delivery worker sends acknowledge or resolve to PagerDuty, acknowledge or close to Opsgenie (by the same dedup_key/alias) within a minute. By default ("") the incident is left alone.

The reaction runs at most once per message: a failed attempt (after the built-in retries) is not repeated forever. There is no reverse direction: acknowledging in PagerDuty does not mark the message as read in Notifly.

A generic webhook receives a POST with JSON:

{
"title": "CPU high",
"message": "load average 12.4",
"priority": 8,
"channel": "Prod",
"appId": 12345,
"ts": 1782561600000
}

If a secret is set, the body is signed with HMAC-SHA256 and the signature is passed in a header:

X-Notifly-Signature: sha256=<hex>

Verifying it on the receiving side — the SDKs already have it.

Python:

import notifly
raw = request.get_data()
if not notifly.verify_signature(SECRET, raw, request.headers.get("X-Notifly-Signature")):
abort(401)

Go — the webhook subpackage ships a handler that verifies the signature, decodes the body and answers with the right status codes:

import "github.com/truboter/notifly.ru/sdk/go/webhook"
http.Handle("/notifly/alerts", webhook.DeliveryHandler(secret,
func(ctx context.Context, p *webhook.DeliveryPayload) error {
log.Printf("unread in %s: %s", p.ChannelName, p.Title)
return nil // returning an error asks Notifly to retry
},
&webhook.Options{Dedup: webhook.NewDedup(time.Hour)}))

Without an SDK it is seven lines in any language: hmac_sha256(secret, raw_body) in hex, prefixed with sha256=, compared in constant time.

Channel settings → the Delivery tab. Turn on the method you need with the toggle, fill in the address and the timeout. The Test button next to a method saves the settings and immediately sends a test notification there.

Telegram hint: create a bot via @BotFather (/newbot) — it returns a bot token; find the Chat ID (for direct messages ask @userinfobot, for a group it looks like -100…); make sure to press Start on your bot or add it to the group, otherwise Telegram will refuse the delivery.

Delivery settings are part of the channel object — the deliveryConfig field in POST /application and PUT /application/{id}:

Окно терминала
curl -X PUT "$NOTIFLY_URL/application/12345" \
-H "Content-Type: application/json" \
-H "X-Notifly-Key: <client-token>" \
-d '{
"name": "Prod",
"deliveryConfig": {
"email": { "enabled": true, "to": "oncall@example.com", "readTimeoutSec": 300 },
"telegram": { "enabled": true, "botToken": "123:ABC", "chatId": "-1001234567890",
"readTimeoutSec": 900, "minPriority": 7 },
"webhook": { "enabled": true, "url": "https://example.com/hook",
"secret": "s3cret", "readTimeoutSec": 0, "minPriority": 5 },
"pagerduty": { "enabled": true, "routingKey": "R0123456789abcdef0123456789abcde",
"region": "eu", "onRead": "resolve",
"readTimeoutSec": 600, "minPriority": 8 }
}
}'

Adapter fields:

FieldUsed byMeaning
enabledallwhether the method is on
toemail, sms, callrecipient address or number (+79001234567)
urlslack, webhook, discord, teams, mattermostwebhook URL / arbitrary URL
botToken, chatIdtelegrambot parameters
secretwebhookHMAC-SHA256 signing key (optional)
readTimeoutSecallescalation timeout, 0…82800
routingKeypagerdutyIntegration Key (Events API v2)
apiKeyopsgenieAPI integration key
regionpagerduty, opsgenieus (default) or eu
onReadpagerduty, opsgenie"", acknowledge or resolve
minPriorityall integrationsdo not send messages below this priority
Окно терминала
curl -X POST "$NOTIFLY_URL/application/12345/delivery/test" \
-H "Content-Type: application/json" \
-H "X-Notifly-Key: <client-token>" \
-d '{"adapter": "telegram"}'

Without a body, all enabled methods of the channel are checked. The response carries a result per method:

{"results": {"telegram": {"ok": false, "error": "telegram: 403 bot was blocked by the user"}}}

If no method is enabled (or the requested adapter is off), the response is 400 no enabled adapter to test. The check bypasses escalation: no need to wait for a timeout, the notification goes out immediately.

Escalation does not consume the daily quota: the event was already charged when the message was created (message). No matter how many delivery methods are enabled, the event counter does not grow — see Quotas & pricing.

  • Slack, webhook, Discord, Teams and Mattermost URLs are checked by the SSRF guard: private, loopback and link-local addresses are blocked at send time.
  • Secrets — the Slack, Discord, Teams and Mattermost webhook URLs (they embed a token), botToken, secret, routingKey, apiKey — are replaced with the ******** mask in API responses; sending the mask back means “keep”. They live in the channel settings and are available to the channel owner only: someone with delegated access does not get the “Delivery” tab.
  • Managing delivery requires the channel owner’s client token; the channel app token is not enough.

Two mechanisms cancel escalation — for good, without a morning catch-up:

  • Quiet hours of the channel owner: while the window is active, methods for messages below the urgency threshold are not sent; they are collected in a single digest instead.
  • Maintenance windows: messages in covered channels are not escalated at all, regardless of priority.

A suppressed method is logged as “suppressed” and never retried.

Check in this order:

  1. Is the message still unread? Reading it — in the dashboard, in the app or with the Mark read button in the notification — cancels escalation.
  2. Has the method’s timeout passed (see Timeout). Messages created before the method was enabled are not forwarded.
  3. Is the priority not below the minimum (minPriority) — for the integrations where it is set (see Minimum priority).
  4. Are quiet hours or a maintenance window in effect — they suppress escalation for good.
  5. Does the method itself work — run a delivery test: it sends a notification right away and returns the adapter’s error if something is wrong. SMS and phone calls fire only if the integration is enabled on the server side.

If the push notification itself does not reach your device, see Notifications not arriving.