Skip to main content
Two records carry this area, and keeping them apart is the whole design. An entitlement is Omniflow’s own statement of what one paid order bought: a plan version, a period, a traffic allowance, a device limit, and a squad set. A subscription is the stable, customer-visible handle for exactly one Remnawave user — a slot, a label, and a remote user identifier. One subscription accumulates entitlements over time as the customer renews; each entitlement belongs to exactly one order. By default a customer owns exactly one subscription, and every screen behaves as it did before concurrency existed. An operator who needs more — a household with separate device pools, a customer running two regions — turns it on explicitly.

Entitlements

An entitlement row carries what Omniflow owes, plus the last thing it observed on the other side: observed_state, remnawave_user_id, and reconciled_at. Keeping the two apart is what lets Omniflow tell what it wants from what it last saw. It exists as its own record because payment state, entitlement state, and Remnawave state fail independently. A successful payment has to survive a Remnawave outage: the money is recorded, the entitlement is written, and provisioning is retried afterwards. The schema enforces the rest — UNIQUE (order_id) means one order can never mint two entitlements, and a check requiring the end to be after the start means a zero-length or negative period is unrepresentable. Nobody creates an entitlement by hand. It is written by the settlement of a paid order, or by a gift claim, inside the same transaction that records the money. Everything except pending and superseded is written from the observed Remnawave status. superseded is written locally, after a replacement has actually been provisioned.
An entitlement’s stored status never becomes expired on a timer. Expiry is computed for display from ends_at, and the stored status only changes to expired when a reconciliation pass reads that back from Remnawave. A customer screen showing “expired” and a database row still reading active are the same fact.
Nothing ever deletes an entitlement. Retention deletes resolved drift rows and nothing else in this area.

The subscription model

One customer may own several Remnawave users, and a subscription is the stable handle for exactly one of them. Without a handle in between, Omniflow would have to guess which remote user an alert, a renewal, or a device removal was about. Entitlements, orders, notification deliveries, and auto-renew settings all carry a subscription_id. Wallet balance, promotion eligibility, referral attribution, and trial claims deliberately do not: those are properties of the customer, so opening a second subscription grants no second trial and no second referral reward.

What is per subscription and what is per customer

Trial abuse controls count customers, never subscriptions. trial_claims is keyed on the customer, so the second subscription of a customer who already used their trial cannot claim another one.

Labels

A label is trimmed, bounded to 40 runes, and rejected outright if it is empty or contains control characters — never silently truncated, because it is shown back to the customer verbatim. Customers rename with PATCH /v1/account/subscriptions/{subscriptionID}; the bot’s rename route does the same. An operator renames with PATCH /v1/panel/customers/{customerID}/subscriptions/{subscriptionID} (subscriptions.write). Both accept the label and nothing else.

Concurrency

APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER defaults to 3 and is clamped to at least 1. While APP_MULTI_SUBSCRIPTION_ENABLED is false, the effective ceiling is one whatever the maximum says. A plan may set a tighter ceiling of its own through plans.max_concurrent_per_customer, which narrows the installation limit and can never widen it. Both are checked at purchase time, so a plan configured above the installation limit is bounded by it rather than overriding it. See Catalog and promotions for where that cap is edited. A customer at either ceiling gets a stable refusal reason, checked in this order: The limit is enforced where the money is, not in the interface. The count is taken under a transaction-scoped advisory lock on the customer, so two simultaneous purchases cannot both observe a stale count and both pass. Only a purchase may open a new subscription. A renewal, upgrade, or downgrade always names an existing one. The web checkout refuses to guess: with concurrency on and more than one subscription held, opening one of those operations without a subscriptionId is refused rather than defaulted, because clearing a subscription target by accident is how a renewal silently becomes a second subscription. The subscription row is written when the order for it is created, before any money moves, so it can be targeted by the payment and counted against the ceiling while the checkout is open. If that order then closes unpaid — cancelled by the customer or an operator, or expired by the sweep — the row is removed again, provided nothing else ever used it: no entitlement, no other live order, no Remnawave user, no auto-renew setting, notification, cart, checkout session, or dunning attempt. The closed order is detached from it first. An operator’s cancel does this in the same transaction; the expiry sweep does it for every customer whose order it closed; a customer’s cancel on either surface calls the same cleanup after it commits. A subscription that was ever provisioned is never removed by this — that is the operator procedure under Reducing a customer to one subscription. Money can still arrive for a detached order. A manual transfer is often confirmed after its window, and a checkout left open in another tab can be paid after a cancel. Settlement then opens a fresh subscription in place of the one that was removed — under the same advisory lock and concurrency ceiling as a purchase — writes it onto the order, and builds the entitlement there, inside the settling transaction. It never falls back to another subscription the customer holds: a thirty-day second subscription confirmed late used to land on the primary one and overwrite months of remaining time. When the ceiling will not allow another subscription, the payment is classified subscription_unavailable and does not settle; an operator decides. For the same reason the fulfillment worker never adopts an entitlement onto a subscription other than its own and its order’s: one whose subscription cannot be established fails permanently with subscription_unresolved instead of being provisioned onto the primary.
/admin has a concurrent-subscription screen at PUT /v1/panel/settings/commerce/subscriptions (settings.write), and it does not govern enforcement. It writes commerce_settings, while the policy actually checked at purchase comes from APP_MULTI_SUBSCRIPTION_ENABLED and APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER, read once at process start.The stored row is read in exactly one place: it decides whether the customer dashboard shows a subscription switcher. Changing the limit means changing the environment and restarting the API, the bot, and the worker.

