Skip to main content
The customer web panel is the browser half of the product the Telegram bot has carried since v0.2. It is not a second product: it reads the same customer records, the same entitlements, and the same Remnawave state, and a customer who signs in with Telegram on the web lands on exactly the account the bot has been using. It is served by the same Next.js application as the operator panel, under /account, and backed by a third API prefix, /v1/account. The three prefixes never share middleware — /v1/panel takes an operator session, /v1/admin takes a bearer token, /v1/account takes a customer session — because a customer session must never be able to reach an operator route, and the cheapest way to guarantee that is for the two never to share a router.

What it covers

Sign-in

Telegram, an optional bot-delivered link, and any number of OIDC providers.

Dashboard

Status, expiry, remaining days, traffic, devices, and the active plan.

Connection

Per-platform instructions, app deep links, a QR code, and the raw link.

Account security

Active sessions, the account’s own history, and linked sign-in methods.

Plans and checkout

Comparison, trial, purchase, extension, upgrade, downgrade, promo codes, and the wallet breakdown.

Wallet

Balance per currency, the ledger, and top-up through any enabled provider.

Digital goods

Telegram Premium and Stars, with the recipient review step and delivery progress.

Support and news

Tickets, attachments, announcements, and communication preferences.

Referrals and loyalty

Code, link, invited and qualified counts, rewards, and tier progress.

Your own data

A personal-data export and an account-deletion request.
Every one of these is the same record the bot reads and writes. A checkout started in a chat is the checkout the browser shows; a support reply read on the web stops being unread in Telegram. There is one product with two surfaces.

How the panel is laid out

The panel is designed for a phone first and becomes an application on a desktop, rather than a desktop layout that collapses. That is not a slogan: the customer product is opened on a phone, frequently from inside Telegram. On a narrow viewport it is a single column with five tabs in a floating bar, chosen by frequency of need rather than by API division — the plan store and the digital-goods shop share one tab because from the customer’s side they are the same errand. At md and above the bar is replaced by a sidebar. It carries the same five destinations plus the ones a phone keeps one level down under the account tab — orders, devices, invites, announcements, and security — because the constraint that justified hiding them is that five is all a thumb bar can hold, and a column with room to spare does not have it. The sidebar also carries the account identifier, which is what support asks for, and a sign-out control. Somebody opening the web panel rather than the bot is, by definition, the customer who is not on a phone. Anything that is not one of those five is a detail route and gets a Back control instead. Five tabs cannot also carry devices, sessions, invites, notification preferences, and personal data, so those live in a hub on /account/profile: News is reached from the Support tab, and each history has its own screen: /account/orders for plan purchases and /account/shop/orders for digital goods. The whole panel renders robots: noindex, nofollow, and every /v1/account response carries Cache-Control: no-store, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin, X-Content-Type-Options: nosniff and a default-src 'none' content-security policy — because a customer response can contain a subscription link, which is a credential.

Enabling it

The panel is on by default and needs APP_DATA_ENCRYPTION_KEY, which seals OIDC client secrets, the short-lived sign-in flow cookie, stored contact addresses, and goods-gateway credentials. The checkout reads APP_TERMS_URL, APP_DEFAULT_CURRENCY, APP_RECOVERY_WINDOW_HOURS, APP_TRIAL_MINIMUM_ACCOUNT_AGE_HOURS, and APP_MULTI_SUBSCRIPTION_ENABLED — the same variables the bot reads, so one .env configures both processes identically. A trial the chat refuses and the browser grants would be an abuse control with a hole in it. The full list is in Configuration. A service that is not attached leaves its routes unmounted rather than answering 503 on each, so an installation with no commerce configured presents a panel that simply has no checkout instead of one whose every purchase control fails when pressed. For the same reason an unmounted surface answers 404, not 503: a 503 would say “this exists and is broken”, which is a lie about an installation that simply does not sell digital goods, and it would also tell an anonymous caller what the operator has configured.
A ticket opened in Telegram stores only a Telegram file reference, because the bytes stay with Telegram. A browser upload has no such custodian, so the installation holds it — which is why the panel needs a writable directory and the bot does not.
Leaving APP_CUSTOMER_COOKIE_SECURE off in production means the session cookie can be captured from any plain-HTTP request to the same host.

Sign-in

/account/sign-in renders from GET /v1/account/auth/methods, not from build-time knowledge, so an installation with no bot token does not show a Telegram button that cannot work and one with three OIDC providers shows three. The Telegram flag is derived from whether the bot username resolves, because the widget is loaded by username: a token without a resolvable username cannot render one. With neither Telegram nor any OIDC provider available the screen says “No sign-in method is configured yet. Contact support.” There is no password field, and there is no configuration that adds one. That is a decision rather than an omission, with a stated cost and a stated condition for reversing it: 0007 — Customers do not sign in with a password.

Telegram

