Skip to main content
Quickstart leaves you with a running installation and an operator account. It has no catalogue, so both customer surfaces show an empty plan list and nothing can be bought. This page fills that gap: one plan, one immutable priced version, one payment provider, the bot connected, and a real purchase followed from the customer’s tap to the Remnawave user it creates. Work through it in order. Each step is verifiable on its own, which matters because the checks that decide whether a plan is sellable are spread across four tables.
A purchase cannot currently settle on a freshly migrated database. Settlement inserts a fulfillment job in the same transaction as the money, and no committed migration creates the River queue’s own tables, so that insert fails and the whole settlement transaction rolls back. Everything up to and including the provider handoff works and is worth setting up; the step where it stops is described under Where a test purchase stops today.

Decide the settlement currency first

APP_DEFAULT_CURRENCY (default RUB) is the currency both customer surfaces compare the catalogue in, and it is uppercased and validated against ^[A-Z]{3}$ at startup. It matters here because of one rule that catches every first installation: A plan with no price in the settlement currency is omitted from the customer catalogue entirely. It is not shown as unavailable. A row nobody can act on is only a question the customer cannot answer, so it is not rendered at all. If you intend to sell through Telegram Stars only, set APP_DEFAULT_CURRENCY=XTR and price your versions in XTR. Stars settle in no other currency.

Create the plan

A plan is the stable, customer-facing product: a lowercase code, a kind, Russian and English copy, a visibility flag, and a sort order. It carries no price and no duration — those belong to its versions, which is what lets you rename and reorder a product without touching what anyone already bought. The operator panel cannot create a plan. /admin/catalog publishes versions, toggles visibility, and saves add-ons, but plan creation exists only on the bearer-token API — a gap worth knowing about before you go looking for a button.
One call creates the plan, both localisations, the first version, and its prices in a single serializable transaction, so a plan is never visible without the copy and price that make it sellable. Refusals to expect: an unknown JSON field is 400 invalid_request, because the decoder rejects unknown fields rather than silently dropping a typo; a missing duration or an empty price list is 422 plan_version_rejected with duration and prices are required; a bad bearer token is 401 unauthorized.
Re-running this call with the same code does not fail. The insert upserts on code, so you get the existing plan back — and createVersion then appends version 2. Change the code, or use the panel, if you did not mean to publish another version.

Publish a priced version from the panel

Every later price change is a new version. There is no update route for a version, on purpose: a plan version is immutable once an order references it, and an editor that could change one would silently re-price history. An order additionally stores a JSON snapshot of the version’s period, duration, traffic, device limit, squads, and lifecycle policies at purchase time, so even a tampered version row could not change what an existing order cost. Open /admin/catalog (permission catalog.read; the controls need catalog.write), find the plan row, and press New version. The editor expands under the row, pre-filled from the current version, because a price change is usually the only difference. Publish version stays disabled until the duration is above zero, at least one price parses, and a reason is typed. The card is titled “Publish the next version” and explains why: a plan version is immutable once an order references it, so this publishes a new one instead of changing the current one. Publishing takes an advisory lock while allocating the number, so two operators cannot mint version 4 twice. The version editor states the consequence itself: existing orders keep the version they were bought at.
Two selects in this editor send values the database rejects. Squad selection sends automatic, customer_choice, or fixed, while the column’s constraint allows automatic, optional, and required. Trial eligibility sends new_customer, never, or any_customer, while the column allows new_customer, never_trialled, and any. Anything other than Assigned automatically and New customers only passes the panel’s own validation and then fails the constraint, which surfaces as 500 operation_failed. To offer customer-chosen locations, or a trial rule other than the default, call POST /v1/panel/catalog/plans/{planID}/versions directly with the database’s values.
A version published from the panel offers no add-ons. The offered set is replaced with exactly the addonIds in the request, and the panel’s editor does not send that field. Populating it needs a direct API call — see Add-ons and the configurator.
Two more controls decide what happens to a version later, both API-only: POST /v1/panel/catalog/plans/{planID}/archive stops new purchases of the whole plan and forces visible = false, and POST /v1/panel/catalog/plan-versions/{planVersionID}/retire stops a single version being the one the catalogue offers. Neither touches an existing entitlement: a customer keeps what they bought.