What changes for the customer

With concurrency on:
  • the main menu opens a subscription switcher instead of the single subscription screen;
  • every purchase, renewal, upgrade, downgrade, and add-on names its target subscription explicitly;
  • device management, link rotation, and connection instructions act on the selected subscription’s Remnawave user;
  • expiry, traffic, renewal, grace, recovery, and fulfillment notices name the subscription they are about.
With it off, none of that appears. A customer with one subscription sees no extra selection step, and alerts carry no subscription label, because naming the only one would be noise. The switcher on the web dashboard follows the operator’s stored setting but is forced on whenever the customer holds more than one subscription — hiding subscriptions they own would strand one of them. In the bot, a subscription screen offers rename, connect, devices, renew, link rotation, and add-ons. In the browser, /account lists the cards, /account/subscriptions/{id} shows one with its traffic meter, rename form, and rotation control, and /account/devices shows devices with a switcher when there is more than one. The customer web panel and The Telegram bot cover both surfaces in full.

Alert isolation

Notification deduplication is keyed on (user_id, kind, subscription_id, dedupe_key) with nulls not distinct. Without the subscription in the key, two subscriptions expiring on the same day would collide on one dedupe key and the customer would be told about only one of them. A busy subscription can never suppress a quiet one’s notice.

Fulfillment

Each subscription provisions its own Remnawave user, derived deterministically from the subscription identifier:
An installation upgraded from v0.4 keeps the customer-derived name for slot 1. The worker looks for the subscription-derived name first and the legacy customer-derived name second, so an existing Remnawave user is adopted rather than duplicated. A slot above 1 with no mapping never falls back to the legacy name, so a second subscription can never be confused with the first one’s remote user. The customer-level remnawave_users mapping continues to track the primary subscription (slot 1). That is what keeps the pre-commerce self-service screens resolving unchanged. Drift detection, reconciliation, and superseding are all scoped to one subscription. Buying a second subscription never retires the first one’s entitlement. Fulfillment has the operation pipeline, the retry policy, and what a failure looks like.

Changing a subscription: the arithmetic

An order’s operation plus the plan version’s policies decide three instants — when the entitlement starts, when it ends, and when provisioning should actually run. Let base be the current entitlement’s end when that is still in the future, and now otherwise. There is no proration anywhere in this table. An upgrade charges the new plan’s full catalogue price; replace discards the remaining time and extend keeps it. Omniflow never guesses what a mid-period plan change is worth, which is why the choice is a per-version policy an operator sets rather than a formula. An at_expiry downgrade is what the deferred provisioning instant exists for: the fulfillment job snoozes until that moment rather than occupying a worker, and the previous entitlement is superseded only after the replacement has actually been provisioned. Until then both surfaces keep showing the plan in force: every customer-facing read prefers the entitlement whose start has passed over one scheduled for later, so a deferred downgrade does not appear as “being set up” for the rest of the period it has not yet begun. extension and renewal do identical arithmetic and remain separate operations because the reason differs — one the customer asked for, one an automatic charge produced — and support has to be able to tell them apart. A forbidden operation is unreachable rather than merely unoffered. The lifecycle rule lives in internal/commerce and is enforced inside order creation against the subscription the order resolves to, so every caller — the web checkout, the bot, the bearer-token API, a saved cart — meets the same refusal, HTTP 422 operation_forbidden:
  • a purchase is refused on a subscription whose entitlement is still in force (a paused one counts as in force) — it would schedule from now and throw the remaining time away; with no subscription named, a purchase that would default onto a live primary is refused rather than guessed;
  • an extension must be of the plan the subscription holds; extending a lapsed subscription buys a fresh period from now and is allowed;
  • an upgrade must go to a different plan priced at or above the held one, a downgrade to one priced below it, and both need a live plan to move from;
  • the plan version’s policy refuses forbid as before.
A renewal is the renewal worker’s own operation, opened from the customer’s standing consent for the subscription it names, and is not reclassified. A duration of zero or less is refused outright.

What a customer is offered