The primary route. Telegram’s login widget signs its fields with HMAC-SHA256 under the bot token, so a payload that verifies is proof Telegram produced it for this bot. Omniflow additionally bounds auth_date to one minute: the hash never expires on its own, and without the bound a payload captured from a URL or a screenshot would authenticate indefinitely. POST /v1/account/auth/telegram takes the document exactly as the widget’s data-onauth callback delivers it — id and auth_date are JSON numbers, the rest strings — and verifies the signature over the digits as sent. The page posts the object untouched; a client that reshapes it first gains nothing and risks breaking the signature. The widget is loaded by the bot’s username, which Omniflow resolves from the bot API on first use. A resolved name is kept for the life of the process; a failed lookup is remembered for thirty seconds and then retried, so a bot API that was unreachable when the first customer arrived does not hide the button until the next restart. Telegram only renders the widget on a domain bound to the bot, so set that domain with /setdomain in BotFather before enabling it; while the username cannot be resolved the button is simply not rendered. Opened as a Telegram Mini App the panel signs in with no button at all: the surrounding client hands the page a signed initData, which is verified the same way under a different key derivation. The attempt is made once; if it is refused the screen says so and offers the ordinary methods, rather than retrying the same initData until the customer closes the app. A Telegram subject that has no identity row provisions a new customer — unlike the OIDC rule below, because a Telegram subject is not an address somebody else can assert, so there is no account here to take over. A verified Telegram account is resolved to a customer under the same advisory lock the bot takes on /start, in the bot’s order, so a first tap in the chat and a first sign-in in a browser cannot race into two accounts:
  1. An active identity row names the customer.
  2. Otherwise the Remnawave mapping does — an installation imported from an earlier release carries the Telegram ID on remnawave_users before any identity row exists, and a customer who never pressed /start is adopted rather than given a second, empty account. The identity is linked and the mapping is back-filled, exactly as the bot does it.
  3. Otherwise an identity the customer unlinked from the security screen names the customer it belonged to, and is reactivated. Earlier releases left that account unreachable from Telegram for good.
    Unlinking Telegram from Sign-in methods is therefore not a way to lock a Telegram account out. The next Telegram sign-in with the same Telegram account — widget or Mini App — silently relinks it and signs straight in. The unlink removes a method from the list; it does not revoke the Telegram account’s claim to the customer, because that account is still the one the bot knows the customer by. To stop a Telegram account reaching a customer record, an operator suspends the customer.
  4. Otherwise a new customer is created — with a bot_preferences row and a language taken from the Mini App’s language_code when there is one, else from the browser’s Accept-Language, else English — written to both users.locale and bot_preferences.locale, so the bot greets a customer who signed up in Russian in Russian.
When steps 2 and 3 disagree — the mapping names one customer and the revoked identity another — neither is chosen silently. Moving the identity would move a Telegram account between two people’s orders and wallets, so the sign-in is refused as identity_taken and the screen says to contact support. Every other non-operational failure collapses into a fixed answer set, because the installation must not become an oracle for which Telegram accounts it knows: An operator-enabled fallback for installations where the widget is not usable. The customer sends /login to the bot and receives a one-time link, valid for ten minutes and single-use, capped at five links per hour per account. That cap protects the customer rather than the server: without it, repeatedly asking for a link is a way to flood somebody else’s Telegram chat with sign-in prompts. It is requested from the bot rather than from a web form on purpose. A form would have to take some identifier and answer whether Omniflow knows it, and every version of that answer tells a stranger whether somebody is a customer here. Starting inside a chat the customer has already authenticated to removes the question, and guarantees the link can only reach somebody who already controls the account. Redemption takes two steps. The link lands on GET /v1/account/auth/link?token=…, which signs nobody in: it checks the token without spending it and redirects to /account/sign-in/confirm#token=…. That page asks the customer to continue and sends POST /v1/account/auth/link with {"token": "…"}, which consumes the token and opens the session in one transaction and answers like any other sign-in. The split exists because a GET can be triggered by any page. If the GET opened the session, somebody could ask the bot for a link to their own account, plant it in a victim’s browser, and leave the victim signed into an account the attacker controls — where whatever the victim then enters lands with the attacker (login CSRF). The POST, like every session-opening POST under /v1/account/auth, requires Content-Type: application/json and refuses a request the browser marks as cross-site (Sec-Fetch-Site other than same-origin, or a foreign Origin) with 403 cross_origin_request; a form post is 415 unsupported_media_type. The token travels to the confirmation page in the URL fragment, which the browser never sends to a server or in a Referer. Unknown, spent, and expired are one error on purpose: telling a caller which of them applied would confirm that a token once existed. The GET redirects failures to /account/sign-in?error=… with link_invalid or method_unavailable; the POST answers 401 link_invalid, 404 method_unavailable, or the ordinary sign-in problems (account_unavailable, sign_in_unavailable).
The link the bot delivers points at <APP_PUBLIC_URL>/v1/account/auth/link?token=…, which checks the token and hands off to the confirmation page. Earlier releases pointed it at /account/sign-in/link, a path the web application served no route for, so the link answered 404 after the one-time token had already been delivered. The web application now also serves /account/sign-in/link and forwards it to the API, so a link sitting in a chat history from an older release still works for the ten minutes it was promised.
The link is a bearer credential for ten minutes. The bot says so in the message and sends it with forwarding disabled.

OIDC

Any number of providers may be enabled at once, each an ordinary discovery document with an operator-chosen label, icon, and ordering. The flow is Authorization Code with PKCE, an anti-forgery state, and a nonce compared in constant time against the value the flow issued. The nonce is not redundant with PKCE: PKCE binds the authorization code to this flow, and the nonce binds the resulting ID token to it. The flow state lives in a short-lived sealed cookie (__Host-omniflow_account_oidc, SameSite=Lax) rather than in the database, so an abandoned sign-in leaves nothing to clean up. The cookie is cleared before the token exchange, so a replayed callback cannot be processed twice. Presets for Google, Yandex, and Discord prefill the form and produce ordinary rows; nothing downstream branches on which provider a row came from. Customer providers default to allow_auto_provision = true, unlike the operator panel’s providers, because a customer panel that refuses everyone who has not already used the bot is not a sign-in method, it is a second lock.
Yandex does not consistently return email_verified. Its preset therefore leaves the verified-email requirement off — turning it on for that provider refuses every sign-in through it.
Two rules govern how an external identity maps to a customer:
  • A subject nobody has linked never adopts an existing account by email. Matching on an address would let anyone who can make a provider assert somebody else’s address take over their account. An unlinked subject either provisions a fresh customer, when the provider allows it, or is refused.
  • Linking happens from inside a session. The link flow starts at GET /v1/account/sign-in-methods/oidc/{slug}/start, and the target account is sealed into the flow cookie rather than read from the session at the callback, so a link flow can never be completed against a different account than the one that started it. If the subject already belongs to another customer the attempt is refused as identity_taken, never merged: joining two customers would move one person’s orders, wallet, and subscription into another person’s account with no way back, and the refusal says the identity is in use, never whose account it is on.
