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

# Customer web panel

> Everything a customer can do from a browser: sign-in, subscriptions, checkout, the shop, support, referrals, and their own data.

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](https://omniflow-vpn.mintlify.site/customer/connection.md)**
>
> 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.

| Tab | Route | What it holds |
| - | - | - |
| Subscriptions | `/account` | The dashboard: one card per subscription |
| Store | `/account/store` | Plans, the resume-checkout banner, order history, the shop |
| Wallet | `/account/wallet` | Balances, top-up, the ledger |
| Support | `/account/support` | Tickets, News, notification preferences |
| Account | `/account/profile` | Theme, language, and the hub |

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`:

| Hub entry | Route |
| - | - |
| Devices | `/account/devices` |
| Sessions | `/account/security` |
| Invites | `/account/referrals` |
| Notification preferences | `/account/preferences` |
| Personal data | `/account/privacy` |

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.

| Variable | Default | Purpose |
| - | - | - |
| `APP_CUSTOMER_PANEL_ENABLED` | `true` | Mounts `/v1/account`. A Telegram-only installation can turn it off. |
| `APP_CUSTOMER_COOKIE_SECURE` | `true` | Marks the session cookie `Secure`. Turn it off only for a plain-HTTP local stack; the API logs a warning at startup when it is off. |
| `APP_CUSTOMER_MAGIC_LINK_ENABLED` | `false` | Offers the bot-delivered sign-in link. Requires `APP_PUBLIC_URL` or the API refuses to start. |
| `APP_SUPPORT_ATTACHMENT_DIR` | `/var/lib/omniflow/support-attachments` | Where a file uploaded through the web panel is kept. Mount a volume at it. |

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](https://omniflow-vpn.mintlify.site/getting-started/configuration.md).

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.

> [!NOTE]
>
> 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.

> [!WARNING]
>
> 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](https://omniflow-vpn.mintlify.site/architecture/decision-0007-no-customer-password.md).

### 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.

   > [!WARNING]
   >
   > 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:

| Condition | Status | Problem code |
| - | - | - |
| No Telegram method configured | 404 | `method_unavailable` |
| The account is suspended or closed | 403 | `account_unavailable` |
| The payload did not verify, or sign-up is refused | 401 | `sign_in_rejected` |
| Two customer records claim the Telegram account | 409 | `identity_taken` |
| Anything else | 500 | `sign_in_unavailable` |

### 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`).

> [!NOTE]
>
> 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.

> [!NOTE]
>
> 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.

> [!WARNING]
>
> 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.

> [!NOTE]
>
> 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.

| Property | Customer panel | Operator panel |
| - | - | - |
| Inactivity timeout | 14 days | 30 minutes |
| Absolute lifetime | 60 days | 12 hours |
| Token rotation | every 24 hours | every 15 minutes |
| Re-authentication window | 15 minutes | — |

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:

| Action | Route |
| - | - |
| Remove a sign-in method | `DELETE /v1/account/sign-in-methods/{identityID}` |
| Rotate the subscription link | `POST /v1/account/subscriptions/{id}/rotate-link` |
| Disconnect every device | `DELETE /v1/account/subscriptions/{id}/devices` |
| Request account deletion | `POST /v1/account/privacy/deletion` |

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.

> [!NOTE]
>
> 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.

### 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](https://omniflow-vpn.mintlify.site/customer/connection.md) 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.

> [!NOTE]
>
> 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](https://omniflow-vpn.mintlify.site/operations/required-channels.md), 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:

| Handoff | What the customer does |
| - | - |
| `hosted` | Opens the provider's own checkout page in a new tab |
| `telegram_invoice` | Opens the bot through `https://t.me/<bot>?start=pay_<orderID>`; the bot sends the invoice for that order and the customer pays it in the chat |
| `manual` | Waits for an operator to confirm the transfer, which can take hours |
| `none` | Nothing, when the order owes nothing: the wallet covered it. On an order that still owes money it means the bot link could not be built, and the page says to finish the payment from the bot's order list |

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.

> [!NOTE]
>
> 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.

> [!NOTE]
>
> 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:

| Limit | Default |
| - | - |
| Open conversations per customer | 5 |
| Subject length | 120 characters |
| Message length | 4000 characters |
| Attachment size | 5 MiB (an installation may configure less, never more than 10 MiB) |
| Attachment media types | `image/png`, `image/jpeg`, `image/webp`, `application/pdf`, `text/plain` |

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.

> [!NOTE]
>
> 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:

| Route | Limit |
| - | - |
| `POST /v1/account/contacts` | 10 per hour |
| `POST /v1/account/privacy/export` | 3 per hour |

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.

## Related

> **[Connecting a device](https://omniflow-vpn.mintlify.site/customer/connection.md)**
>
> Clients, deep links, QR, the device list, and link rotation.

> **[Telegram bot](https://omniflow-vpn.mintlify.site/customer/telegram-bot.md)**
>
> The same product in chat, and the surface that reaches a closed tab.

> **[Notifications](https://omniflow-vpn.mintlify.site/customer/notifications.md)**
>
> Notification classes, consent, quiet hours, and the news inbox.

> **[Privacy and your data](https://omniflow-vpn.mintlify.site/customer/privacy.md)**
>
> Export contents, redactions, deletion requests, and retention state.

> **[Orders and payments](https://omniflow-vpn.mintlify.site/commerce/orders-payments.md)**
>
> The order state machine behind the screens above.

> **[Customer OIDC](https://omniflow-vpn.mintlify.site/integrations/oidc.md)**
>
> Configuring the sign-in providers this panel offers.


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