Localise it

Both customer catalogue queries join the localisation for the requested locale rather than left-joining it. A plan with no copy in a locale does not render blank — it vanishes for customers using that language. That is why the create call above sends both ru and en. To change copy afterwards:
This route needs a panel session with catalog.write and a CSRF token rather than the operator bearer token, and answers 204. Any locale other than ru or en is 422. There is no localisation editor in the panel.

Confirm the plan is actually sellable

Before involving a payment provider, check the catalogue the way a customer’s screen does. The public route needs no credentials:
An entry appears only when all of the following hold:
  1. plans.visible is true and archived_at is null.
  2. A plan_localizations row exists for the requested locale.
  3. The plan has at least one version that is not retired — the highest such version wins.
  4. That version has at least one price row.
The customer surfaces add one more condition this route does not: the price must be in the settlement currency. This endpoint returns one entry per price row, so a plan priced in two currencies appears twice here and once in the bot. If the list is empty, work down that checklist rather than guessing. /admin/catalog shows the plan, its latest version number, and how many order lines already reference it.

Configure a payment provider

Payment adapters are built from environment variables at startup. manual and telegram_stars are always registered; the other two appear only when their credentials are present, and an empty credential leaves the adapter unregistered rather than registering a broken one. For a first sale, the fastest working rail is usually CryptoBot or Telegram Stars, because neither needs a public URL:
APP_CRYPTOBOT_TESTNET defaults to true. Invoices are created against the testnet host until you set it to false, which is exactly what you want for a test purchase and exactly what you must remember to change before taking real money.
YooKassa additionally requires APP_PUBLIC_URL, because a hosted checkout needs an absolute return link; the bot refuses to start with a shop ID configured and no public URL. Point your provider’s webhook at POST /v1/payments/webhooks/{provider} on the API host. That route is unauthenticated by necessity — authenticity comes from the adapter’s signature check over the exact bytes received, which is why a proxy that re-encodes JSON bodies breaks every provider webhook.
/admin/settings has a Payment providers card that stores an enabled flag, a display order, and sealed credentials per merchant. Those rows do not configure any adapter: the registry is built from environment variables only. The card’s connection test also writes a status value the table’s own constraint rejects. Configure providers in .env and restart, and read Payment providers for the whole picture.
Because manual is always registered, it is offered to every customer in both surfaces with no switch to hide it. On an installation that does not take bank transfers, expect to see it and to have to explain it.

Connect the bot

Restart the bot service. With no webhook URL configured the bot long-polls, which needs no inbound port and no TLS — the reason it is the default for a first installation. On start it publishes its command list to Telegram: nine commands for the default locale and eight for Russian. The bot answers only in private chats. Every screen carries subscription state, and a group chat is an audience, so /start, every command, every callback, and the operator /ops panel all return silently when the chat is not private. Send /start. You should get the main menu; /plans should list the plan you just published as a block with period, traffic, devices, and price, and a button labelled Pro · 499 RUB. If the list is empty, the empty state names the currency it looked for — go back to Confirm the plan is actually sellable. For webhook mode, the Mini App domain, and the operator group, see Telegram.

Make the test purchase

Use a Telegram account that has never talked to this bot, so you see what a first-time customer sees. A first-time visitor can browse and buy before any Remnawave user exists: the canonical Omniflow customer record is created on /start, and the Remnawave user is created by provisioning after the order is paid.
1

Open the plan and choose an action

/plans → the plan button. The plan screen shows the description, the period, traffic, devices, price, the upgrade, downgrade, and cancellation policies in words, and the grace period when it is non-zero. A policy of forbid removes the action entirely rather than failing after the customer taps it, so the buttons you see are exactly what the backend would accept: 🛒 Buy for a customer with nothing active, ♻️ Extend this plan for the same version, or a switch button for an upgrade or downgrade the policy permits.
2