As shipped, no screen in the web panel starts that link flow. /account/security lists the linked methods and offers unlinking only, so adding a second provider to an existing account is an API-level capability rather than a button.
Because the subject is the key and the address is only a claim, a customer changing their email upstream is a non-event and needs no recovery path. Disabling or deleting a customer OIDC provider revokes every session it minted (revoked_reason = 'provider_disabled'), because a provider that is off while the doors it opened stay open is not off.

Sessions

Sessions are cookie-backed, HttpOnly, and SameSite=Lax. The cookie is named __Host-omniflow_account when it carries Secure: the __Host- prefix binds it to this exact origin, so a browser refuses to accept it with a Domain attribute and a sibling subdomain cannot overwrite it. The prefix requires Secure, and a browser discards a __Host- cookie that lacks it rather than accepting a weaker one — so under APP_CUSTOMER_COOKIE_SECURE=false, the plain-HTTP local stack, the name would prevent sign-in altogether. It follows the attribute instead: omniflow_account without Secure, __Host-omniflow_account with it. Either way the name differs from the operator panel’s cookie, so both panels can be open in one browser without either replacing the other. Only the SHA-256 of the token is stored, so a database read never yields a usable cookie. Lax rather than Strict is deliberate: a magic link or an OIDC callback both arrive as cross-site navigations and would otherwise land signed out. The CSRF token, not the cookie policy, is what defends the unsafe methods. The customer figures are deliberately more generous. An operator signing in again costs seconds of a working day; a customer checking how many days are left does not warrant the same friction, and a panel that logs them out over lunch simply pushes them back to the bot. The absolute horizon and rotation do the security work instead, together with re-authentication on the few actions that warrant it. Neither panel’s lifetimes are configurable — they are the compiled-in defaults. Rotation is built to survive the way a browser actually loads a page: several requests at once, all carrying the cookie the tab held when it opened. When that burst lands on the rotation boundary, exactly one request swaps the token — the swap is a compare-and-set on the digest it arrived with, so a second request cannot overwrite it — and the superseded token keeps resolving for one minute through a forwarding entry in Valkey that names the session and carries the replacement sealed under the installation’s data key. A request that arrives on the old cookie inside that minute is answered normally and leaves with the new cookie; only after the minute is the old token a dead one, and only a dead one has its cookie cleared. If Valkey cannot be reached at the moment a rotation is due, the rotation is skipped and retried on a later request rather than performed without the forwarding entry — a session that rotates a day late is a smaller failure than a customer signed out for no reason they can see. Re-authentication applies to the four actions a stolen but still-live cookie could otherwise use to lock the real owner out or to make something irreversible happen: A session older than fifteen minutes is refused with 401 reauthentication_required — 401 rather than 403 because the remedy is something the customer can actually do, and the panel needs to tell them that instead of showing a flat refusal. Every one of those screens says so before the button is pressed rather than after, and a refusal sends the customer to /account/sign-in?next=<path>; every sign-in route — the widget, the Mini App, an OIDC provider — returns them to that path once the fresh session exists. The same next is carried when the shell finds the session gone, so a customer whose session ended on an order page comes back to that order. Only a same-site path under /account is honoured; anything else lands on the dashboard. Signing in again from a browser that still holds a live session for the same customer replaces that session rather than adding a second one: the earlier row is revoked with reauthenticated as its reason. A customer who was asked for a fresh sign-in asked for a fresher session, not for two devices on the security screen. A cookie belonging to a different customer is left alone — the new cookie simply takes its place in the browser — because ending it would let one person sign another out by knowing nothing more than the address of the screen. Every unsafe request carries a double-submit CSRF token derived from a per-session secret, so a token minted for one session cannot be replayed in another — and the customer and operator panels derive theirs under different labels, so neither can mint a credential for the other. The current token is published on the X-CSRF-Token response header of every authenticated response and captured by the shared transport, so a token rotated mid-session is already current by the time the next mutation is submitted. A mismatch is 403 csrf_failed. A missing cookie answers 401 unauthenticated before the service is even checked, because answering 503 there would tell an anonymous caller something about the installation’s configuration for no benefit. A dead session and a suspended account both clear the cookie on the way out, so a browser holding an unusable session stops presenting it on every subsequent request.
There is no single “sign out” button. A customer ends a session from /account/security — End on a specific row, or End all sessions, which deliberately includes the current one.

The subscription screens

