/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.
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. Atmd 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 needsAPP_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.
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 boundsauth_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:
- An active identity row names the customer.
-
Otherwise the Remnawave mapping does — an installation imported from an
earlier release carries the Telegram ID on
remnawave_usersbefore any identity row exists, and a customer who never pressed/startis adopted rather than given a second, empty account. The identity is linked and the mapping is back-filled, exactly as the bot does it. - 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.
-
Otherwise a new customer is created — with a
bot_preferencesrow and a language taken from the Mini App’slanguage_codewhen there is one, else from the browser’sAccept-Language, else English — written to bothusers.localeandbot_preferences.locale, so the bot greets a customer who signed up in Russian in Russian.
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:
The sign-in link
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-forgerystate, 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.
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 asidentity_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.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 answers409 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.
Rotating the access link
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 sendskeepCurrent: 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 ispurchase, 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.
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.
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 astopup_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.
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 byGET /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.
Preferences and consent
/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.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 thesecurity 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+jsonwith{type, title, status, detail, request_id}, where the code at the end oftypeis the contract. Thedetailstring is English prose written for operators; the panel never shows it to a customer, and translates the code instead. - CSRF: read
X-CSRF-Tokenfrom 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 answer400 invalid_cursorwhen it is malformed; support, news, and referral histories take one opaque?cursor=and silently return the first page instead. Idempotency-Keyis 8–128 characters. It is required onPOST /checkout/confirm,POST /orders/{id}/payment,POST /wallet/top-up, andPOST /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”.
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.
Related
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.