Skip to main content
Everything Omniflow sends a customer passes through two mechanisms described here: the news inbox, which is where announcements live durably, and the delivery policy, which is the single place that decides whether a customer may be messaged at all. Both exist for the same reason. A customer needs to be told their subscription ends on Thursday whether or not they want marketing, and an operator needs a record of every message that was not sent and why. Folding those into one “notifications” switch would make one of the two impossible. This page is the operator and system view. For what a customer sees and controls, see Notifications and consent.

News and announcements

Categories, and what each one costs

A post carries a category and a class. Four categories exist, and the category is what the delivery machinery treats as the notification kind: All four are additionally governed by the customer’s single news preference switch, so a customer who turns news off stops receiving announcements and incident notices too.
news_posts.class is a separate column carried through to the customer inbox payload, where the panel uses it to tone a badge. It has no effect on delivery — the category decides that.

The lifecycle, and what can actually move a post through it

news_posts.status is one of draft, scheduled, published, unpublished, or archived, with scheduled_for, published_at, unpublished_at, and archived_at beside it. A publication timestamp alone cannot express “written but not ready”, “scheduled for Tuesday”, or “taken down because it was wrong” — and the last is the one an operator needs most urgently, which is why unpublishing exists as a state rather than as a deletion. published_at is set with COALESCE(published_at, now()), so republishing does not rewrite when customers first saw a post. A CHECK requires scheduled_for whenever the status is scheduled.
There is no operator surface for news. The only state transition any code makes is the worker’s scheduled → published: the campaign_expansion pass runs every minute, takes up to 50 posts whose scheduled_for has passed, and publishes them. Creating a post, writing its Russian and English localisations, scheduling it, unpublishing it, or archiving it can only be done by writing news_posts and news_post_localizations directly.Slugs match ^[a-z0-9][a-z0-9_-]{2,63}$, titles are capped at 200 characters, bodies at 3500, and locales are ru or en only.

What makes a post visible

A post is visible to a customer when all of the following hold:
The status check matters on its own. Unpublishing keeps published_at set on purpose — that is when customers first saw it — so a timestamp window alone would keep showing a post an operator had taken down. Both the web panel and the bot apply the identical predicate, so withdrawing a post removes it from both surfaces at once.

Locale resolution

A post exists per locale, and a post with no translation in the customer’s resolved locale is absent rather than shown in the other language. Resolution runs in order: an explicit locale request parameter when it names ru or en, then the customer’s bot preference unless it is auto, then the account’s locale, then en. The resolved locale is returned in the response, so the panel can say it was not given the language it asked for rather than pretending.

The inbox

Items are ordered newest first on (published_at, id) on both surfaces, so a batch published in the same instant reads in the same order everywhere. unreadCount counts every visible post with a localisation in the resolved locale that has no read row, and is not bounded by the page. Marking a post read checks visibility first and answers 404 for an invisible one; the insert is ON CONFLICT DO NOTHING, so reading twice is not an error. Customers reach it at /account/news in the web panel, or the News menu entry in the bot, each carrying an unread badge.

The push

The inbox is pull. The push is what makes an outage notice arrive. The bot’s notifier looks for posts published in the last seven days, for active customers with a Telegram recipient, that have a localisation in the customer’s resolved locale and no delivery row for that post that is still waiting or already final. It sends one message per customer per post, 200 at a time, and reads the next batch as soon as the last is sent, at the bot-wide APP_TELEGRAM_DELIVERY_RATE. A customer whose chat is known to be unreachable — blocked, deactivated, or not_started — is not read at all, and a customer who stops being reachable between the read and the send gets a suppressed delivery row with the reason unreachable. Either way the queue moves past them; before, they were skipped without a record, stayed at the head of every batch for the whole seven days, and two hundred of them stopped announcements for everybody. A delivery deferred to the end of quiet hours, or failed with retries left, is read again once it can move, so a deferral is a delay rather than a drop. A post that is already older than seven days when the worker first sees it is never announced. Publishing an old post backdated will populate the inbox and send nothing.

How every notification is classified

Every customer notification passes through one decision function. An unclassified kind is refused rather than delivered, so adding a notification without deciding its class is impossible. Urgent kinds are never deferred by quiet hours: payment, fulfillment, support, and incident. Everything else waits for the window to end.

Which preference governs which kind

The four kinds with no opt-out carry facts a customer needs whether or not they want to hear from the business. A preference model that could silence a payment failure would be a way to lose access without warning.

The decision order

  1. The customer blocked the bot — refuse, bot_blocked. Their account is deactivated — refuse, user_deactivated. They never started the bot — refuse, not_started. None is retried, and all three end when the customer next writes to or taps in the bot.
  2. The kind’s preference is off — refuse, kind_disabled.
  3. Marketing class with no consent — refuse, no_marketing_consent.
  4. Marketing class already over the frequency cap — refuse, frequency_cap.
  5. A retry-after in the future — defer until then.
  6. Not urgent and inside quiet hours — defer to the end of the window.
  7. Otherwise, allow.