The dashboard answers the question customers open it with: is my subscription working, and how long is left. Everything else on the page is reachable from there rather than competing with it. Each card carries a coloured phase dot, a phase sentence, the plan name, the remaining days as the headline figure, device usage, and a traffic meter. The phase vocabulary is the customer-facing one — active, expiring_soon, grace, limited, provisioning, none, disabled, expired, failed — and it is what both surfaces render, rather than the internal subscription status. Remaining days round up, because a subscription with eight hours left has a day left from the customer’s point of view, not zero. A subscription row is created together with the order that opens it, and it survives that order going unpaid. Such a row carries pendingOrderId while no entitlement has been provisioned, and the card renders it as Payment pending with a way to the order rather than as “not active” with a Connect control that the connect screen would refuse. Connect is offered only for a provisioned subscription in a usable phase; an expired or disabled one offers Renew, and one that was never provisioned offers Browse plans. The same decision drives the primary control on /account/subscriptions/{id}, where link rotation is shown only once something has been provisioned. The subscription switcher appears when the installation allows concurrent subscriptions, and also whenever the customer actually holds more than one even if the setting is off: a customer holding two subscriptions under a single-subscription setting is a migration state, and hiding the switcher there would strand one of them. Traffic is shown as a bar and, above it, as a sentence — the textual equivalent is visible to everyone rather than hidden for assistive technology, and the bar carries that same sentence as its accessible name. The percentage is computed on the server and clamped, so the two can never disagree. “Unlimited” is carried explicitly rather than inferred from a zero limit, and an unlimited allowance renders no bar at all. When Remnawave cannot be reached the page still renders what Omniflow itself knows — the plan, the period, the expiry — and says which figures are not current, rather than presenting a traffic number that has stopped moving as though it were live. A maintenance notice, when the operator has set one, renders above everything in the customer’s language, with an expected return time when one is configured; the operator-facing reason for the maintenance is not shown. On /account/subscriptions/{id} a customer renames the subscription — up to 40 characters, no control characters, refused rather than silently truncated because the label is shown back to them verbatim — and finds the link-rotation control.

Connecting a device

The connect screen exists to move one string onto another device. It offers a deep link per documented client, a QR code rendered in the browser, and the raw link with a copy button. The client list comes from the same table the bot renders, so a customer who reads one recommendation in the chat and a different one in the browser is impossible by construction. A subscription that is still being provisioned answers 409 not_provisioned, and the screen treats that as a stage rather than a failure: it says the subscription is being set up and re-reads every five seconds until the link exists, so a customer who arrives straight from a payment watches the screen turn into the link rather than being told to come back. No other refusal is polled — an outage does not clear on a timer. Rotation invalidates the old link on every device, and every device must import the new one before it connects again. Traffic and expiry are unaffected. It is the remedy when a link has been shared or leaked, and it sits behind a typed confirmation as well as a recent sign-in. One confirmation is one rotation. The screen refuses to submit a second request while the first is in flight, so a double press cannot invalidate the link the customer was just handed; and when the request was sent but its answer never arrived, the screen says the link may already have been replaced and points to the connect screen, rather than asking for a third confirmation that would rotate again.

Devices and privacy

The panel never returns a hardware identifier or an IP address for a connected device. A device is named by an opaque handle — a truncated digest the server resolves back by hashing its own current list — so removing one never requires the caller to hold the identifier being acted on. Last-seen is a date rather than a precise instant: enough to recognise your own laptop, not enough to build a movement log from. Subscription links are credentials, so the connect screen fetches one on demand rather than the dashboard carrying it, and the QR code is generated in the browser rather than fetched as an image, which would otherwise put the link into a request line, a proxy log, and the browser cache. Connecting a device covers all of this in full: the per-platform client table, deep links, the QR, the manual fallback, the device list, removal, and link rotation.

Account history

The security screen lists live sessions (up to 50), the linked sign-in methods, and an account history the customer can read: sign-ins, sign-outs, link rotations, device removals, and changes to sign-in methods, 50 at a time. Each session row shows the sign-in method, the IP address recorded for it, and a relative last-seen, with either “Current” or an End button. End all sessions sends keepCurrent: false deliberately — the control exists for a suspected compromise, and sparing the session that pressed it would leave an attacker in if they were the one holding it — and then returns the browser to the sign-in screen. That history is a separate record from the operator audit trail. The audit trail is an operator artefact searched under operator permissions, and deciding per row which of its fields a customer may see would put a disclosure decision in the read path. The customer-facing log is written to be shown: its vocabulary is closed, and it has no column for an amount, a link, or another party’s identifier. Removing a sign-in method is refused when it is the last one (409 last_sign_in_method), and the panel disables the control rather than letting the request fail, because the reason is worth showing before the attempt. A customer who removes their only way in has locked themselves out of an account holding a paid subscription, and nothing in the panel can let them back in.

Buying and paying