GET /v1/account/plans returns an operations array per plan, computed on the server. A customer with no subscriptions is offered purchase. A customer with one is offered purchase when the concurrency ceiling still allows it, plus extension, plus upgrade and downgrade wherever the policy is not forbid. The panel renders buttons; it does not decide policy — a panel that decided for itself which buttons to render would be a second implementation of the rules, and the two would eventually disagree in the customer’s favour or against it, both of which are bugs. The array is commerce.OfferedOperations, the same rule order creation enforces, so everything offered is accepted.

Cancellation

cancellation_policy is stored on the plan version, snapshotted onto the order line, and shown, and nothing acts on it. There is no cancel operation in the order model, no cancel route, and no cancel control on any surface. A subscription ends by reaching its end date without a renewal. An operator who needs to stop access before then disables the subscription through the fulfillment pipeline — POST /v1/panel/customers/{customerID}/subscriptions/{subscriptionID}/operations with disable, a reason, and subscriptions.write — which is a Remnawave state change, not a cancellation, and does not refund anything. Money comes back through the refund path in Orders and payments.

Phases: what a customer is told

One function reduces an entitlement plus the observed Remnawave traffic into a single phase, so the bot and the browser cannot describe the same subscription differently: The 7-day window matches the earliest renewal reminder, so the bot never shows “active” while it is already asking the customer to renew. Days left are computed on the server and rounded up, because a subscription with eight hours left has a day left from the customer’s point of view. Traffic percentage is clamped to 0–100 so an overage renders as a full bar, and “unlimited” is carried explicitly rather than inferred from a zero limit — a progress bar with no ceiling is not a bar at zero per cent.

Grace periods

plan_versions.grace_period_seconds defaults to 0, and zero means expiry is immediate. There is no implicit grace anywhere. It is set per plan version in the version editor, entered in whole days, and exists so that expiry copy is not guesswork: it keeps access alive after the end so the customer can be told exactly how long they have left to renew. Grace is carried to the customer as gracePeriodSeconds on the plan payloads, and the phase ladder reports grace for as long as it lasts. It is also carried to Remnawave, which is what makes the promise real. The entitlement’s ends_at stays the paid end — renewal arithmetic, reminders, and reporting read it — but the expiry the fulfillment worker pushes to Remnawave is ends_at plus the plan version’s grace, for every operation: the first create, an extension, a resume after a pause, and the reconciliation sweep. Drift is measured against that same sum, so a remote expiry that carries the grace is agreement rather than a expiry drift. Because Remnawave then keeps the user alive through the window, a reported EXPIRED status means the window is truly over, and the phase ladder trusts it over the clock: an entitlement whose remote user is expired is expired, never grace.

Pausing

Cancellation and refund both end a subscription. Neither covers the case people actually ask for — “I am away for six weeks, stop the clock” — and the workaround an operator otherwise reaches for is to disable the Remnawave user, which stops access and does not stop the clock. The customer comes back to a subscription that expired while they could not use it. A pause is therefore two facts, not one. Access stops. The Remnawave user is disabled, through the ordinary fulfillment pipeline, so it carries the same idempotency key, retry policy, and history as any other change. The clock stops. entitlements.paused_at records when, and every remaining-time figure on both customer surfaces is measured from that instant instead of from now. A paused subscription that counted down to zero on the customer’s own screen would be the feature contradicting itself in the one place the customer looks. Resuming moves ends_at forward by exactly the elapsed pause and records the same amount in paused_seconds — so an entitlement whose expiry sits later than its order paid for is explained by its own columns rather than looking like a mistake.

What holds it together

Resume is one fulfillment operation rather than two, and the order is why: the new expiry is pushed before the user is re-enabled, so the user is never briefly enabled carrying an expiry that has since passed.

Buying a change while paused

A paused entitlement is still the subscription’s live entitlement, and a change bought while it is paused — an extension, a gift or access code that extends, an upgrade or downgrade — builds on it and retires it, exactly as it would a running one. What it builds on is the time the pause was preserving: the new entitlement’s base is now plus ends_at − paused_at, so the customer gets back precisely the days a resume would have given them, and the bought period is added on top. Provisioning the new entitlement supersedes the paused one and closes its pause in the same write — paused_at is cleared and the elapsed pause is added to paused_seconds — so a later resume of the old row is refused and the days cannot be handed out twice. The paid change resumes the subscription: the new entitlement is enabled in Remnawave like any other paid change. An at_expiry downgrade is the one change that does not take effect now — it waits for the current period to end. Scheduled behind a paused entitlement it would start at now plus the frozen remainder while the subscription stayed disabled, and the preserved days would run out with the customer locked out. So settling it resumes the paused entitlement first, in the same transaction and exactly as an operator’s resume would — ends_at moves forward by the elapsed pause and a resume operation keyed order:{orderID}:resume re-enables the user — and the downgrade starts when that resumed period ends. Automatic renewal never fires while paused; it selects active and limited entitlements only. An add-on is refused while paused with no_active_subscription, because it is priced against the time the current period has left and a stopped clock has no remaining period to price against.