Quiet hours

Quiet hours are a whole-hour local-time window evaluated against users.timezone. The window wraps past midnight when the end hour is at or before the start hour, and start == end means there is no window at all — which is how turning quiet hours off is expressed on both surfaces.
The timezone the window is evaluated in is the account’s, and the customer preferences screen neither shows nor changes it. A customer setting 23:00–08:00 in the browser is setting it in whatever timezone their account already carries.

The frequency cap

Both are read by the bot process. The count is the number of delivery rows with class marketing and status sent inside the window, so a suppressed or deferred marketing message does not spend a customer’s allowance. The bot’s /settings screen shows the cap to the customer when one is configured.

The durable record

notification_deliveries is the record of every decision, unique on (user_id, kind, subscription_id, dedupe_key) with NULLS NOT DISTINCT. A row is re-claimed only when it is failed with fewer than three failures, or deferred with an elapsed deferral. That is what turns a deferral into a delay rather than a drop. The key includes the subscription, so lifecycle alerts are per subscription and a busy subscription cannot suppress a quiet one’s expiry notice. Telegram failures are classified into a durable delivery state on the account: bot_blocked becomes blocked with no retry, user_deactivated and chat_not_found become deactivated with no retry, “bot can’t initiate conversation” becomes not_started with no retry, and flood waits, timeouts, and Telegram outages become failing, which is retryable. Any update from the customer’s private chat, and Telegram’s my_chat_member report of a restart, clears the three final states; a my_chat_member block records blocked.

Three deliberate exceptions

  • Dunning notices bypass the policy entirely. 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 either. Each notice still leaves a payment delivery row keyed dunning:<attempt>, which bounds it: a failed send is retried on the next two passes and then stops, a refusal Telegram calls final stops at once, a customer whose chat is known to be unreachable is not tried, and a notice older than seven days is not sent.
  • Support replies bypass it too. The reply delivery pass sends directly and writes no delivery row; deduplication comes from the message’s own delivered_at instead. A support reply has no suppression record and no retry accounting as a result. It would be classified urgent in any case.
  • A customer with no Telegram identity receives no push at all. Recipients are drawn from active Telegram identities and Remnawave Telegram ids. A customer who signed in only by magic link or OIDC sees replies and announcements in the web panel and nothing is pushed anywhere.
Every marketing decision appends a consent_records row carrying the purpose, whether it was granted, the policy version, the source surface, and the request id. The current answer is the newest row. A history that can be rewritten is not evidence of anything. A preference update appends only when the value actually changes relative to the latest record. An unsubscribe appends unconditionally, because “they had already opted out” is not a reason for their unsubscribe to leave no trace. The marketing_enabled column on the preferences row is a fallback for installations that predate the consent history. Where the two disagree, the record wins. This is also what makes the customer-facing evidence line possible: “you opted in” is not a useful thing to tell somebody who does not remember doing so, and “you opted in on this date, from this surface, under this policy version” is.

What unsubscribing does

One button on /account/preferences turns off marketing and the news preference, clears the marketing flag on every live contact channel, appends a consent withdrawal, and writes a communication_suppressions 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, and an unsubscribe that silenced them would turn a marketing preference into a way to lose access without warning. Turning marketing back on later deletes only the customer_request suppression — see the suppression list for why an operator-recorded suppression survives that.

Support attachments and retention

A support attachment is stored one of two ways, and the difference matters operationally. A Telegram attachment stores only Telegram’s file identifier. Telegram is the custodian and Omniflow holds no bytes. A web upload has no such custodian, so the bytes are written to disk under a content-addressed key. Which of the two a row is, is a column: origin is telegram or local, and the reference sits in telegram_file_id or in storage_key accordingly. The table enforces that pairing, so a row cannot claim one origin and carry the other’s reference. Anything sending an attachment back to Telegram skips a local row — a content digest means nothing to Telegram — and the download route refuses a telegram row, because it does not hold those bytes.
The API and the worker must point at the same directory. The API writes the bytes and the worker’s sweep is what removes them. If they disagree, retention deletes the rows while the files stay, and the disk grows without bound.
Attachment retention itself is the column default of 90 days and is not configurable. The hourly sweep deletes every expired row, then removes only those web files that no surviving row still references — two customers who uploaded identical bytes share one content-addressed file, so deleting per row would delete somebody else’s attachment. Files are removed only after the transaction commits, and a file that cannot be removed is logged and skipped rather than failing the whole sweep.
The worker’s sweep is the only thing that deletes an expired attachment. The bot used to purge rows at the end of every notification pass as well, and that path reclaimed no files — so whenever it ran first, the row was gone, the sweep found nothing to reclaim, and the bytes stayed on disk for good. It has been removed: one deleter, and it is the one that also frees the disk.

Audience segments and campaigns

Sending a message to a chosen audience, and the suppression list.

Notifications and consent

The same machinery from the customer’s side.

Support desk

Where attachments arrive and how replies are delivered.

Configuration

Every environment variable named on this page.