The store screen compares plans on the terms that decide a purchase: period, traffic allowance, device limit, price, and currency. It sorts nothing, filters nothing, and hides nothing — a plan the customer cannot start still appears with the server’s reason under it, because a customer who was told about a plan and cannot find it assumes the panel is broken rather than that they are ineligible. Eligibility and the offered operations arrive decided from the API. The operation vocabulary on the wire is purchase, extension, upgrade, and downgrade; renewal and trial are accepted input aliases that normalise into those four. The rule is one sentence, and the bot applies the same one: the plan the customer holds is extended, a plan priced at or above a held one is an upgrade, a plan priced below a held one is a downgrade, and with nothing held the only thing to do is buy. “Held” means the plan behind a subscription’s current entitlement that has neither been superseded nor run out — an expired entitlement is not held, so a lapsed customer is offered purchase on every plan including the one they had, rather than an “upgrade” away from a plan they no longer pay for. An equal price is an upgrade, not a refusal. upgrade and downgrade appear only when the plan version’s policy is not forbid; with several live subscriptions a plan is judged against each and may be both, and the target picker resolves which one it applies to. purchase is offered alongside when the policy allows one more subscription. A plan that offers nothing — the customer holds something and this plan forbids being moved to — carries subscription_limit_reached. A purchase never targets a subscription that still has time left. It schedules from now and supersedes what is there, so aimed at a live subscription it would throw the remaining time away; the plan page and the checkout list only empty slots — an unpaid or lapsed subscription — beside “a new subscription”, and POST /checkout refuses the rest with 422 operation_forbidden. Under a single-subscription setting a purchase against a live subscription is refused the same way; the catalogue offers extension there instead.
The catalogue’s eligibility answer is advisory. The plan’s concurrency cap and the subscription count are re-checked inside order creation under a lock this read deliberately does not take, and a trial is re-evaluated again at confirm time. Hiding an impossible action in the catalogue is a courtesy; refusing it at creation is the guarantee.
A plan with no price in the settlement currency is omitted from the catalogue entirely, because it cannot be bought here at all and an entry nobody can act on is only a question the customer cannot answer. A checkout is a resumable purchase intent, and a customer has at most one. It is shared with the bot: opening a checkout in a browser deletes one left open in a chat in the same transaction, and a partial unique index in the database enforces one-at-a-time rather than either surface’s code doing it. Two half-finished purchases for one person is a state neither surface could explain. A checkout expires one hour after it is opened, and reading an expired one deletes it first, so the customer restarts from a fresh, correctly priced quote rather than confirming a price the catalogue has since moved away from.
1

Choose a plan and a target

When concurrent subscriptions are enabled, every extension, upgrade, and downgrade names the subscription it changes — with no preselected default, because an upgrade applied to the wrong subscription is not visible until the next renewal. A single-subscription installation renders no picker at all.
2

Configure it

Squads and add-ons, where the plan offers them. The bounds on a squad selection are stated as guidance and enforced by the server: a second implementation of the counting rule in React is exactly what would eventually let through a set the order then refuses.A plan that requires a choice opens without a price: the checkout carries squadSelection: {required: true, reason} and quoteAvailable: false, the configurator renders first, the breakdown says to choose, and Confirm stays disabled until the server reports that the selection resolves. Each tap sends the whole set, so an incomplete set is stored and reported as squad_selection_too_few; a set the plan could never accept — a server it does not offer, more than it allows, any choice on an automatic plan — is refused with 422 before it is stored, so the checkout is never left in a state the customer cannot repair from the screen.
3

Apply a promo code

A refused code is answered with a reason — unknown, ineligible, exhausted, or malformed — and the quote is repriced without it, so the screen explains the outcome instead of failing. The plan page lists which campaigns are running but never the codes that redeem them: a code is a bearer value the operator hands to a particular audience.
4

Read the breakdown

Subtotal, add-ons, discount, wallet applied, and the external amount that still has to be settled. Every figure is computed by the server, so the number a customer approves and the number they are charged cannot be arrived at two different ways.
5

Pay

Only providers that are enabled and settle the chosen currency are offered, each showing the price it would charge — the same plan can cost a different number through a different provider, and hiding that until the provider’s own page would be a nasty surprise.
Confirming a checkout creates an order and charges nothing. Starting the provider payment is a separate step, so a customer who closed the tab between the two finds an order waiting rather than a payment nobody can account for. Confirmation also applies the operator’s required-channel gate, for a customer with a Telegram identity, and refuses with 422 channel_required carrying the channels to join — the same list the bot shows before Pay — when the customer is recorded as absent from one. The web reads the membership the worker recorded rather than asking Telegram, so a customer who has just joined waits for the next sweep or presses I joined in the bot; an unknown or stale answer never blocks. An order that is still pending and owes money carries paymentChoices — the methods that settle its currency, each priced at what is owed — and preferredProvider, the method the checkout recorded, so the order screen offers a picker of its own rather than depending on the ?provider= the checkout redirected with. Arriving from the order list, reloading without the query string, or opening the order in a second tab all still lead to a payment. POST /orders/{id}/payment defaults to the intent’s provider, then to the recorded one; it refuses a method that does not settle the order’s currency with 422 provider_currency_unsupported, and an order that stopped being pending — or whose hour passed, whether or not the expiry sweep has run yet — with 409 order_not_payable, so a button pressed a moment too late reads as a conflict rather than a failure. Telegram Stars is offered only to a customer with a Telegram identity, because it settles through an invoice in a chat. The checkout screen always offers Discard this purchase, including when the checkout itself could not be read. A customer has at most one checkout and it blocks every other purchase, so a way out that lived only on the happy path would leave somebody holding a purchase they can neither finish nor abandon.

When a payment does not land cleanly