Reconciliation knows about it

A paused subscription and a disabled one are the same thing from Remnawave’s side, and only Omniflow knows the difference. Two cases are therefore treated as agreement rather than drift:
  • The remote user is disabled — what a pause looks like the moment after it is applied.
  • The remote user is expired — what it looks like a week later, once real time has walked past the frozen expiry. Remnawave knows nothing about the pause. Treating this as the entitlement expiring would take away the days the pause exists to preserve, a week after the customer was told they were safe.
A paused entitlement whose remote user is active is real drift: somebody re-enabled it in Remnawave and the customer is connecting on time nobody is counting. That is reported.

There is no customer-facing pause

Deliberately. A pause turns a dated entitlement into an indefinite one, and letting the person holding it decide when the clock runs would make “thirty days” mean “thirty days of my choosing, forever”. An operator pausing on request is a support action with a reason attached, which the audit entry records. What the customer does get is an honest state: the bot and the panel both say the subscription is paused and that the remaining days are kept, rather than “disabled — contact support”, which would send somebody to a ticket queue to be told nothing is wrong. Automatic renewal skips a paused subscription without any extra rule: the renewal query already selects only active and limited entitlements.

Reminders, grace notices, and recovery

The bot’s notifier walks every subscription a customer owns and sends, per subscription: An expired subscription nobody is told about is a lost customer, and a recoverable one is worth a one-tap path back. The recovery message carries a button that opens a purchase of the same plan version, plus links to the catalogue and the menu. APP_RECOVERY_WINDOW_HOURS sets how long that offer stays open; the default is 14 days, which .env.example ships as 336. Marketing volume caps do not apply to these — lifecycle notices go through the customer’s own notification preferences and quiet hours instead. See Notifications. Recovery is Telegram-only. There is no equivalent nudge in the customer web panel. A customer imported before subscriptions existed, holding only a customer-level Remnawave mapping, still gets alerts: the notifier falls back to a summary carrying just the remote identifier. Subscription labels are added to alerts only when the customer holds more than one.

Automatic renewal

Auto-renew is consent to be charged for the next period without being present, and it is off unless the customer turned it on. It is configured in the Telegram bot only — there is no auto-renew endpoint or screen in the customer web panel or in /admin. Recurring payments covers the consent record, the funding sources, the lead time, the dunning schedule, and the current state of the write path.

Turning concurrency on and off

Turning it on

Nothing has to be migrated. The migration that introduced subscriptions already created one per existing customer, adopting the Remnawave user they had. Flipping APP_MULTI_SUBSCRIPTION_ENABLED to true only changes what customers may add from that point on.

Turning it off

Setting it back to false is safe and non-destructive:
  • existing extra subscriptions keep working, keep renewing, and keep their Remnawave users;
  • no customer may open another one;
  • a customer who already has more than one still sees the switcher, because hiding subscriptions they own would be worse than showing them.
Nothing is orphaned in either direction: the switch controls creation, never existing state.

Reducing a customer to one subscription

There is no way to close a subscription. The status column allows closed and a query exists to set it, but nothing calls it: no route, no panel control, and no bot action. A subscription that has been opened stays on the customer’s record.
What an operator can do instead, from /admin/customers/{customerId} with subscriptions.write and a reason:
1

Let it lapse, or disable it

Let the extra subscription reach its end date without a renewal, or queue a disable operation against it so its Remnawave user stops working immediately.
2

Confirm nothing is pending

Check the subscription’s entitlement is expired or superseded and that no fulfillment operation is still pending or retrying under /admin → System → Jobs.
3

Leave it in place

The subscription row remains, with no live entitlement. It occupies a concurrency slot, so a customer at the ceiling who wants a different plan needs the ceiling raised rather than the old subscription removed.
Disabling a subscription does not delete its Remnawave user, and neither does anything else in Omniflow. Removing that is a deliberate action in the Remnawave panel, so nothing here destroys access a customer paid for.
Suspending a customer does not reach Remnawave either. Suspension records the status, a lifecycle event, and an audit row; no part of the fulfillment pipeline reads customer status. A suspended customer keeps working access until their period ends — the one thing suspension does stop is an automatic renewal charge. Disable the subscription as well if access is meant to stop now.

Catalog and promotions

Where duration, grace, squads, and the upgrade and downgrade policies are authored.

Fulfillment

The operation pipeline, reconciliation, drift, and operator-requested changes.

Add-ons and the configurator

Extra traffic, device slots, and squads on top of a live entitlement.

Recurring payments

Auto-renew consent, funding sources, retries, and failed-charge review.