There is exactly one import source. The table constrains it with
CHECK (source = 'remnawave') and the reader is the Remnawave list endpoint. There is no CSV
import, no file upload, and no second provider. The CSV described at the end of this page is an
export only.What an import produces
For each applied record, exactly two rows: ausers row with status active, and a
remnawave_users mapping carrying the Remnawave user ID, the Telegram ID if there was one, an
observed status, and a reconciliation timestamp.
It does not produce a subscription, an entitlement, an order, or a verified identity. Omniflow
does not know what those customers paid, when, or for what — Remnawave does not record it — and
inventing an entitlement would put a period and a price into the ledger that nobody agreed to. See
Data ownership for the boundary this respects.
What the mapping buys is recognition. When an imported customer next messages the bot, the customer
resolution path looks for a Telegram identity first, and on finding none falls back to the Remnawave
mapping keyed on their Telegram ID. It adopts that customer and links the Telegram identity to them
rather than creating a second customer. That is the whole reason the preview treats an already-linked
Telegram ID as a conflict: two customers holding one Telegram identity would make that fallback
ambiguous, and the ambiguity would surface as somebody’s subscription appearing on somebody else’s
account.
Phase one: preview
Preview reads one page of Remnawave users and stages them. It is safe to run repeatedly and writes nothing outside the import tables. Each candidate is staged with four fields only —remnawaveId, username, status, telegramId.
The staged payload is what the conflict checks read and what an operator’s counters are computed
from; nothing else from the Remnawave record is copied into Omniflow.
How matching works
Before staging a page, the service builds the set of identities that already exist, from three sources:- Every
remnawave_idandtelegram_idinremnawave_users— customers Omniflow already knows. - Every active
identitiesrow withprovider = 'telegram'— customers who signed in through the bot or the web panel and have no Remnawave mapping yet. - Every Telegram ID already staged in this run whose source ID is not on the current page.
Validation codes
The item’s status follows from the codes: none means
valid; any code means conflict; a code
beginning missing_ or invalid_ makes it invalid instead. The distinction is the point of the
counters — a conflict is a record that belongs to somebody, an invalid record is one that cannot be
read at all, and the two need different decisions from an operator.
Every item also stores a SHA-256 fingerprint of its staged payload, so a re-preview can tell a
changed record from an unchanged one. Re-previewing never rewrites an item that has already been
applied or skipped: the upsert carries
WHERE customer_import_items.status NOT IN ('applied', 'skipped'), which is what makes running
preview again on a partially applied run harmless.
Cursor and run status
The cursor is the numeric offset into the Remnawave list, stored as text, starting at zero. Each pass advances it by the number of users the page returned and sets the run toready once the cursor
reaches the reported total; until then it stays previewing.
pageSize is clamped: anything at or below zero, or above 500, becomes 100.
If Remnawave cannot be reached, the run is not left half-finished and no error is thrown away. It is
written to status failed with error_summary set to {"code":"remnawave_unavailable"} and
returned as it stands. Because the resume path only short-circuits on completed and cancelled, a
failed run can be resumed from its stored cursor once the panel is back.
Phase two: apply
Apply selects up tolimit items with status valid, ordered by source ID, and commits each in its
own Serializable transaction. The limit is clamped the same way pageSize is.
Each item is one CTE chain in one statement: lock the item FOR UPDATE on status = 'valid', insert
the customer, insert the Remnawave mapping, mark the item applied with its new customer ID and
timestamp. Because it is one statement in one transaction, a failure creates no partial customer and
no orphan mapping. A concurrent second run cannot apply the same item twice, because the lock only
matches an item still in valid.
Conflicting and invalid items are never selected, so they are never applied. After the batch, if no
valid item remains the run becomes completed and completed_at is stamped; otherwise it stays
applying and the next call continues.
Two further things to expect:
- Imported customers are always created with locale
ru. The locale is hard-coded in the apply path regardless of anything in the Remnawave record. A customer can change it themselves in the bot or the web panel afterwards. - Imports write no audit event. The import tables are the record of what happened; the audit trail described in Admin operations does not cover them.
Running an import
The preview, resume, and apply routes are on the operator-token surface. There is no panel control that starts an import —/admin/customers lists runs and offers the export, and that is all it does.
1
Confirm the panel credentials
The worker and API read
APP_REMNAWAVE_URL and APP_REMNAWAVE_TOKEN; the import reads the same
panel through the same adapter. APP_OPERATOR_TOKEN authorises /v1/admin.2
Open a run
{id, status, cursor, totalCount, validCount, conflictCount, invalidCount, startedAt, updatedAt, completedAt}.3
Page until it is ready
status is ready. Each call reads one page and advances the cursor.4
Read the counters before writing anything
validCount, conflictCount, and invalidCount are the whole reason the preview phase exists.
A large conflict count usually means the installation already knows those customers, which is a
reason to stop rather than a reason to continue.5
Apply
status is completed.
Refusals to expect:
- Applying a run that is
previewing,completed,failed, orcancelledreturns422 import_failedwith the detail “import is not ready”. - A malformed import identifier returns
422 import_failedwith “invalid import ID”. - Unlike the panel routes, which map every failure onto a fixed message, these three routes place the
raw error text in the problem document’s
detail. Treat the response as operator-facing.
Watching a run in the panel
Open/admin/customers and read the Imports card. It polls GET /v1/panel/imports every five
seconds, because a run in progress moves while an operator watches it, and shows per run:
- the first eight characters of the run ID and a status badge;
- a resumable badge whenever a cursor is stored — a run interrupted at record forty thousand does not start again at one;
- the four counters: total, valid, conflicts, invalid;
- the start time and, once finished, the completion time;
- an error summary, rendered as class-and-count pairs. An operator needs to know that 812 records had no Telegram identifier, not to scroll past 812 identical lines.
The error summary is only ever empty or the single entry
{"code":"remnawave_unavailable"}. The
card is built to render per-code counts, but the import service never populates them, so a failed
run shows one pair naming the panel failure rather than a breakdown by validation code. The
breakdown you can rely on is the three counters.Customer CSV export
Beside the import history is an Export button, which is a plain link toGET /v1/panel/customers/export and needs customers.read. It streams a CSV of the customer list,
optionally filtered with ?status=.
The column set is the point. An export is a file that leaves the installation, so it carries the
identifiers an operator may safely be given and the facts they need to reconcile against another
system — and deliberately no subscription link, no payment token, and no hardware identifier.
Contact values do not appear either; they are encrypted at rest and are not part of this file.
The handler walks the cursor server-side at 200 rows a page and flushes as it goes, so an export over
a large installation is never held in memory. It stops after 200 pages, for the same reason the audit
and finance exports do: one request must not be able to walk an unbounded table forever. An
installation past forty thousand customers should expect the file to end there.
Merging two customer accounts
Linking an identity to the account you are already signed in to works. Joining two accounts that both exist is a different problem, and it is the one that actually occurs: a customer buys in Telegram, later signs in on the web through a provider the first account never carried, and holds an empty second account with their subscription on the other one./admin/customers/{id} has it, below the tabs. The account you are looking at
is the one being absorbed — that direction is fixed and stated, because “merge
A and B” is ambiguous in exactly the way that matters.
Preview first
A merge cannot be undone, so everything that would move is counted before anything does: subscriptions, orders, sign-in methods, tickets, referrals, and the wallet balance per currency, on both sides. The blockers are recomputed when the merge is applied rather than trusted from the preview. The two are separate requests, and a merge authorised by a stale screen is the kind of thing that only goes wrong once.What is not a plain reassignment
Forty-six tables referenceusers. Most are a change of owner. Five are a
decision, and they are why this is not a loop over the foreign-key catalogue:
- Subscription slots are unique per customer, so moved subscriptions are renumbered onto the first free slots on the target.
- The wallet moves as a compensating pair of ledger entries — the source
debited to zero, the target credited the same — because
ledger_entriesis append-only. Rewritinguser_idon a customer’s financial record is not available and should not be: those entries are the evidence for every balance the installation has ever reported. - A saved cart is cancelled rather than moved. Only one cart may be open per customer, and a cart is a selection made in a session that is now ending.
- Read markers move only where the target has not read the same post; the pair is a primary key, and the whole merge should not fail over a read receipt.
- Saved payment methods move but arrive as non-default. One default per customer is a unique index, and the survivor’s is the method they last chose.
After it happens
The source keeps its rows and its history and is markedmerged, pointing at
the target. It is not deleted: the record of what happened is worth more than
the row is worth reclaiming, and support answering “where did my subscription
go” needs somewhere to land.
Both accounts get a lifecycle event — merged_away and merged_in — so the
merge is readable from either customer’s own history rather than only from the
operator audit trail. The audit event is customer.merged and carries the
counts.
A reason is required. It goes into the audit trail and into both histories,
because “why were these two accounts joined” is the first question anybody asks
about one afterwards.
Related pages
Remnawave integration
The adapter the import reads through, and every call it makes.
Customer privacy
What is stored about a customer, what they can export, and deletion.
Admin operations
Bulk actions, audit trail, and the rest of the customer surface.
Fulfillment
How an imported customer’s first purchase becomes an entitlement.