The order screen folds three facts — order state, payment intent status, and fulfillment status — into one phase, precisely so that neither surface has to combine them itself. A late webhook arriving after provisioning already completed is the case that makes that worth insisting on. A three-stage progress bar (paid, provisioning, ready) is drawn from the phase alone, and the page polls every six seconds while the phase can still change and not at all once it cannot. The payment handoff is one of four kinds, each with its own wording, because collapsing them into one “complete your payment” message would leave three of the four customers waiting for something that is never going to appear: Telegram Stars settles only inside a Telegram chat, so it is offered — in the checkout, on the order screen, and for a wallet top-up — only to a customer who holds a Telegram identity, and a Stars payment started by anyone else is refused as provider_unavailable. The link is built from the bot’s username, resolved by the same lookup the sign-in screen uses; /start pay_<orderID> is the bot’s cue to send the invoice for that order. “Nothing left to pay” is shown only when externalMinor is zero. A refresh control re-reads the provider rather than merely re-fetching Omniflow’s own record, so a customer who returns before the webhook arrives still sees the settled state; it is best-effort, and an adapter that cannot be polled leaves the order exactly as the webhook will eventually find it. Provisioning progress is read from the fulfillment operation the order created, with the attempt count shown once it exceeds one — a retrying operation looks identical to a stuck one from outside, and the difference is the only thing a waiting customer wants to know. The error shown is the worker’s own bounded classification, never a provider message, because those can carry identifiers. Cancel appears only while the order is still draft or pending; anything later is 409 order_not_cancellable, because refunds are an operator action, not a customer one. Cancelling is idempotent and also deletes the checkout session pointing at the order, so the customer is not left holding a checkout that points at something cancelled.
Confirming a checkout reuses the checkout’s own idempotency key, so a double tap or a retried request resolves to the order already created. Starting a payment keys on the order and the chosen adapter — switching provider is a new payment, pressing the same button twice is not. Both routes still require an Idempotency-Key header of 8–128 characters, so a client that retries has said it means to, but that header is not what deduplicates.
/account/orders lists plan purchases newest first, cursor-paginated on the pair the list is ordered by, so a payment that settles between two pages cannot make a row appear twice or drop one out of sight. Wallet top-ups are not in this list — a top-up moves money into the wallet rather than buying anything, and it is accounted for in the wallet’s own ledger. A customer looking for a top-up in their order history will find it on /account/wallet instead.

Wallet

Three figures are carried separately because they answer different questions and a single “balance” would answer neither honestly: total is what the ledger holds, reserved is what unpaid orders have already claimed, and available is what a new order may actually spend. A customer who sees only the total and then watches a smaller amount apply at checkout has been told the wrong number twice. Balances are per currency and isolated: a balance in one currency never funds an order in another. Every currency the customer holds gets a card, but the top-up form only ever offers the settlement currency. The ledger is append-only, so a correction appears as a compensating entry rather than as an edited row, and each row names its movement type — credit, debit, payment, refund, referral reward, correction, or expiration — with the operator’s note when a correction carried one. Top-up runs through the same order, webhook, and reconciliation pipeline a plan purchase uses, with the operator’s preset amounts, minimum, maximum, and rolling window applied. The presets are filtered by the domain against what this customer has already credited in the window, so an amount that is offered is one that would be accepted. Refusals arrive as topup_disabled, topup_below_minimum, topup_above_maximum, topup_window_exceeded, or topup_invalid_amount. The caller’s idempotency key becomes the order’s key here, unlike everywhere else: a top-up mints real money into the wallet, so a retried request credits the same top-up rather than a second. If the order is created but the provider refuses the intent, the order is still returned — 201 with paymentProblem in place of payment — because it exists and is the customer’s, and a provider that refused an intent is a retryable state, not a reason to hide what was created. GET /v1/account/wallet carries pendingTopUps: the top-up orders still waiting to be paid, each with its payment handoff. The wallet screen renders the handoff from that list rather than from the response that started it, so a provider page the customer closed, a reload, and a second tab all show the same way back in — and a top-up whose payment never started links to the order screen, which offers every method that settles it. Top-ups are not in /account/orders, so this list is the only place they appear.

Digital goods

The shop sells Telegram Premium and Stars under exactly the rules the bot follows. The catalogue quotes no prices, because a quote is a promise with an expiry on it and a screen full of promises would start expiring while it was still being read; a product whose gateway cannot currently sell is shown as unavailable rather than hidden, so a customer who bookmarked it learns why. A price is a quote with an expiry. It is issued when a product is opened, not when the catalogue is listed. The panel shows the countdown, and the purchase echoes the displayed quote back. The server re-quotes before charging and refuses with a distinct reason when the window has passed (quote_expired, re-quoted silently) or the number moved (price_changed, shown side by side and requiring a second deliberate press), so the price on screen is always the price charged. The recipient is confirmed on its own. The handle is normalised by the server — a pasted link, a leading @, a trailing slash all fall away — shown in the exact form the gateway will be given, and the purchase is refused unless the confirmed string is byte-identical to the one submitted. Editing the field drops the agreement. Delivery is irreversible once a gateway has sent the goods, and a mistyped username cannot be recovered. Quantity is clamped to ten for the same reason.
The “this is for me” switch annotates the order for the customer’s own history and decides nothing about delivery. Delivery is addressed purely by the reviewed handle — Omniflow stores no Telegram username for a web customer.
An ambiguous delivery parks. The gateway that fronts Fragment honours no idempotency key, so a lost answer is genuinely ambiguous: retrying might deliver and charge twice, and refunding might refund goods that arrived. Such a delivery moves to the operator review queue and the panel says so, offering support rather than a retry button — and it is coloured as a warning, not a failure, because nobody can yet say the purchase failed and painting it red would tell the customer something untrue about their money. The support handoff carries a reference of the form goods-order:<orderId>, which names the order in a form a ticket can carry without repeating the recipient. A delivery that fails permanently is refunded to the wallet, and the panel reports the refund only once the credit exists. An unpaid shop order is settled through the ordinary order screen rather than a second checkout: a second checkout is how two checkouts start disagreeing about what is enabled. When money is owed and no configured provider settles the currency, the panel offers support instead of a dead button.

Support and news

