Section 1
What it is
Onboarding is the machine that turns an email address into a merchant that can take money. It is invite-driven end to end: nobody self-registers on Ummah. A staff member or a marketplace Client creates the invite, the invitee proves who they are to Sumsub, and a worker then builds the merchant's entire Adyen identity before the account goes live.
Three design decisions shape everything on this page. First, identity checks are Sumsub's job, never Adyen's — the merchant completes KYC in an embedded Sumsub WebSDK iframe, and Ummah replays the verified data into Adyen's Legal Entity Management API afterwards; Adyen hosted onboarding is not used anywhere. Second, every merchant is a full Adyen citizen — a sub-merchant gets its own legal entity, account holder, balance account and transfer instrument, exactly like a top-level Client. Third, pricing is decided before the merchant exists: the staff invite refuses to create a merchant without a defaultSplitProfileId, because economics are Ummah's to set — the full pricing model lives in Pricings, Splits & Fees.
The whole journey is one status column: Merchant.status, a thirteen-value MerchantStatus enum whose transitions are owned by an explicit state machine. Everything else — queues, webhooks, emails, retry rules — exists to push a row along this line.
The merchant status state machine
FIG 1 · state machine
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
INV["INVITED"] -->|"open"| OPN["OPENED"]
OPN -->|"verify code"| VER["VERIFIED"]
VER -->|"sumsub-token"| INP["IN_PROGRESS"]
INP -->|"Sumsub GREEN"| SSR["SUMSUB_REVIEWED"]
INP -->|"resubmission asked"| RSR["RESUBMISSION_REQUESTED"]
INP -->|"Sumsub RED"| FLD["FAILED"]
SSR -->|"provisionMerchant"| ADP["ADYEN_PROVISIONING"]
ADP -->|"account holder valid"| ACT["ACTIVE"]
ADP -->|"Adyen 4xx"| FLD
INV -.->|"24 h code TTL"| EXP["EXPIRED"]
EXP -.->|"resend-invite"| INV
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class INV,OPN,VER,INP plain; class SSR ummah; class ADP adyen; class ACT sub; class RSR,EXP warn; class FLD danger;
INACTIVE (the /v1 API allows reads only), SUSPENDED (the whole /v1 surface answers 403 merchant_suspended) and DISABLED. The two amber states are the recoverable pauses — both accept a resend-invite; SUMSUB_REVIEWED deliberately does not, because a provisioning job is already in flight.Merchant
One table, two tiers
There is no separate SubMerchant model. Merchant.tier is CLIENT or SUBMERCHANT with a parentClientId self-relation; both tiers onboard through the same pipeline. The invite lives on the row itself: a bcrypt verificationCodeHash and an expiresAt TTL.
Sumsub applicant
The identity check
Created lazily the moment the merchant asks for a WebSDK token. The named Sumsub level decides which questionnaire runs — the company level for the merchant, ummah-adyen-individual-uk-v1 by default for each beneficiary. Review results arrive as HMAC-verified webhooks.
Adyen identity quartet
The provisioned account
Every merchant ends up holding four Adyen ids: adyenLegalEntityId, adyenAccountHolderId, adyenBalanceAccountId and adyenTransferInstrumentId — minted by the worker from Sumsub data, never typed by anyone.
Section 2
The vocabulary
Nine terms carry this whole feature. Everything else on the page is composition.
| Term | What it is | Where it lives |
|---|---|---|
| Magic link + 6-digit code | The invite email carries a link to the hosted onboarding page and a 6-digit code. Only the bcrypt hash (verificationCodeHash) is stored, with a 24 h expiresAt; entering the code mints the onboarding session. | schema.prisma Merchant · onboarding.controller.ts |
MerchantStatus | The thirteen-value enum of FIG 1: nine pipeline states plus INACTIVE, SUSPENDED, EXPIRED, DISABLED. All transitions run through the onboarding state machine. | schema.prisma:66-80 · onboarding.state-machine.ts:34-55 |
defaultSplitProfileId | The merchant's admin-chosen pricing template, fixed at invite time — merchants never choose their own split. Economics on Pricings, Splits & Fees. | schema.prisma:446-453 |
| Sumsub level | A named KYC questionnaire configuration. The invite validates the level against Sumsub's live catalogue; the portal door falls back to the first company level when the caller has none (422 if the catalogue has none). | submerchant.service.ts · portal-submerchant.service.ts |
Ubo | A beneficiary row. Each UBO verifies through a JWT link and gets an Adyen legal entity of their own (Ubo.adyenLegalEntityId). | schema.prisma:500-517 · onboarding.controller.ts:112-137 |
adyen-provisioning | The BullMQ queue that carries the provisionMerchant job; jobId provision-${merchantId} so duplicate Sumsub webhooks dedupe into one run. | queue.constants.ts:5-44 · adyen-provisioning.processor.ts |
ensureDefaultStore | The step that fires on ACTIVE: creates the merchant's default store, reusing the onboarding-time balance account and pushing the split configuration to Adyen. Stores themselves are covered in Merchants & Stores. | stores.service.ts:611-655 |
MerchantOwner | The MERCHANT-scope system role stamped on the invited owner User (created with status invited). System roles resolve to all permissions of their scope at token time. | prisma/seed.ts:29-59 · auth.service.ts:72-158 |
isMarketplace vs canCreateSubMerchants | Two different marketplace gates: the first (admin toggle, one-way once subs exist) guards the Client-API door; the second (admin invite flag) guards the portal door. They are not kept in sync — see gaps. | schema.prisma:390-399, 459 |
Section 3
The three invite doors
Every merchant is born through one of three doors, and the doors do not behave identically. Two different services answer them, with different guards and — critically — different split-profile behaviour.
Door 1 — staff invite
POST api/admin/merchants/invites is the canonical door for top-level Clients. It runs AdminMerchantsService.createInvite, which refuses to create a merchant without a defaultSplitProfileId (422 split_profile_required) — the one exception being a sub-merchant invite, which may inherit the parent Client's default. Staff can also invite sub-merchants under any Client via POST api/admin/clients/:clientId/submerchants/invites (@RequireScope('STAFF') + merchant.write), but that route runs the other service, SubMerchantService.inviteSubMerchant.
Door 2 — Client API invite
A marketplace Client invites its own subs with POST api/clients/:clientId/submerchants/invites (@RequireScope('MERCHANT') + merchant.write). The controller enforces assertSelf — the path :clientId must equal the bearer's own merchant id, else 403 not_your_marketplace — and the service requires isMarketplace=true (403 client_not_marketplace). Marketplace mode itself is a staff toggle, PATCH api/admin/clients/:clientId/marketplace: only tier === CLIENT rows qualify (400 not_a_client) and it is one-way once subs exist (400 marketplace_locked). The service validates the Sumsub level, then in one transaction creates the tier: SUBMERCHANT Merchant row, the owner User with the MerchantOwner role, enqueues the invite email and writes a submerchant.invited audit row. The invite link carries &clientId= so the onboarding page can dress itself in the Client's brand.
Door 3 — portal invite
POST api/portal/sub-merchants is the direct-merchant door. PortalSubMerchantsService.invite refuses sub-merchants themselves (403 — no nesting) and callers without canCreateSubMerchants (403). The sub's pricing is resolved here and never left empty: a supplied splitProfileId must be one of the caller's own active profiles (else 403), and when omitted it falls back to the caller's defaultSplitProfileId — so a portal-invited sub is never unsplit. It then delegates to AdminMerchantsService.createInvite(dto, actor, reqId, parentClientId), which stamps tier=SUBMERCHANT, forces canCreateSubMerchants=false and subMerchantCommission*=0 on the sub row, and finally flips the caller's isMarketplace to true so marketplace-gated screens recognise the new parent.
Three doors, two services
FIG 2 · invite paths
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart TB
A1["Staff invite
POST api/admin/merchants/invites"]
A2["Staff sub-invite
POST api/admin/clients/:clientId/
submerchants/invites"]
CL["Client API invite
POST api/clients/:clientId/
submerchants/invites"]
PT["Portal invite
POST api/portal/sub-merchants"]
SVC1["AdminMerchantsService.createInvite
defaultSplitProfileId required or inherited
422 split_profile_required"]
SVC2["SubMerchantService.inviteSubMerchant
no defaultSplitProfileId stamped"]
ROW["Merchant row INVITED
owner User + coded invite email"]
A1 --> SVC1
PT -->|"canCreateSubMerchants gate"| SVC1
A2 --> SVC2
CL -->|"assertSelf + isMarketplace gate"| SVC2
SVC1 --> ROW
SVC2 --> ROW
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class A1,A2 ummah; class CL,PT client; class SVC1 plain; class SVC2 warn; class ROW sub;
SubMerchantService doors — including the staff one — create the SUBMERCHANT row without a defaultSplitProfileId, so the resulting merchant can finish onboarding yet be unable to transact. Only createInvite enforces pricing at birth.| Door | Guard chain | Service | Split profile | Notable side effects |
|---|---|---|---|---|
Staffapi/admin/merchants/invites | STAFF scope | createInvite | Mandatory — 422 split_profile_required; sub-invites may inherit the parent's | Owner User + code email; canonical door for Clients |
Staff sub-inviteapi/admin/clients/:clientId/submerchants/invites | STAFF + merchant.write | inviteSubMerchant | None stamped | Sumsub level validated against the live catalogue |
Client APIapi/clients/:clientId/submerchants/invites | MERCHANT + merchant.write + assertSelf | inviteSubMerchant | None stamped | Requires isMarketplace; audit submerchant.invited; branded invite link |
Portalapi/portal/sub-merchants | MERCHANT + merchant.write; tier gate + canCreateSubMerchants | invite → createInvite | Caller-owned profile or caller's default — never unsplit | Forces canCreateSubMerchants=false, subMerchantCommission*=0 on the sub; flips caller's isMarketplace |
SubMerchantService doors as ones that need a manual pricing follow-up — see gaps.submerchant.controller.ts:52-131, 144-285 · submerchant.service.ts:365-501, 595-646 · portal-submerchants.controller.ts:28-50 · portal-submerchant.service.ts:43-123 · admin-merchants.service.ts:320-354, 385-391 · admin-merchants.controller.ts:34-237
Section 4
Code entry & verification
The invitee's first mile is deliberately low-friction: a magic link, a 6-digit code, and a session token — no password until the end.
Opening the magic link calls POST api/portal/onboarding/:id/open, moving the row INVITED to OPENED. The page then asks for the emailed 6-digit code: POST api/portal/onboarding/:id/verify checks it against the bcrypt verificationCodeHash (24 h TTL, fresh code via POST :id/resend-code) and, on success, moves OPENED to VERIFIED and mints a MERCHANT-scope JWT — from here the invitee is an authenticated session, not a link-holder. During the flow the merchant also sets their portal password (POST :id/set-password; TOTP MFA is enrolled on first portal login) and the UI polls GET :id/state to follow the state machine live.
When the invitee is a sub-merchant, the page dresses itself in the parent's brand first: the public GET api/portal/onboarding/:id/branding reads brandName, brandLogoUrl and brandColour from the parent Client's splitConfig JSON — the legacy pricing blob doing double duty as a brand store (its pricing half is discussed in Pricings, Splits & Fees). The invite link's &clientId= parameter is what points the page at the right parent.
onboarding.controller.ts:46-201 · onboarding.state-machine.ts:34-50 · onboarding.service.ts:80-110 · merchant-portal app/(onboarding)/onboarding/ · app/(public)/onboarding
Section 5
Sumsub KYC & UBOs
Identity verification is entirely Sumsub's: the merchant never leaves Ummah's onboarding page, and Ummah never builds its own document flow.
POST api/portal/onboarding/:id/sumsub-token (VERIFIED to IN_PROGRESS) lazily creates the Sumsub applicant and returns a WebSDK token; the merchant completes the company questionnaire inside the embedded iframe. Beneficiaries are verified separately: each UBO receives a JWT link and confirms through POST api/portal/onboarding/beneficiary/verify-token, running against the individual level (default ummah-adyen-individual-uk-v1); each verified UBO later gets an Adyen legal entity of their own.
The review result comes back as a webhook, and the trip is deliberately indirect: Sumsub posts to the dedicated ingress service (ummah-platform-webhook), which verifies the HMAC at the controller, persists a SumsubWebhookEvent row and enqueues a BullMQ job. The worker's webhook-in-sumsub processor re-checks processedAt before dispatching — at-least-once delivery with idempotent handlers, the same discipline described in Events & Notifications. An applicantReviewed GREEN drives OnboardingService.markSumsubReviewed (IN_PROGRESS to SUMSUB_REVIEWED) and enqueues the provisionMerchant job; a RED review fails the merchant, and a resubmission request parks the row in RESUBMISSION_REQUESTED where a resend-invite can restart the attempt.
Invite to ACTIVE, end to end
FIG 3 · sequence
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
participant stf as Inviter — staff or Client
participant mer as Merchant owner
participant api as Backend
participant sum as Sumsub
participant wrk as Ingress + worker
participant ady as Adyen
stf->>api: POST invite — any of the three doors
api-->>mer: invite email · magic link + 6-digit code
mer->>api: POST onboarding/:id/open
Note over api: INVITED → OPENED
mer->>api: POST onboarding/:id/verify — 6-digit code
Note over api: OPENED → VERIFIED · MERCHANT JWT minted
mer->>api: POST onboarding/:id/sumsub-token
api->>sum: create applicant — lazy
Note over api: VERIFIED → IN_PROGRESS
mer->>sum: complete KYC in the WebSDK iframe
sum-->>wrk: POST /webhooks/sumsub — applicantReviewed GREEN
Note over wrk: HMAC verified · SumsubWebhookEvent persisted · job queued
wrk->>api: markSumsubReviewed
Note over api: IN_PROGRESS → SUMSUB_REVIEWED · provisionMerchant enqueued
wrk->>ady: LEM chain — legal entity to transfer instrument
ady-->>wrk: ACCOUNT_HOLDER.UPDATED — verificationStatus valid
wrk->>api: markActive + ensureDefaultStore
Note over api: ADYEN_PROVISIONING → ACTIVE
onboarding.controller.ts:112-137 · onboarding.service.ts:687-699 · ummah-platform-webhook/src/webhooks/ingress.service.ts:18-144 · webhook-in-sumsub.processor.ts:1-16 · schema.prisma:752 (SumsubWebhookEvent)
Section 6
Adyen provisioning
On Sumsub GREEN, a worker builds the merchant's whole Adyen identity in one resumable chain. The merchant sees none of it.
MerchantProvisioningService runs under a status gate (only SUMSUB_REVIEWED or ADYEN_PROVISIONING rows proceed) and walks six steps against Adyen's Legal Entity Management and Balance Platform APIs: legal entity → business line → terms-of-service acceptance → account holder → balance account → transfer instrument, the last built from the bank details Sumsub captured. Each minted id is persisted onto the Merchant row as it lands. The job is provisionMerchant on the adyen-provisioning queue with jobId provision-${merchantId}, so duplicate GREEN webhooks collapse into a single run.
Failure handling splits by fault class. An Adyen 4xx is treated as our data being wrong: the merchant moves to FAILED and stays there until staff fix the underlying data and call POST api/admin/merchants/:merchantId/adyen/retry-provisioning. An Adyen 5xx is treated as weather: the processor throws, BullMQ retries with exponential backoff (3 attempts), and the job dead-letters if Adyen stays down. Completion is also webhook-driven: only when ACCOUNT_HOLDER.UPDATED reports verificationStatus=valid does markActive move ADYEN_PROVISIONING to ACTIVE.
The LEM provisioning chain and its two failure modes
FIG 4 · provisioning
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
subgraph chain["MerchantProvisioningService — status gate SUMSUB_REVIEWED / ADYEN_PROVISIONING"]
LE["Legal entity"] --> BL["Business line"] --> TOS["ToS acceptance"] --> AH["Account holder"] --> BA["Balance account"] --> TI["Transfer instrument
from Sumsub bank data"]
end
TI -->|"ACCOUNT_HOLDER.UPDATED valid"| ACT["ACTIVE
ensureDefaultStore"]
chain -.->|"Adyen 4xx"| F4["FAILED — staff fix data, then
adyen/retry-provisioning"]
chain -.->|"Adyen 5xx"| F5["throw — BullMQ backoff
3 attempts then dead-letter"]
F5 -.->|"retry"| chain
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class LE,BL,TOS,AH,BA,TI adyen; class ACT sub; class F4 danger; class F5 warn;
provision-${merchantId} also means a staff retry and a late duplicate webhook cannot run the chain twice concurrently.ensureDefaultStore on ACTIVE
The moment a merchant goes ACTIVE, StoresService.ensureDefaultStore gives it somewhere to sell: a default Store that reuses the onboarding-time balance account (no second KYC), stamps the merchant's defaultSplitProfileId onto it, creates the Adyen Management-API store via ensureAdyenStore with the default payment methods (visa, mc, maestro, amex, applepay, googlepay), and runs applyPendingSplitProfile to attach the split configuration to the Adyen store. From here the merchant is genuinely payment-ready — the handoff continues in Merchants & Stores and Checkout & Payments.
The exception matters: when the merchant reached ACTIVE through a door that never stamped a split profile, ensureDefaultStore creates the store with splitProfileId null and only logs a warning. The checkout guard then blocks that store from taking payments until someone assigns a profile by hand.
provisioning.service.ts:34-92 · adyen-provisioning.processor.ts:1-12 · queue.constants.ts:5-44 · stores.service.ts:473-544, 522-529, 611-655 · admin-merchants.controller.ts:97-207
Section 7
Ops oversight
Because the pipeline is webhook-driven, the staff toolkit is built around three verbs: observe, simulate, retry. The screens live in the back-office (Back-office Operations covers the console itself).
- Observe. The onboarding queue (
GET api/admin/merchants/onboarding-queue) and the KYC review queue (api/admin/onboarding/review-queue) are the two worklists, backed by the back-officeonboarding-queueandkyc-queuescreens. Per merchant, staff can read the raw Sumsub event history (GET :merchantId/sumsub/events), the Adyen-side verification state (GET :id/adyen/verification), the exact payloads sent during provisioning (GET :id/onboarding-payloads), balance accounts, notes and the audit trail. - Simulate. In sandbox,
POST :merchantId/sumsub/simulateinjects a review result without a real document check — the standard way to drive a test merchant through GREEN or RED. Sumsub level configuration lives in the back-officesettings/sumsub-levelsscreen. - Retry and revive. Staff re-issue invites with
POST api/admin/merchants/:merchantId/resend-invite; a Client does the same for its subs viaPOST api/clients/:clientId/submerchants/:subId/resend-invite, which is allowed only from INVITED, RESUBMISSION_REQUESTED or EXPIRED — and revives EXPIRED back to INVITED with a fresh code. Provisioning failures are retried withPOST :merchantId/adyen/retry-provisioning; the PCI questionnaire is signed viaPOST :merchantId/adyen/sign-pci. - Discipline.
POST api/admin/merchants/:id/suspendandPATCH :id/statuscontrol the administrative states. These bite immediately on the API surface: SUSPENDED blocks the whole /v1 surface with 403merchant_suspended; INACTIVE allows GETs only, refusing writes with 403merchant_inactive.
The pipeline also reports itself: terminal outcomes render the onboarding.succeeded / onboarding.failed templates into Notification rows and send them through the SQS email pipeline — the mechanics live in Events & Notifications.
| Status | Resend allowed? | Effect |
|---|---|---|
INVITED | Yes | Fresh 6-digit code, same row |
RESUBMISSION_REQUESTED | Yes | Restarts the attempt after a Sumsub resubmission request |
EXPIRED | Yes | Revives the row to INVITED with a fresh code |
SUMSUB_REVIEWED / ADYEN_PROVISIONING | No | A provisioning job is in flight — use the retry endpoint, not a new invite |
ACTIVE / administrative states | No | Lifecycle is managed via suspend / status controls |
admin-merchants.controller.ts:34-237 · submerchant.service.ts:308-363 · api-key.guard.ts:39-84 · notification-templates.ts:238-253 · backoffice app/(app)/onboarding-queue, kyc-queue, settings/sumsub-levels
Section 8
Implementation notes
Every endpoint in the feature, grouped by surface. Guards are shown as scope + permission where the code declares them.
| Surface | Endpoint | Guard | Purpose |
|---|---|---|---|
| Invites | POST api/admin/merchants/invites | STAFF | Canonical invite; 422 split_profile_required without defaultSplitProfileId |
POST api/admin/merchants/:merchantId/resend-invite | STAFF | Staff re-issue of the invite email | |
POST api/admin/clients/:clientId/submerchants/invites | STAFF + merchant.write | Staff sub-invite (via SubMerchantService) | |
POST api/clients/:clientId/submerchants/invites | MERCHANT + merchant.write + assertSelf | Client self-service sub-invite; requires isMarketplace | |
POST api/clients/:clientId/submerchants/:subId/resend-invite | MERCHANT + assertSelf | Resend; INVITED / RESUBMISSION_REQUESTED / EXPIRED only | |
POST api/portal/sub-merchants | MERCHANT + merchant.write | Portal sub-invite; needs canCreateSubMerchants, never unsplit | |
PATCH api/admin/clients/:clientId/marketplace | STAFF | Marketplace toggle; 400 not_a_client / marketplace_locked | |
| Onboarding | POST api/portal/onboarding/:id/open | Public (magic link) | INVITED → OPENED |
POST api/portal/onboarding/:id/verify | Public + 6-digit code | OPENED → VERIFIED; mints MERCHANT JWT | |
POST api/portal/onboarding/:id/resend-code | Public | Fresh 6-digit code | |
POST api/portal/onboarding/:id/sumsub-token | Onboarding JWT | VERIFIED → IN_PROGRESS; Sumsub WebSDK token, lazy applicant | |
POST api/portal/onboarding/:id/set-password | Onboarding JWT | Portal credentials; TOTP MFA enrols at first login | |
GET api/portal/onboarding/:id/state | Onboarding JWT | Live state-machine polling for the UI | |
GET api/portal/onboarding/:id/branding | Public | Parent Client brand from splitConfig | |
POST api/portal/onboarding/beneficiary/verify-token | UBO JWT link | Beneficiary verification, level ummah-adyen-individual-uk-v1 | |
| Async | POST /webhooks/sumsub | HMAC (ingress service) | Persist SumsubWebhookEvent, enqueue KYC state machine |
queue adyen-provisioning · job provisionMerchant | Worker | LEM chain; jobId provision-${merchantId} dedupes | |
ACCOUNT_HOLDER.UPDATED (valid) | Worker | markActive + ensureDefaultStore | |
| Ops | GET api/admin/merchants/onboarding-queue | STAFF | Pipeline worklist |
GET api/admin/onboarding/review-queue | STAFF | KYC review worklist | |
GET :merchantId/sumsub/events · POST :merchantId/sumsub/simulate | STAFF | Sumsub history · sandbox review simulation | |
POST :merchantId/adyen/retry-provisioning | STAFF | Re-run the LEM chain after a 4xx FAILED | |
POST :merchantId/adyen/sign-pci | STAFF | PCI questionnaire signing | |
GET :id/adyen/verification · GET :id/onboarding-payloads | STAFF | Adyen-side state · raw provisioning payloads | |
POST :id/suspend · PATCH :id/status | STAFF | Administrative lifecycle; gates the /v1 surface immediately |
Data model notes
- Invite fields live on Merchant:
verificationCodeHash(bcrypt) andexpiresAt— there is no separate Invite table. The ownerUseris created up front with statusinvitedand theMerchantOwnersystem role. - The Adyen quartet (
adyenLegalEntityId,adyenAccountHolderId,adyenBalanceAccountId,adyenTransferInstrumentId) is persisted step by step during provisioning;Uborows carry their ownadyenLegalEntityId, andBankAccountrows their ownadyenTransferInstrumentId. - Webhook events are rows first:
SumsubWebhookEventpersists before any processing, and consumers stampprocessedAt— replays are harmless. - Sub-merchant rows are defanged at birth:
createInviteforcescanCreateSubMerchants=falseandsubMerchantCommission*=0on any SUBMERCHANT row, so nesting and commission tampering are blocked at the data layer.
Section 9
Gaps & recommendations
The pipeline's core is strong. The gaps cluster where the three doors diverge and where recoverable states rely on convention rather than code.
Sub-merchants can go ACTIVE without a split profile
SubMerchantService.inviteSubMerchant (both the staff and Client-API doors) creates the SUBMERCHANT row without defaultSplitProfileId (submerchant.service.ts:413-427); on activation ensureDefaultStore only logs a warning (stores.service.ts:522-529) and the checkout guard blocks payments. Fix: require or inherit a profile in inviteSubMerchant, mirroring createInvite's 422, and surface unsplit ACTIVE stores in the onboarding queue.
Two invite systems, two marketplace gates
The Client-API door checks isMarketplace; the portal door checks canCreateSubMerchants and then flips isMarketplace itself. A Client can satisfy one gate but not the other, and the doors produce differently-configured subs (split inheritance, invite-link shape). Meanwhile listSubMerchants and configureSplit fetch the flag but never check it. Fix: converge on one service and one explicit capability check.
EXPIRED revival rules live in only one path
The INVITED / RESUBMISSION_REQUESTED / EXPIRED eligibility — and the EXPIRED → INVITED revival — is implemented in the Client-side resend (submerchant.service.ts:308-363). The staff resend-invite endpoint's status rules are not defined by the same code path, so staff and Client behaviour can drift. Fix: centralise resend eligibility in one state-machine rule both surfaces call.
Merchant custom roles cannot actually invite subs
The invite controllers gate on coarse merchant.write, but in MERCHANT scope that name is only reachable through the legacy permission bridge — and sub-merchants.create / sub-merchants.edit have no bridge entries (permissions.registry.ts:120-161). A custom role granted exactly those permissions still cannot invite. Fix: add bridge entries or move the controllers to the fine-grained names.
assertSelf is convention, not a guard
The fix for the historical :clientId IDOR is a per-controller helper each handler must remember to call (submerchant.controller.ts:148-155). Fix: extract a shared guard or param decorator so new :clientId routes are safe by default.
Nesting prevention is scattered
No-sub-of-sub is enforced in three places with three different error codes, and inviteSubMerchant itself never re-checks the target's tier — it relies transitively on isMarketplace only ever being set on CLIENT rows. Fix: assert tier === CLIENT once, inside the invite service.
The seed merchant cannot transact locally
The demo store from prisma/seed.ts:84-86 has no split profile and no Adyen ids, so local checkout fails in a way that looks like a bug in your change. Fix: seed a synced profile and stub ids, or label the store clearly as non-transacting.