UMMAH FLOWS · 02 Verified against code · Aug 2026

Merchant lifecycle · invites, KYC & provisioning

Onboarding & KYC

How a merchant — top-level Client or sub-merchant — travels from an invite email to a live, payment-ready account: the three invite doors and their different guards, the 6-digit code and magic link, Sumsub WebSDK identity checks with UBO verification, and the Adyen LEM provisioning chain that mints the account holder and balance account. This page walks every status transition, every endpoint, and the ops levers that unstick a stuck merchant. Every claim below was verified in the platform code.

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;
Invitee-driven Ummah milestone External rails at work Live Recoverable pause Terminal failure
Three administrative statuses sit outside the pipeline: 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.

TermWhat it isWhere it lives
Magic link + 6-digit codeThe 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
MerchantStatusThe 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
defaultSplitProfileIdThe 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 levelA 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
UboA 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-provisioningThe 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
ensureDefaultStoreThe 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
MerchantOwnerThe 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 canCreateSubMerchantsTwo 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

Ummah staff Marketplace Client Direct merchant AdminMerchantsService SubMerchantService

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;
Staff surface Merchant surface Diverging behaviour Outcome
Both 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.
What each door checks and what it produces
DoorGuard chainServiceSplit profileNotable side effects
Staff
api/admin/merchants/invites
STAFF scopecreateInviteMandatory — 422 split_profile_required; sub-invites may inherit the parent'sOwner User + code email; canonical door for Clients
Staff sub-invite
api/admin/clients/:clientId/submerchants/invites
STAFF + merchant.writeinviteSubMerchantNone stampedSumsub level validated against the live catalogue
Client API
api/clients/:clientId/submerchants/invites
MERCHANT + merchant.write + assertSelfinviteSubMerchantNone stampedRequires isMarketplace; audit submerchant.invited; branded invite link
Portal
api/portal/sub-merchants
MERCHANT + merchant.write; tier gate + canCreateSubMerchantsinvite → createInviteCaller-owned profile or caller's default — never unsplitForces canCreateSubMerchants=false, subMerchantCommission*=0 on the sub; flips caller's isMarketplace
Verdict · WarnThe doors converge on the same INVITED row but disagree on the two things that matter downstream: which marketplace gate applies, and whether the sub is born priced. Until the services merge, treat the portal door as the safe path and the 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

Merchant owner api/portal/onboarding Public endpoints, hashed secrets

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.

Verdict · OKCode custody is sound: only a bcrypt hash is stored, expiry is enforced, revival is explicit (resend), and privilege changes shape at a clear boundary — the JWT exists only after the code proves inbox control.

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

Merchant owner Sumsub ummah-platform-webhook webhook-in-sumsub

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
Dotted arrows are asynchronous — email and webhooks. Nothing in the right half involves the merchant: once the iframe closes, the pipeline is entirely webhook- and worker-driven, which is why staff tooling (Section 7) focuses on observing and retrying rather than data entry.
Verdict · OKThe KYC leg is well-armoured: HMAC at the edge, persisted events, deduped jobs, idempotent state transitions. The one operational sharp edge is that SUMSUB_REVIEWED is not resend-eligible — a merchant stuck there needs the provisioning story (next section), not a new invite.

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

adyen-provisioning queue Adyen LEM · Balance Platform Staff retry

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;
Adyen LEM step Live outcome 4xx — data fault, staff retry 5xx — transient, automatic backoff
The 4xx/5xx split is the load-bearing design: bad data never burns automatic retries, and Adyen outages never poison a merchant into FAILED. The jobId 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.

Verdict · WarnThe chain itself is exemplary — gated, deduped, resumable, with the right 4xx/5xx philosophy. The soft landing at the end is the flaw: an ACTIVE merchant with an unsplit store looks finished in every dashboard yet cannot transact, and the only signal is a log line.

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

Ummah staff back-office api/admin

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-office onboarding-queue and kyc-queue screens. 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/simulate injects 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-office settings/sumsub-levels screen.
  • Retry and revive. Staff re-issue invites with POST api/admin/merchants/:merchantId/resend-invite; a Client does the same for its subs via POST 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 with POST :merchantId/adyen/retry-provisioning; the PCI questionnaire is signed via POST :merchantId/adyen/sign-pci.
  • Discipline. POST api/admin/merchants/:id/suspend and PATCH :id/status control the administrative states. These bite immediately on the API surface: SUSPENDED blocks the whole /v1 surface with 403 merchant_suspended; INACTIVE allows GETs only, refusing writes with 403 merchant_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.