Tickets are the operator desk’s tickets and the bot’s tickets — one conversation per record, read from whichever surface the customer opened. Unread state lives on the message rather than on the surface, so a reply read in Telegram is not still bold on the web, and a customer who answers in a browser appears in the operator queue exactly as one who answered in a chat. A reply to a resolved ticket reopens it, because a customer who writes back has not had their question answered — the same rule the bot applies, so the two surfaces cannot leave a ticket in different states. Internal operator notes are never returned. They live in their own table and no customer-facing query touches it, which is a stronger guarantee than filtering them out would be. The limits are published by GET /v1/account/support/limits and loaded before the compose form is usable, so the panel counts down to the same number the API refuses at instead of guessing: Lengths are counted in characters rather than bytes, because a Cyrillic subject would otherwise be refused at half the length an English one is allowed. Reaching the open-conversation quota disables the “new conversation” control and refuses creation with 409 too_many_open_tickets. A conversation returns at most its 200 most recent messages. Attachments are an allowlist and nothing sniffs the bytes — sniffing exists to guess at an unknown type, and a guess is precisely what an allowlist must not accept. A file attached in Telegram stays with Telegram and is named rather than offered for download, because offering a download that cannot work is worse than saying where the file lives.

News

/account/news lists published announcements with a category badge — announcement, incident, maintenance, or news — an unread marker, and an explicit Mark as read button. Reading is an act the customer takes rather than something inferred from a scroll position, and the read row is shared with the bot, so a post marked read here stops being unread there. The panel deliberately sends no locale with the request. The server resolves which language a customer reads announcements in — the request, then the bot preference, then the account’s own locale, then English — because forcing this page’s locale would make the same customer read the same announcement in two languages depending on which surface they opened; when the resolved locale differs from the page’s, a notice says so. A post with no translation in the resolved locale is absent rather than shown in another language: an inbox that silently switches language mid-list is harder to read than a shorter one. A post classed as marketing carries an extra badge. The customer consented to receive it and is entitled to see which of these is a service notice and which is an offer, without having to infer it from the wording. /account/preferences holds four notification switches (expiry, traffic, renewal, news — all on by default), the message language the bot and mailer write in, a quiet-hours window, the browser-notification opt-in, marketing consent with its dated evidence, and a read-only list of the contact channels. Every control writes one field: the route is a genuine PATCH where an omitted field is left untouched, because sending the whole document back would let this page overwrite a choice the customer made in Telegram between the fetch and the toggle. There are two language controls, and they write to different places on purpose. The profile language (PATCH /v1/account/me) is the account’s language: it is written to users.locale and, in the same transaction, to bot_preferences.locale, because the bot reads the latter first and a profile setting that touched only the former claimed to change the bot’s language and did not. The notification language on this screen writes bot_preferences.locale alone and may be auto, which lets the bot follow the Telegram client’s language for messages while the panel keeps the account’s. Setting the profile language afterwards replaces auto with the chosen language, so the two surfaces never disagree about a choice the customer made once. Quiet hours are whole hours in the customer’s own timezone, and are cleared by sending a window whose start and end are equal — omitting the field would mean “leave the window alone”, which is the opposite of what the customer just asked for. The timezone they are counted in is users.timezone, and the same screen sets it: a searchable picker beside the quiet-hours window, listing the IANA zones the browser knows with each one’s current offset and the zone the device reports at the top. It writes through PATCH /v1/account/me together with the account’s current language, because that route takes both and a timezone change must not reset the language. Every account starts on UTC, which is why the control is next to the window it governs rather than on the profile screen. Consent is append-only: a change records a new decision with the surface and policy version that made it, so the question “when did this person opt in, and where” always has an answer, and a row is written on every unsubscribe even when the customer had already opted out — “they had already opted out” is not a reason for their unsubscribe to leave no trace. Re-consenting clears only a suppression the customer themselves caused; a suppression recorded for a bounce or a complaint is somebody else’s finding about deliverability and a tick-box does not overturn it. Unsubscribing stops marketing messages and news, clears the marketing flag on every live contact channel, and records a suppression. It does not stop transactional messages about a payment, an expiry, or a support reply — 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.
Marketing consent can be recorded from two screens — the preferences switch and the contact-channel form on /account/privacy — and each stamps its own source and policy version. A customer reading their consent trail may therefore see two different provenances for the same decision.
Browser notifications are offered only behind an explicit opt-in, never requested on page load, and they are foreground-only: the panel raises one for a new operator reply or announcement it observes while the tab is in the background. There is no web push, no service worker, and no stored subscription, 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. The opt-in lives in the browser’s own storage rather than on the account, because an account-level flag would claim a setting one device cannot honour; it does not follow the customer to another browser and it vanishes with site data.

Referrals and loyalty

The referral screen shows the code, the program terms and qualification rule, the invited, qualified, pending and rejected counts, the remaining slots against the operator’s cap, and the reward history with each reward’s state. The code is created on the first read of the screen, minted the same way the bot mints one, so either surface can attribute it. The history never carries the invited customer’s identifier: an inviter is entitled to know that an invite paid out, not to a list of who their friends are once the friend is a customer of somebody else’s service. A reversed reward is excluded from the earned total and from the cap count. An operator reverses one when a referral turns out to be abuse, and the slot it consumed comes back with it. When no public URL is configured the panel shows the code and says no link can be built, rather than rendering a link to nowhere. The shareable link the panel builds is <APP_PUBLIC_URL>/account/sign-in?ref=<code>. The sign-in screen holds the code for the duration of the sign-in — in session storage, so it survives an OIDC round trip and dies with the tab — and the panel posts it to POST /v1/account/referrals/attribution on its first authenticated render. That is the same moment the bot’s /start ref_<code> attributes, and the rule is the bot’s: first write wins, self-referral is impossible by construction, and the pair is immutable. Two guards the programme’s own terms imply are applied on top: nothing is written while the programme is disabled, and nothing is written for a customer who has already paid for anything, because the qualification is the first paid order and such a row could never qualify honestly. The route always answers 200 with an outcome (recorded, already_attributed, program_disabled, unknown_code, self_referral, not_new) rather than a problem, because a link that did not count is not something the invited person can act on, and the panel does not surface it. Loyalty shows the current tier, the progress toward the next, and the rules that govern it. Nothing on these screens grants a reward or promotes a tier: the reward and evaluation workers already wrote those rows, and doing it on a page load would make a customer’s balance depend on which screen they opened — and a customer inside their grace period would be demoted the moment they opened the page. A programme that is not enabled, or one with no tiers, is reported as “not enabled” rather than as an empty ladder, because those look identical in a list of zero tiers and only one of them is worth showing a screen for.

