> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications and announcements

> How Omniflow decides whether a customer may be messaged, what a customer can turn off, and where announcements are kept

Every message Omniflow sends a customer passes through one decision function.
It reads the notification's kind, the customer's per-kind preferences, their
marketing consent, their quiet window, the marketing frequency cap, and the
health of their Telegram delivery, and it answers with one of three outcomes:
send now, wait until a stated instant, or do not send and record why.

That function is `commerce.EvaluateDelivery`, and it refuses a kind it does not
recognise rather than defaulting to "send". Adding a notification without
deciding whether it is transactional or marketing is therefore impossible: the
new kind would simply never be delivered, which is a visible failure rather than
a silent disclosure. Delivery runs in the bot process as three loops. The
lifecycle pass — dunning notices and every customer's lifecycle alerts — runs
every 15 minutes, and walks **every** reachable customer in identifier order,
200 at a time, until the last page comes back short, so the customer whose
identifier sorts last receives their expiry warning in the same pass as the
first. Campaign recipients and announcements have a loop of their own: it reads
200 of each, sends them, and reads the next batch straight away while there is
a backlog, looking again every 30 seconds once there is none. Support replies
have their own 30-second loop.

Every message the bot sends draws from one bot-wide budget,
`APP_TELEGRAM_DELIVERY_RATE` messages a second (25 by default; Telegram's
ceiling for one bot is about 30). A second's worth may go at once, then the rate
holds. When Telegram answers 429 anyway, the `retry_after` it gives pauses
**every** send, because the limit is the bot's and not the recipient's: a pause
of up to 30 seconds is waited out, a longer one ends the current pass, and
messages caught by it are deferred to the instant it ends rather than marked
failed. The 429 is no longer recorded against the customer who happened to
receive it.

## The two classes

A notification is either **transactional** — a fact about something the customer
bought or asked for — or **marketing**, which requires an explicit, revocable
opt-in. The classification is a closed table in the code, not a per-message
choice made by whoever wrote the message.

| Class | Kinds |
| - | - |
| Transactional | `expiry`, `traffic`, `renewal`, `grace`, `recovery`, `payment`, `fulfillment`, `support`, `incident`, `maintenance`, `announcement`, `referral`, `trial` |
| Marketing | `news`, `marketing` |

Four kinds are additionally **urgent** and are never held back by quiet hours:
`payment`, `fulfillment`, `support`, and `incident`. A customer sitting in the
middle of a payment, or affected by an outage right now, is not served by a
message that arrives at eight the next morning.

> [!NOTE]
>
> `news` is a marketing kind. A news post published in the `news` category
> therefore needs marketing consent and consumes the frequency cap, while
> `announcement`, `incident`, and `maintenance` posts do not. This matters when
> an operator writes an outage notice: publish it as an `incident` or an
> `announcement`, not as `news`, or only the customers who opted into offers
> will see it pushed.

## What a customer actually receives

| Message | Kind | When |
| - | - | - |
| Expiry reminder | `expiry` | 7, 3, 1, and 0 days before the subscription ends — only for a subscription with no entitlement behind it (imported before commerce) |
| Traffic warning | `traffic` | at 80% and at 100% of the traffic allowance |
| Renewal reminder | `renewal` | 7, 3, 1, and 0 whole days before the entitlement ends — for every subscription Omniflow sold |
| Grace notice | `grace` | while the subscription is past its end date but inside its grace period |
| Recovery offer | `recovery` | while the expired subscription is still inside the recovery window |
| Provisioning result | `fulfillment` | once per entitlement when provisioning succeeded, and once when it failed |
| Support reply | `support` | when an operator answers a ticket |
| Failed automatic charge | dunning | when a recurring charge is declined |
| Announcement push | the post's category | once per customer per newly published post |
| Campaign | `marketing` | when an operator runs a campaign |

A subscription gets exactly one end-of-term countdown. One that Omniflow sold
has an entitlement, and the `renewal` reminder is computed from the
entitlement's end and can offer renewal; a legacy subscription imported before
commerce has only Remnawave's expiry to go on and gets the `expiry` reminder
instead. They are never both sent: the two count the same days two ways and
used to arrive as two messages about one event, up to eight over the last
week.

