KYC: levels, features and the application queue
Building verification levels and their 51 feature switches, the two settings that make those switches real, working the application queue, and proving a third-party verifier is wired up.
KYC is the part of the platform you are personally answerable for. It has two screens and one very large trap.
The screens are Users → Compliance & Verification → Verification Levels
(/admin/crm/kyc/level), where you define what a level asks for and what it
unlocks, and KYC Applications (/admin/crm/kyc/application), where you
approve or reject what customers send in.
The trap is that the per-level feature switches decide nothing until a second setting is turned on, and that setting is off by default on every install. Read What makes the feature switches real before you spend an afternoon curating them.
Permissions
| Screen or action | Keys |
|---|---|
| Verification Levels | access.kyc.level + view.kyc.level |
| Create a level | create.kyc.level |
| Edit / bulk status | edit.kyc.level |
| Delete a level | delete.kyc.level |
| KYC Applications | access.kyc.application + view.kyc.application |
| Approve / reject / notes | edit.kyc.application |
| Delete an application | delete.kyc.application |
| Verification services (read, connection and env checks, results) | view.kyc.verification |
| Run a verification | edit.kyc.verification |
/admin/crm/kyc/level/{id} is not in the URL permission map, so the page at
that address falls back to access.admin; the data behind it is still gated on
the keys above.
Verification levels
A level is a row in kyc_level with seven meaningful fields.
| Field | What it is |
|---|---|
name |
The label customers see — "Basic", "Identity", "Address". Required. |
description |
Free text explaining what this level requires. Required. |
level |
An integer giving the tier. This is the ordering, and it is what "highest approved level" means for a customer. Required. |
fields |
JSON. The form the applicant fills in. |
features |
JSON. Which platform features the level unlocks. |
serviceId |
Optional. The verification service that handles this level. Blank is stored as null; a service id that does not exist is refused with a 400 naming it. |
status |
ACTIVE, DRAFT or INACTIVE. Required; defaults to ACTIVE. |
DRAFT is how you build a level without exposing it. Bulk status changes go
through PUT /api/admin/crm/kyc/level/status.
The level builder
Creating or editing a level opens a full-screen builder — a control rail, a live form preview and a right-hand editor.
Full-screen means what it says: the admin nav is not drawn over the builder, so all four panes get the height they need. The way back is the arrow at the top left of the builder's own bar, beside the level name, which returns you to the level list.
The field library offers twelve field types: Text, Textarea, Dropdown, Checkboxes, Radio Buttons, Date, File Upload, Number, Email, Phone, Address and Identity Verification. Each placed field gets its own editor panel for label, help text, whether it is required, validation and — for the choice types — its options.
Five presets seed a level with a sensible field set rather than a blank page: Basic Verification (Tier 1), Identity Verification (Tier 2), Address Verification (Tier 3), Financial Verification (Tier 4) and Trading Experience (Tier 5). They are starting points; edit freely afterwards.
The Features panel is the other half of the builder, and it is described below.
Saving a level looks up every approved application pointing at it and drops that customer's cached profile, so the change is live for them immediately. That is the behaviour you want — but it means a level edit is not a quiet change on a busy platform.
The feature catalogue
The features array on a level is a list of feature ids. The catalogue is
defined twice, deliberately, and the two halves must agree: KYC_FEATURES in
backend/src/utils/kyc.ts is the server's copy, and platformFeatures in the
level builder is the admin's. An id that exists in only one of them fails
open — the membership test is simply never true — which is the worst
direction for a gate.
There are 51 feature ids, in fifteen groups. The builder's left rail carries sixteen buttons: the fifteen groups below plus All Features at the top, which is a filter rather than a group of its own. Its search box matches on name and description.
| Group | Feature ids |
|---|---|
| Trading | trade, binary_trading, view_forex, deposit_forex, withdraw_forex, trade_forex, create_forex_account, futures_trading |
| Wallet | view_wallets, deposit_wallet, withdraw_wallet, transfer_wallets, api_keys |
| Content | author_blog, comment_blog |
| E-commerce | view_ecommerce, order_ecommerce |
| Investment | invest_forex, invest_general, invest_ai |
| ICO | view_ico, purchase_ico, create_ico |
| P2P / affiliate | affiliate_mlm, withdraw_affiliate, make_p2p_offer, buy_p2p_offer |
| Staking | view_staking, invest_staking, withdraw_staking |
| Support | ask_faq, support_ticket |
| NFT | view_nft, create_nft, buy_nft, sell_nft, transfer_nft, deploy_nft_contract |
| Gateway | view_gateway, use_gateway |
| Copy trading | view_copy_trading, copy_traders, become_trader |
| Trading bot | view_trading_bot, trade_bot_live, buy_bot_strategy, become_bot_seller |
| Hummingbot | view_hb |
| Swap (DEX) | view_dex, swap_dex, swap_direct |
Each feature carries a recommended level (1 to 4) that the builder shows as
an L2+ badge and counts in the footer as "enabled above recommended level".
It is advice, not enforcement — the switch is yours.
Three pairs are worth deciding deliberately rather than in bulk:
view_*and the acting verb are separate on purpose.view_dexlets a customer see routes and prices;swap_dexlets them execute. Same for staking, ICO, NFT, copy trading and the bots.swap_directis a third id, not a duplicate ofswap_dex. On an operator-seeded liquidity pool you are the counterparty rather than an interface, which is exactly the thing you might want a higher level for.withdraw_stakingmust never sit aboveinvest_staking. Staked principal is locked until the end date and this is the only exit; setting the exit higher than the entrance traps customers' money.
Features are cumulative across approved levels. A customer's effective feature set is the union of the features on every level they hold an approved application for, and their effective level number is the highest of them. A customer does not lose a Tier 1 feature by being approved at Tier 2.
What makes the feature switches real
Two settings must both be on. Neither is redundant.
| Setting key | Where | Default |
|---|---|---|
kycStatus |
System → Platform Settings → Features → KYC Verification | on |
kycFeatureEnforcement |
System → Platform Settings → Features → Enforce KYC Feature Access | off |
kycStatus is already on out of the box, so on a fresh install the second
switch is the only one you have to find. Turning kycStatus off is the way to
take KYC out of the platform entirely: with it off there are no levels for
anyone to hold, so the enforcement check returns false without even reading
kycFeatureEnforcement.
With kycFeatureEnforcement off, the non-throwing check used by read routes
returns true for everybody, and the throwing gate returns without looking at
anything. Every switch you set in the level builder does precisely nothing.
kycFeatureEnforcement defaults off on purpose. The switches existed in the
admin UI long before anything on the server read them, so every live platform
has levels whose feature lists were never curated against real enforcement —
turning it on by default would have revoked access from working customers on
deploy.
Once kycFeatureEnforcement is on, a customer whose approved levels do not list
a feature is refused that action — including customers who used it yesterday.
Open the level builder and review every level, at every tier, before you
flip it. Both switches are Super-Admin-only saves.
Two behaviours are worth knowing because they decide what happens when something is broken:
- A settings read error fails open. A cache hiccup must not stand between a customer and their own money.
- An unreadable KYC table fails closed. If enforcement is on and the applications cannot be loaded, the customer is treated as unverified. You asked for verification; silently skipping it is the worse of the two failures.
Newer addons (the Swap/DEX gate is the reference) use a legacy-aware form of the check: while feature enforcement is off they still require any approved application, and hand over to the level builder's switch the moment you turn enforcement on. So switching enforcement on does not loosen anything.
The application queue
/admin/crm/kyc/application opens on the PENDING tab, sorted oldest
first — the applicant who has waited longest is the one to review next. The
tabs are the status filter: Pending, Additional Info Required, Approved,
Rejected. There is also a level filter, a manual/service filter and a search box
that matches application id, applicant name, applicant email and level name, all
of which run server-side.
The KPI cards above the queue come from
GET /api/admin/crm/kyc/application/analytics and take the same scope
parameters as the list (levelId, verification, search), so the figures
always describe the population you are looking at: total, pending, approved,
rejected, additional info required, completion rate and average processing time.
Opening an application gives four tabs — Details (the submitted form and its uploaded documents), Verify (the verification service, if the level has one), User (the applicant) and Tips.
The Verification Levels screen has its own analytics at
GET /api/admin/crm/kyc/level/analytics: total users, verified, pending,
rejected, and a per-level completion rate with the number of approved
customers, sorted by level.
One decision path
Every status change — yours from this screen, or one written by a verification
service — goes through a single function, applyKycDecision. That is what
makes the two routes behave identically, and it is what you can rely on having
happened after a decision:
-
The status is set to one of
PENDING,APPROVED,REJECTEDorADDITIONAL_INFO_REQUIRED, and your admin notes are saved with it. Notes are sanitised and capped at 5000 characters. -
reviewedAtis stamped with the moment of the decision. This is what the average-processing-time figure is computed from. -
The applicant's cached profile is dropped, but only when the status actually changed. Feature access is read from that cache, so an approval that skipped this step would leave the customer locked out of what you had just granted.
-
The applicant is emailed —
KycApproved,KycRejectedorKycUpdatefor "additional info required". APENDINGdecision sends nothing. A mail failure is logged and does not fail the decision. -
An entry is written to the applicant's own activity feed —
kyc.approved,kyc.rejectedorkyc.updated, naming the level and, when a service decided, saying so. Again, only on a real change: editing your notes without changing the status does not notify anybody.
Editing notes on a settled application is therefore safe. Changing the status is not — the customer hears about it immediately.
Verification services
Three services are seeded on a fresh install: SumSub (sumsub-1, a global
identity verification platform), DeepSeek (deepseek-1) and Gemini
(gemini-1.5-pro) — the last two being AI document verification. A level points
at one through its serviceId.
Their credentials live in the environment, not in the database, which is why
there are two separate checks: one that reads your .env and one that talks to
the vendor.
Returns success and a list of missingEnvVars. It never leaves the box.
| Service | Environment variables |
|---|---|
| SumSub | SUMSUB_API_KEY, SUMSUB_API_SECRET |
| Gemini | GEMINI_API_KEY |
| DeepSeek | DEEPSEEK_API_KEY |
This is the one that proves the integration. It signs and sends a real request —
a /resources/checks call for SumSub, a minimal generation call for Gemini and
DeepSeek — and returns connected plus a message that distinguishes a missing
credential from a rejected one, a rate limit and an outage.
Run check-env first, check-connection second. The env check tells you what you forgot to set; the connection check tells you whether what you set is right.
The body takes applicationId and an optional applyDecision.
applyDecision defaults to false. The service's verdict is written to
kyc_verification_result — status, score, structured checks and the document
verifications — and the application keeps its current status so a human can
read the verdict first.
Send applyDecision: true and the verdict is applied through the same path as a
manual review: VERIFIED becomes APPROVED, FAILED becomes REJECTED,
anything else is left PENDING. The applicant is emailed and their activity
feed records that a machine decided, naming the service.
This is where those recorded verdicts are read back.
Deleting a level
kyc_level is not soft-deleted — the row is gone. Applications are
associated to it with ON DELETE CASCADE, so every application at that level,
including approved ones, is deleted with it. There is no confirmation beyond the
dialog and no undo, and neither endpoint refuses a level that is in use.
Set the level to INACTIVE instead. That takes it out of circulation while the
approvals already granted keep their evidence.
If the cascade is missing — which happens after a schema re-sync — the applications survive but are orphaned from a level row that no longer exists. The platform tolerates this deliberately: an approved application with no level is still counted as approved, and its level number and features are recovered from what the application itself carries, rather than turning a verified customer into an unverified one. That is a repair behaviour, not a plan. Do not rely on it.
Related
- Settings reference — the Features tab, where both KYC switches live.
- Roles and permissions — how
edit.kyc.applicationand the rest are granted, and why a change needs a backend restart. - The admin panel — the Operations inbox, where the Verification queue carries a 7-day target.