Personal data

Export

A customer can download their own records: profile, identities, contacts, subscriptions, entitlements, orders, payments, wallet ledger, support conversations, referral and loyalty standing, consent history, and lifecycle events. It is produced synchronously rather than queued. A queued export would need a delivery channel, a retention window, and a link that authenticates on its own — three further disclosure decisions to answer a question that fits in one response. It is a POST despite changing nothing, because a GET would be cached, prefetched, logged in a proxy’s access line, and reachable by navigation, and this is the most sensitive document the API produces. The document is never logged and never written to disk, and the panel never renders it — a screen that painted it would leave it in a scroll position, a screenshot, and a back-forward cache. The document declares what it left out and why: no other customer’s identifier, no payment credential, no provider secret, no subscription link, and no device or network identifier. Each section is capped at 1000 rows and any section that hit the cap is named in the document, because a customer told their data is complete when it is not has been misinformed about the one thing the document exists to answer.

Deletion

Requesting deletion records a request. It does not delete. The retention workflow an operator already governs is what carries it out, so an irreversible action never happens on the strength of one browser session, however recently that session authenticated. The request needs a recent sign-in, an explicit confirmation, and a stated reason of at most 400 characters — free text rather than a menu, because a closed list would put the operator’s categories in the customer’s mouth. It can be withdrawn until it is executed, and cancelling it needs no re-authentication: cancelling only ever prevents the irreversible thing. Three things happen on the request, in this order, so that “recorded” means more than a line in a log. The lifecycle event is written with the customer as its actor. In the same transaction an operator notice is queued on the security stream — customer_deletion_requested with the account identifier, and never the customer’s reason, which is their own words and belongs in the record the operator opens rather than in a group chat. Then every other session the customer holds is ended: the request is the customer saying they are done with the account, and a session left open on another device would otherwise keep using it, or withdraw the request, without them. The session that asked is kept, because cancelling is done from it. There is no worker that acts on the request; the operator does, from the customer’s record in the panel, which is what the notice points them at. A second request while one is open is refused with 409 deletion_pending. The lifecycle event is never removed on cancellation — “this was asked for and then withdrawn” is a different fact from “this was never asked for” — but a withdrawn request loses its reason, because the customer’s words were written for a decision that is no longer open.

Contact channels

The addresses this installation may reach the customer on are listed and managed on /account/privacy. Adding one records an intention and nothing more: the panel cannot prove an address belongs to the person typing it, so the flags are a permission the delivery pipeline reads, not a claim that the address is verified. A channel that may receive neither transactional nor marketing messages is refused, because that is an address stored for no reason. A contact address already registered elsewhere is refused with nothing more than “not available here”, and the remedy is contacting support. Saying that another account holds it would turn the panel into an oracle for whether a given address is a customer. Removing a channel revokes it rather than deleting it, so an address cannot be silently moved between accounts and the consent history keeps pointing at something. Addresses are sealed with the installation’s data key and matched on a keyed fingerprint, so a stolen database cannot be tested for a known address by hashing candidates. Without APP_DATA_ENCRYPTION_KEY the whole section reports as not offered rather than as an error.

Rate limits and the transport contract

Only two customer routes are rate-limited, both per customer and both failing closed — an unreachable limiter refuses, so an outage cannot silently remove the protection: Nothing else under /v1/account is rate-limited at the HTTP layer, including the sign-in routes. For anyone integrating against the same API:
  • Problems are RFC 9457 — application/problem+json with {type, title, status, detail, request_id}, where the code at the end of type is the contract. The detail string is English prose written for operators; the panel never shows it to a customer, and translates the code instead.
  • CSRF: read X-CSRF-Token from any authenticated response and echo it on every method other than GET, HEAD, and OPTIONS.
  • Cursors: order, wallet, and shop histories page on ?cursor=<timestamp>&cursorId=<uuid> and answer 400 invalid_cursor when it is malformed; support, news, and referral histories take one opaque ?cursor= and silently return the first page instead.
  • Idempotency-Key is 8–128 characters. It is required on POST /checkout/confirm, POST /orders/{id}/payment, POST /wallet/top-up, and POST /shop/purchase, and optional on the two support-writing routes, because a customer whose browser resubmits a form should not be shown a validation error about a header they have never heard of.
  • JSON bodies are decoded with unknown fields rejected and must contain exactly one value; anything else is 400 invalid_request.
  • Every “not found” is deliberately indistinguishable from “not yours”.
The panel’s own transport is hand-written rather than generated, because the generated SWR hooks emit bare fetch calls that honour no mutator: a generated hook would send no session cookie and no CSRF token, so every mutation through one would be rejected and every read would be anonymous.

Connecting a device

Clients, deep links, QR, the device list, and link rotation.

Telegram bot

The same product in chat, and the surface that reaches a closed tab.

Notifications

Notification classes, consent, quiet hours, and the news inbox.

Privacy and your data

Export contents, redactions, deletion requests, and retention state.

Orders and payments

The order state machine behind the screens above.

Customer OIDC

Configuring the sign-in providers this panel offers.