Alerts are keyed per subscription, so a busy subscription cannot suppress a
quiet one's expiry notice. Each one carries its own dedupe key — the expiry
notice is keyed on the end date and the day count, the traffic warning on the
threshold and the allowance — which is what stops a reminder repeating on every
pass for a week.

Two paths deliberately bypass the policy machinery:

* **Support replies** are pushed by their own 30-second loop without consulting
  consent, quiet hours, or the frequency cap — a reply is something the customer
  asked for. They do leave a `support` delivery row, so the history below shows
  each one as delivered, waiting, or held back with the reason (`bot_blocked`,
  `no_telegram`); the message itself stays readable on `/account/support` either
  way.
* **Dunning notices** — a failed automatic charge — are sent regardless of
  preferences and quiet hours. A customer about to lose access because a card
  was declined is not served by holding the message until morning, and there is
  no opt-out for it in the preference model.

> [!WARNING]
>
> A customer with no Telegram identity — one who signed in only with a
> bot-delivered link or an OIDC provider — is never in the recipient set for
> any of this. They see operator replies and announcements in the web panel when
> they open it, and nothing is pushed to them anywhere. There is no email or SMS
> sender in Omniflow.

## Setting preferences

The same `bot_preferences` row backs both surfaces, so a choice made in one
applies to the other immediately. Both write to the same four switches, the same
language field, and the same quiet window.

### In the browser

Open `/account/preferences`, reached from **Account** → **Notifications** on the
hub at `/account/profile`.

**1. Choose what we send**

Four switches, each on by default: **Subscription expiry**, **Traffic
warnings**, **Renewals and payments**, and **Announcements**. Flipping one
sends a `PATCH` carrying that field alone.

**2. Choose the language of messages**

**Follow my account**, **Russian**, or **English**. This is the language the
bot writes to you in, not the language of the panel — the panel's own
language is set on `/account/profile`.

**3. Set quiet hours**

The master switch turns on a default 23:00–08:00 window; two selects then
move the start and end hour. Turning the switch off sends
`{"startHour": 0, "endHour": 0}` rather than omitting the field, because
omitting it means "leave the window alone", which is the opposite of what
was asked for.

**4. Decide about offers**

The **Offers and promotions** switch is marketing consent. Under it, the
date, the surface, and the terms version of the decision that is currently
in force.

