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.
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.
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.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 bothru and en.
To change copy afterwards:
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:plans.visibleis true andarchived_atis null.- A
plan_localizationsrow exists for the requested locale. - The plan has at least one version that is not retired — the highest such version wins.
- That version has at least one price row.
/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_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.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
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./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 reachesRecordProviderPayment, 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 statusfailed. workeris not running either; it exits at startup loggingstart River.
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.