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.
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.
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
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
supportdelivery 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/supporteither 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.
Setting preferences
The samebot_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.
/account/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
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 markeddeferred
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.
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 toconsent_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.
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 acustomer_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.
The frequency cap
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:
- Telegram delivery state is
blocked→ refuse, reasonbot_blocked;deactivated→ refuse, reasonuser_deactivated;not_started→ refuse, reasonnot_started. - The per-kind preference is off → refuse, reason
kind_disabled. - The message is marketing and there is no consent → refuse, reason
no_marketing_consent. - The message is marketing and the cap is spent → refuse, reason
frequency_cap. - A Telegram flood wait is still in force → defer to that instant.
- The kind is not urgent and the moment falls inside quiet hours → defer to the end of the window.
- Otherwise, send.
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 ispublished, 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.
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.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
localStorageunderomniflow.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
Notificationconstructor 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:
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 needssupport.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
202and 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.
customer.notification.tested.
Reference
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:
Related
Customer web panel
The rest of what a customer can do from a browser.
Telegram bot
/settings, the news menu, and every other command.Personal data
Contact channels, the consent trail, export, and deletion.
Message wording
The operator side: rewording these notices, with a preview and a test send.
Campaigns
The operator side: segments, templates, sends, and suppression.