Resend-invite eligibility by status
StatusResend allowed?Effect
INVITEDYesFresh 6-digit code, same row
RESUBMISSION_REQUESTEDYesRestarts the attempt after a Sumsub resubmission request
EXPIREDYesRevives the row to INVITED with a fresh code
SUMSUB_REVIEWED / ADYEN_PROVISIONINGNoA provisioning job is in flight — use the retry endpoint, not a new invite
ACTIVE / administrative statesNoLifecycle 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.

Endpoint map
SurfaceEndpointGuardPurpose
InvitesPOST api/admin/merchants/invitesSTAFFCanonical invite; 422 split_profile_required without defaultSplitProfileId
POST api/admin/merchants/:merchantId/resend-inviteSTAFFStaff re-issue of the invite email
POST api/admin/clients/:clientId/submerchants/invitesSTAFF + merchant.writeStaff sub-invite (via SubMerchantService)
POST api/clients/:clientId/submerchants/invitesMERCHANT + merchant.write + assertSelfClient self-service sub-invite; requires isMarketplace
POST api/clients/:clientId/submerchants/:subId/resend-inviteMERCHANT + assertSelfResend; INVITED / RESUBMISSION_REQUESTED / EXPIRED only
POST api/portal/sub-merchantsMERCHANT + merchant.writePortal sub-invite; needs canCreateSubMerchants, never unsplit
PATCH api/admin/clients/:clientId/marketplaceSTAFFMarketplace toggle; 400 not_a_client / marketplace_locked
OnboardingPOST api/portal/onboarding/:id/openPublic (magic link)INVITED → OPENED
POST api/portal/onboarding/:id/verifyPublic + 6-digit codeOPENED → VERIFIED; mints MERCHANT JWT
POST api/portal/onboarding/:id/resend-codePublicFresh 6-digit code
POST api/portal/onboarding/:id/sumsub-tokenOnboarding JWTVERIFIED → IN_PROGRESS; Sumsub WebSDK token, lazy applicant
POST api/portal/onboarding/:id/set-passwordOnboarding JWTPortal credentials; TOTP MFA enrols at first login
GET api/portal/onboarding/:id/stateOnboarding JWTLive state-machine polling for the UI
GET api/portal/onboarding/:id/brandingPublicParent Client brand from splitConfig
POST api/portal/onboarding/beneficiary/verify-tokenUBO JWT linkBeneficiary verification, level ummah-adyen-individual-uk-v1
AsyncPOST /webhooks/sumsubHMAC (ingress service)Persist SumsubWebhookEvent, enqueue KYC state machine
queue adyen-provisioning · job provisionMerchantWorkerLEM chain; jobId provision-${merchantId} dedupes
ACCOUNT_HOLDER.UPDATED (valid)WorkermarkActive + ensureDefaultStore
OpsGET api/admin/merchants/onboarding-queueSTAFFPipeline worklist
GET api/admin/onboarding/review-queueSTAFFKYC review worklist
GET :merchantId/sumsub/events · POST :merchantId/sumsub/simulateSTAFFSumsub history · sandbox review simulation
POST :merchantId/adyen/retry-provisioningSTAFFRe-run the LEM chain after a 4xx FAILED
POST :merchantId/adyen/sign-pciSTAFFPCI questionnaire signing
GET :id/adyen/verification · GET :id/onboarding-payloadsSTAFFAdyen-side state · raw provisioning payloads
POST :id/suspend · PATCH :id/statusSTAFFAdministrative lifecycle; gates the /v1 surface immediately

Data model notes

  • Invite fields live on Merchant: verificationCodeHash (bcrypt) and expiresAt — there is no separate Invite table. The owner User is created up front with status invited and the MerchantOwner system role.
  • The Adyen quartet (adyenLegalEntityId, adyenAccountHolderId, adyenBalanceAccountId, adyenTransferInstrumentId) is persisted step by step during provisioning; Ubo rows carry their own adyenLegalEntityId, and BankAccount rows their own adyenTransferInstrumentId.
  • Webhook events are rows first: SumsubWebhookEvent persists before any processing, and consumers stamp processedAt — replays are harmless.
  • Sub-merchant rows are defanged at birth: createInvite forces canCreateSubMerchants=false and subMerchantCommission*=0 on 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.

P1

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.

P1

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.

P1

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.

P1

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.

P2

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.

P2

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.

P2

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.