Choose a payment method

Only configured adapters that can settle this plan’s currency are listed, each labelled with the price it would charge, because the same plan can cost a different number through a different provider. Choosing the method is what fixes the order currency. If nothing is listed, no adapter supports a currency the plan is priced in.
3

Review the summary

The checkout screen is the exact breakdown the order will be created from: subtotal, discount, add-ons, wallet, and total due. From here a customer can enter a promo code, toggle whether wallet credit is applied, add offered add-ons, save the cart for later, or change the method.
4

Pay

💳 Pay creates the order and immediately starts the payment. Two idempotency keys guard it: the checkout’s own key is reused for every order attempt, so a double tap resolves to one order, and the payment key is scoped to the order and the provider, so a customer who abandons one method and returns with another gets a second payment rather than a confusing reuse of the first. The confirm action is both replay-claimed and rate-limited to six per minute.For a hosted provider the status screen offers 💳 Open payment page. For Telegram Stars it offers ⭐ Pay in Telegram, which sends a real Telegram invoice; the bot approves the pre-checkout question only when the order belongs to that customer, the currency matches, the total equals the outstanding amount exactly, and the order is still pending or draft.
The customer web panel is the same flow with the same records: /account/store → a plan → /account/checkout → Confirm. A checkout opened in one surface is the checkout the other one shows.

Where a test purchase stops today

When the provider reports success, the webhook or the Stars update reaches RecordProviderPayment, which opens one transaction and does all of the following together: records the payment event, moves the order to paid, spends any wallet credit through the double-entry ledger, creates the entitlement, writes the fulfillment operation, inserts the order.paid outbox event, grants any referral reward, and enqueues the fulfillment job. That last insert is the problem. The job goes into River’s own tables, no committed migration creates them, and nothing in the stack runs River’s migrator — so the insert fails and the entire transaction rolls back. What you observe:
  • The provider has the money.
  • The order stays pending. No entitlement, no ledger movement, no Remnawave user.
  • The webhook is recorded on /admin/system → Webhooks with status failed.
  • worker is not running either; it exits at startup logging start River.
The same applies to an order fully covered by wallet credit, which settles at creation time through the same path, and to a free or trial plan, which owes nothing externally and therefore settles immediately. Everything before settlement is real and worth verifying: the catalogue, eligibility, the quote, the promo code, the wallet arithmetic, the order, and the provider handoff all work and are all recorded. Watch this page and Jobs and queues for the migration that closes it.

Where a completed sale lands

Once the queue exists, this is the trail one purchase leaves, and it is worth knowing which screen answers which question. The worker picks the job up on the critical queue and pushes the desired state to Remnawave. The Remnawave username is derived from the subscription UUID (omniflow_ plus sixteen hex characters), and the worker looks it up before creating anything, so a retried create after a lost response adopts the existing user instead of minting a second one. On success the earlier entitlements of that subscription are marked superseded and the order moves from paid to fulfilled. Provider failures retry with a backoff of five seconds doubling to a 1280-second ceiling, and after three failed attempts an operator notice is queued — once per operation, however many times it retries. Every operator action on a subscription afterwards goes through the same pipeline rather than writing to Remnawave directly, which is why those buttons report queued rather than done: a panel click that wrote to Remnawave itself would be the one change reconciliation could not explain.

Next steps

Catalogue and promotions

Trials, promo codes, personal offers, and the rules that decide which version a customer is priced against.

Payment providers

Per-provider setup, webhook verification, refunds, and what each adapter cannot do.

Fulfillment

Operations, idempotency keys, retries, drift detection, and scheduled reconciliation.

Orders and payments

The state machine, the payment classifications, and what each one means for an operator.

Telegram

Webhook mode, the Mini App domain, and the operator notification group.

Admin operations

The runbooks for refunds, stuck payments, and everything else that follows a sale.