The screen also shows any active suppression (read-only) and one row per contact
channel with its verified, service-message, and promotions flags. **The
addresses themselves are never returned by this route** — the API does not
select the ciphertext at all, which is a stronger guarantee than remembering to
drop it before writing the response. Addresses are managed on
[`/account/privacy`](https://omniflow-vpn.mintlify.site/customer/privacy).

Every field of the update is a pointer on the server, which is what makes it a
genuine `PATCH`: an absent field is left alone, so a screen that renders one
switch cannot silently rewrite the others it never showed. The response is the
full re-read document, so the panel replaces its cache outright instead of
revalidating.

### In Telegram

Send `/settings`. The screen carries a language row, the same four toggles, a
marketing toggle, and a **quiet hours** entry that opens a submenu of three
presets: 22:00–08:00, 23:00–09:00, and off. The presets are an explicit list
rather than a free-text time field, so there is no time parser to get wrong and
the callback data stays closed.

When a marketing frequency cap is configured, the Telegram settings screen states
it. The web screen does not.

### Which switch governs which message

| Preference | Kinds it governs |
| - | - |
| Subscription expiry | `expiry` |
| Traffic warnings | `traffic` |
| Renewals and payments | `renewal`, `grace`, `recovery`, `trial` |
| Announcements | `news`, `announcement`, `incident`, `maintenance` |
| Offers and promotions | `marketing` |
| No opt-out | `payment`, `fulfillment`, `support`, `referral` |

The last row is the point of the whole model. Those kinds are consequences of
the customer's own actions, and a preference model that let them be switched off
would let somebody lose access quietly.

A customer who has never opened a settings screen has no preferences row at all;
the read falls back to the column defaults — all four switches on, marketing off,
language `auto`.

## Quiet hours

A quiet window is a local-time window during which non-urgent messages wait
rather than being dropped. A window whose end is not after its start wraps past
midnight, so 23:00–08:00 is one window and not two. Equal hours means no window,
which is the convention both surfaces use to clear it.

When a message lands inside the window, the delivery row is marked `deferred`
with the instant the window ends. The next pass re-claims a deferred row whose
instant has passed, which is what turns a deferral into a delay instead of a
drop.

> [!NOTE]
>
> The window is evaluated in `users.timezone`, which defaults to `UTC`. Both
> surfaces set it: the web preferences screen offers every IANA zone the browser
> knows, and the bot's `🌍 Time zone` button — on the settings screen and on the
> quiet-hours screen — offers a list of common zones with their current offset.
> The bot names the zone beside the window wherever it shows one, and a customer
> still on the default is told that their quiet hours are counted in UTC. When
> their Telegram client's language suggests a zone — Russian suggests Moscow,
> Ukrainian Kyiv, Kazakh Almaty, and so on — the picker asks about it first, as a
> question with a `✅ Yes` button; the bot never sets a zone on its own.

## Marketing consent, and what an unsubscribe does

Marketing consent is not a column somebody keeps up to date. Every decision
appends a row to `consent_records` with the purpose, the granted flag, the terms
version, the surface, and the request id that carried it, and the current answer
is the newest row. A history that can be rewritten is not evidence of anything —
and "you opted in" is not a useful thing to tell somebody who does not remember
doing so, where "you opted in on this date, from this surface, under these terms"
is.

| Surface | `source` | `policy_version` |
| - | - | - |
| `/account/preferences` and its unsubscribe | `customer_web` | `web-v0.10` |
| `/account/privacy` contact channels | `account_web` | `account-web-v0.10` |
| Telegram `/settings` | `telegram_bot` | `bot-v0.4` |

A preference change appends a row only when the value actually changed. An
unsubscribe appends one unconditionally, because "they had already opted out" is
not a reason for their unsubscribe to leave no trace.

### Unsubscribe

`/account/preferences` → **Unsubscribe from everything promotional** → confirm.
The route takes no body at all: an unsubscribe control that needs a form filled
in correctly is one that can fail for somebody who has already decided.

In one transaction it sets marketing consent off, sets the announcements switch
off, clears the promotions flag on every live contact channel, appends a consent
withdrawal, and writes a suppression row with reason `customer_request`.

It deliberately does not touch expiry, traffic, or renewal. Those carry facts a
customer needs whether or not they want to hear from the business — that their
subscription ends on Thursday, that a renewal failed — and an unsubscribe that
silenced them would turn a marketing preference into a way to lose access
without warning.

### Suppression

A suppression is not the consent flag. Consent is a preference the customer
toggles; a suppression survives them toggling it back on by accident, and it can
also be recorded by an operator for a bounce or a complaint. The preferences
screen reports an active suppression with its reason and the date it took effect
and offers no control to lift it — a hold placed because messages kept failing
to arrive is a finding this screen has no standing to reverse. Turning marketing
back on deletes only a `customer_request` suppression, and an unsubscribe
never overwrites an operator's: when a `bounced`, `complaint`, or `operator`
row already exists, the customer's unsubscribe leaves it as it is, so the two
clicks — unsubscribe, then opt back in — cannot turn a bounce finding into a
lifted hold.

The delivery pass reads the list. A marketing-class message — a campaign, or a
`news` post — to a suppressed customer is held back with reason `suppressed`
whatever the consent flag says, and the history shows it under that reason
rather than as `no_consent`. Transactional messages are untouched: a suppressed
customer still learns that their subscription ends on Thursday.

Operators see the list under `/admin/marketing` → **Suppressions** and can lift
an entry there; adding one is an API call rather than a screen. See
[campaigns](https://omniflow-vpn.mintlify.site/commerce/campaigns.md).

### The frequency cap

| Variable | Default | Purpose |
| - | - | - |
| `APP_MARKETING_FREQUENCY_CAP` | `3` | Marketing messages allowed per window. `0` disables the cap. |
| `APP_MARKETING_WINDOW_HOURS` | `168` | Window length in hours; the default is seven days. |

Both are read by the bot process. The count is taken over delivery rows with
class `marketing` and status `sent` inside the window, so a suppressed or
deferred message does not spend an allowance. Because `news`-category posts are
marketing, an announcement published in that category competes for the same
three slots a campaign does.

## The decision, in order

`EvaluateDelivery` runs these checks in this sequence and stops at the first one
that answers:

1. Telegram delivery state is `blocked` → refuse, reason `bot_blocked`;
   `deactivated` → refuse, reason `user_deactivated`; `not_started` → refuse,
   reason `not_started`.
2. The per-kind preference is off → refuse, reason `kind_disabled`.
3. The message is marketing and there is no consent → refuse, reason
   `no_marketing_consent`.
4. The message is marketing and the cap is spent → refuse, reason
   `frequency_cap`.
5. A Telegram flood wait is still in force → defer to that instant.
6. The kind is not urgent and the moment falls inside quiet hours → defer to the
   end of the window.
7. Otherwise, send.

The bot then applies one veto the function itself does not know about: a
marketing-class message that came out of the sequence as "send" or "defer" is
refused with reason `suppressed` when `communication_suppressions` names the
customer. It sits outside the function because a suppression is a record about
the customer rather than a preference of theirs, and the surfaces that share
the function have no list to consult.

Every attempt leaves a row in `notification_deliveries`, unique on
`(user_id, kind, subscription_id, dedupe_key)` with `NULLS NOT DISTINCT`, whose
status is one of `pending`, `deferred`, `sent`, `failed`, or `suppressed`, and
which carries the class and the refusal reason. A `failed` row is retried while
its failure count is below three; a `deferred` row is retried once its instant
has passed; a `suppressed` row is final.

Telegram's own failures are classified into a durable state so a blocked or
deleted account stops consuming retries: `bot_blocked` marks the recipient
`blocked` and `user_deactivated` or `chat_not_found` marks them `deactivated`,
neither of which is retried, while a flood wait, a timeout, or a Telegram outage
marks them `failing`, which is. Telegram's "bot can't initiate conversation with
a user" — somebody who never pressed Start, such as a customer who signed in on
the web with the Telegram widget — marks them `not_started`, which is final in
the same way but is not a block.

`blocked`, `deactivated`, and `not_started` keep a customer out of every
notification pass, so a successful send can never be what clears them. The bot
clears them instead the moment it hears from the account: any message, button
tap, or payment from the customer's private chat sets the state back to
`active`. Telegram's own report that the bot was blocked or restarted in that
chat (`my_chat_member`) is followed too — a block is recorded as `blocked`
before any send has failed against it, and a restart clears it.

## The announcement inbox

A push can be missed. The inbox is the durable place a customer can go back to,
and it is the same record on both surfaces: reading a post in Telegram stops it
being unread on the web.

**In the browser** — `/account/news`, reached from `/account/support` → News.
Posts are listed newest first, 20 at a time behind a **Show older announcements**
control, with an unread count in the header. Each post carries a category badge —
`announcement` in the informational tone, `incident` in the danger tone,
`maintenance` in the warning tone, `news` neutral — and a post whose class is
`marketing` gets a second **Promotion** badge, because a customer who consented
to offers is entitled to see which of these is a service notice and which is a
sales message without inferring it from the wording. Bodies render as plain text;
nothing in a post is interpreted as markup.

Reading is an explicit act: press **Mark as read**. The panel does not infer it
from a scroll position, because the shared read state is a claim worth making
only when the customer actually said so.

**In Telegram** — the **News** menu entry, carrying an unread badge, then a list
and a post view.

### Which posts appear

A post is visible when its status is `published`, its publication instant is set
and in the past, and it has not expired. The status check is load-bearing:
unpublishing keeps the publication timestamp set on purpose, so that republishing
does not rewrite when customers first saw the post — and a timestamp window alone
would therefore keep showing a post an operator has taken down.

The language is resolved server-side: an explicit request wins, then the bot
preference (unless it is `auto`), then the account language, then English. **A
post with no translation in the resolved language is absent rather than shown in
another language**, because an inbox that switches language mid-list is harder to
read than a shorter one. The resolved language comes back with the response, and
the panel shows a notice when it differs from the one the page is rendered in.

The web panel deliberately sends no language of its own, so that the same
customer does not read the same announcement in two languages depending on which
surface they opened.

> [!NOTE]
>
> There is no operator screen for writing announcements. The only state
> transition any code performs is the worker's `scheduled` → `published` pass,
> which runs about once a minute and promotes up to 50 posts whose scheduled
> instant has arrived. Creating a post, writing its translations, unpublishing
> it, or archiving it is done by writing `news_posts` and
> `news_post_localizations` directly.

The push that accompanies a post is sent once per customer per post, to
customers with a Telegram recipient and a translation in their resolved language,
and only for posts published within the last seven days — a post the delivery
pass first sees when it is older than that is never announced.

## Browser notifications

`/account/preferences` → **Browser notifications** → **Turn on browser
notifications**. The browser's permission prompt follows that click and never a
page load, because a browser gives a site one ungrudging chance to ask and a
reflexive "Block" is permanent and cannot be undone from the page.

This is the `Notification` API driven by the polls the panel already runs — it is
explicitly **not** web push. There is no service worker, no VAPID key, and no
subscription sent anywhere, because Omniflow has no server-side push to deliver
one, and a subscription the server never uses is a permission prompt spent on
nothing. When the tab is closed, Telegram is what reaches the customer.

What that means in practice:

* The opt-in lives in `localStorage` under
  `omniflow.account.browser-notifications`, so it is a setting of one browser
  rather than of the account, and it disappears with site data. An account-level
  flag would claim a setting one device cannot honour.
* Both halves must agree. A permission revoked in the browser's own settings
  clears the stored opt-in on the next read.
* Nothing is fetched at all until the customer has opted in; afterwards the
  ticket list and the news list are polled every 60 seconds.
* Nothing is raised while the tab is visible and focused. The feature exists for
  the tab left in the background.
* The first poll after opting in establishes a baseline only, so opening the
  panel with three unread replies fires nothing.
* Repeats collapse per ticket and per post through the notification tag.
* A denied permission is never re-requested; the card says to change it in the
  browser's settings.
* Chrome on Android throws from the `Notification` constructor because
  notifications there require a service worker. That is caught and reported as
  unsupported, with an explanation on the card, rather than becoming a silent
  no-op.
* Reply detection compares the **server's** unread count, which is computed from
  the shared message record — so a reply already read in Telegram arrives here
  with nothing to announce.

## What was actually sent

Everything above this point is a setting. A setting says what *should* arrive,
which leaves "I never got the expiry warning" as a claim nobody in the
conversation can check — the customer has only an absence to go on, and an
absence looks identical whether the bot is broken, a quiet window held the
message back, or nothing was ever due.

The record existed from the first release. `notification_deliveries` has always
carried the kind, the class, whether the message left, when, how many attempts
it took, and — when it never left — a code saying why. Nothing read it. Both
sides read it now.

### The customer's own history

Beneath the preference controls, on the same screen, newest first. There are no
message bodies: the record says that a notice of a kind happened, not what it
said, and rendering a template against today's data would show somebody
something that was never sent to them.

What each outcome means:

| Outcome | What happened |
| - | - |
| Delivered | It reached Telegram and Telegram accepted it |
| Held back | It was never sent, on purpose, for the stated reason |
| Waiting | It is queued and has not gone out yet |
| Waiting for quiet hours | It is deferred until the window the customer set ends |
| Could not be delivered | Telegram refused or was unreachable |

The reasons on a held-back message point back at a control on the same page —
`quiet_hours` at the quiet window, `no_consent` at a switch, `frequency_cap` at
the marketing setting. That is the point of putting the two together: it turns
"nothing arrived" into something the customer can change without opening a
ticket.

### The operator's view

`/admin/customers/{id}`, under **Notifications**. Reading it needs
`customers.read`: a delivery history is part of a customer record, and the
person answering "did you send it" is already looking at the account.

The same rows, with a summary per kind above them and a filter by outcome. The
useful filter is **Held back**, because it is the one that turns a support
conversation around. `suppressed` is shown in a neutral colour rather than as a
warning — it is the installation doing what it was told, and colouring it as a
fault sends operators looking for a problem that is a working feature.

## Sending a test

An operator can send one message to one customer to establish that delivery
works right now. It needs `support.write`: nothing about the customer changes, a
message goes out, and the people who need this are the ones handling the
conversation it came from.

It travels the ordinary outbox, and that is the whole value of it. The panel
process holds no Telegram connection; the notifier does. A test that took its
own path would prove that the test path works, which nobody asked. This one is
claimed by the same worker, sent by the same call, classified by the same
failure codes, and recorded in the same table — so when real notifications are
failing, the test fails identically and names the reason.

Three consequences worth stating:

* **It is queued, not sent.** The API answers `202` and the panel says so. The
  message goes out on the notifier's next pass, and the outcome appears in the
  history below the button. Reporting a send that has not happened yet as though
  it had is precisely the failure this feature exists to expose.
* **Quiet hours, the frequency cap, and the per-kind switches do not apply.** A
  test deferred until nine in the morning answers nothing at the moment somebody
  is asking. Delivery health still applies, because a customer who blocked the
  bot cannot be reached by wanting it more — and that is the finding the
  operator needs.
* **It carries a kind of its own.** A test can never be read back as a real
  notice, and it can never spend a marketing frequency budget.

Deduplicated by operator and by minute: an impatient double-click is one
message, two operators testing the same customer are two, because those are two
questions. The panel says which happened.

The customer sees a message that states plainly that an operator sent it as a
test and that nothing is required of them, with the timestamp. An unexplained
message from a VPN provider is worse than no message.

Every test send is in the audit trail as `customer.notification.tested`.

## Reference

| Route | Method | Notes |
| - | - | - |
| `/v1/account/preferences` | `GET` | The full document: language, four switches, quiet window, marketing with its evidence, contacts, suppression |
| `/v1/account/preferences` | `PATCH` | `{locale?, notifications{expiry?,traffic?,renewal?,news?}, marketing?, quietHours?{startHour,endHour}}` — every field optional |
| `/v1/account/preferences/unsubscribe` | `POST` | No body; returns the refreshed document |
| `/v1/account/news` | `GET` | `?locale&cursor&limit` (default 20, max 50) → `{items, unreadCount, locale, nextCursor?}` |
| `/v1/account/news/{postID}/read` | `POST` | 204; marking twice is not an error, an invisible post is 404 |
| `/v1/account/notifications` | `GET` | `?limit` (default 30, max 100) → `{items}` — what was actually sent to the signed-in customer, newest first |

Refusals are RFC 9457 problem documents. `locale` must normalise to `auto`,
`ru`, or `en` — the value is lower-cased first, so refusing `EN` would be a rule
about capitalisation rather than about language. Quiet hours must be whole hours
between 0 and 23. A customer record that does not exist answers 404.

Operator routes, all under `/v1/panel`:

| Route | Method | Notes |
| - | - | - |
| `/customers/{id}/notifications` | `GET` | `customers.read`; `?kind&status&offset&limit` (max 50) → `{deliveries, total}` |
| `/customers/{id}/notifications/summary` | `GET` | `customers.read`; totals per kind |
| `/customers/{id}/notifications/test` | `POST` | `support.write`; answers `202` with `{status, queued}`. `queued: false` means an identical test was already waiting |

## Related

> **[Customer web panel](https://omniflow-vpn.mintlify.site/customer/web-panel.md)**
>
> The rest of what a customer can do from a browser.

> **[Telegram bot](https://omniflow-vpn.mintlify.site/customer/telegram-bot.md)**
>
> `/settings`, the news menu, and every other command.

> **[Personal data](https://omniflow-vpn.mintlify.site/customer/privacy.md)**
>
> Contact channels, the consent trail, export, and deletion.

> **[Message wording](https://omniflow-vpn.mintlify.site/operations/message-wording.md)**
>
> The operator side: rewording these notices, with a preview and a test send.

> **[Campaigns](https://omniflow-vpn.mintlify.site/commerce/campaigns.md)**
>
> The operator side: segments, templates, sends, and suppression.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.