Skip to content

KYC tiers

Definition. Five-step identity verification ladder backed by Sumsub. LEVEL_0 is unverified; LEVEL_1 is self-attested PII (no documents); LEVEL_2LEVEL_4 are document-backed (Sumsub Web SDK + webhook). The level gates withdrawal limits, slot game access, and several promo codes. The withdrawal limit per level is runtime-configurable via SiteConfig, not compiled in.

1. Levels

// libs/_prisma/src/schema/api.prisma:432
enum KycLevel { LEVEL_0  LEVEL_1  LEVEL_2  LEVEL_3  LEVEL_4 }
Level Source of trust Captured fields How player advances
LEVEL_0 none — fresh signup only auth account default state
LEVEL_1 self-attested PII firstName, lastName, dateOfBirth, countryCode, address, postalCode, city, occupation, gender POST /kyc/upgrade with the form data
LEVEL_2 Sumsub Web SDK (basic ID) photo of government ID + selfie POST /kyc/upgrade (no body) → returns Sumsub access token, player completes Web SDK flow, Sumsub webhook updates row
LEVEL_3 Sumsub (proof of address) utility bill / bank statement same
LEVEL_4 Sumsub (enhanced due diligence) source-of-funds, etc. same

Levels are sequential — KycService.upgradeKyc (apps/api/src/kyc/kyc.service.ts:39) explicitly only allows the next level given the current one (switch block at line 58).

UserKyc model lives at libs/_prisma/src/schema/api.prisma (search model UserKyc); the per-attempt audit trail is UserKycVerificationRequest (api.prisma:413).

2. Sumsub integration

sequenceDiagram
    participant Player as Player
    participant Api as ebit-api /kyc
    participant Sumsub as api.sumsub.com
    participant Web as Sumsub Web SDK (FE)
    participant WH as Sumsub Webhook

    Player->>Api: POST /kyc/upgrade (target = LEVEL_2)
    Api->>Api: KycService.upgradeKyc<br/>+ Mutex(KYC_UPGRADE_MUTEX_KEY)
    Api->>Api: feature flag enable_kyc_sumsub
    Api->>Sumsub: POST /resources/accessTokens?userId=...&levelName=LEVEL_2
    Sumsub-->>Api: { token }
    Api-->>Player: { token, level: LEVEL_2, verificationPending: false }
    Player->>Web: open Web SDK with token
    Web->>Sumsub: upload docs / selfie
    Sumsub->>WH: POST /kyc/sumsub/webhook (applicantPending)
    WH->>WH: HMAC verification (SUMSUB_WEBHOOK_SECRET_KEY)
    WH->>Api: handleApplicationPending<br/>create verificationRequest<br/>set verificationPending=true
    Sumsub->>WH: POST /kyc/sumsub/webhook (applicantReviewed: GREEN | RED)
    WH->>Api: handleApplicationReviewed
    alt verdict = GREEN
        Api->>Api: updateUserKycLevelNext(level=LEVEL_2, verificationPending=false)
    else verdict = RED, rejectType = RETRY
        Api->>Api: verificationPending=false (level unchanged)<br/>player can retry
    else verdict = RED, rejectType = FINAL
        Api->>Api: verificationPending=false (level unchanged)<br/>player permanently blocked at this level
    end

2.1 Token request

SumSubLevelsService.getWebSdkToken (apps/api/src/kyc/sumsub/sumsub.service.ts:48) signs each request with X-App-Token + HMAC-SHA256 of requestPath keyed on SUMSUB_APP_TOKEN. Token TTL is SUMSUB_GET_TOKEN_CACHE_TTL = 300 seconds (kyc/const.ts:13). The mutex SUMSUB_GET_TOKEN_KEY blocks concurrent token requests for the same user.

2.2 Webhook signature

checkWebhookRequestSignature (sumsub.service.ts:209) supports sha1, sha256, sha512 HMAC variants on the raw body, keyed on SUMSUB_WEBHOOK_SECRET_KEY. Mismatched digest returns 404 (deliberately ambiguous to deter probing). All webhook verification requests pass through WaitMutex keyed on SUMSUB_WEBHOOK_KEY(userId) (sumsub.service.ts:89).

2.3 Verdict handling

processLevelNext (sumsub.service.ts:175) advances on GREEN; on RED + RETRY it clears the pending flag so the player can retry; on RED + FINAL it leaves the row at the current level with no retry path (admin must intervene).

3. Withdrawal limits — runtime-configurable

There is no compile-time KYC limit table. Limits live in the SiteConfig row of type = WITHDRAWALS (api.prisma:128). The shape is [ { kycLevel, max_withdrawal_amount_usd, unlimited_withdrawal } ]. Read at withdrawal time:

// apps/api/src/payment/withdraw/withdraw-check.service.ts:41-78
const kycLevelSiteSettings = this.siteConfigService.getSiteConfig(SiteConfigType.WITHDRAWALS);
const checkInfo.kycLevel = user.kyc?.level ?? KycLevel.LEVEL_0;
const withdrawKycLevelSettings = kycLevelSiteSettings.find(l => l.kycLevel === checkInfo.kycLevel);
checkInfo.maxWithdrawByKyc = withdrawKycLevelSettings.max_withdrawal_amount_usd;
checkInfo.isUnlimitedByKyc = withdrawKycLevelSettings.unlimited_withdrawal;

if (!checkInfo.isUnlimitedByKyc &&
    checkInfo.totalWithdrawsAfter.gt(checkInfo.maxWithdrawByKyc)) {
  throw new ApiException(ApiCode.WITHDRAWAL_MAX_WITHDRAWAL_LIMIT_EXCEEDED(...));
}

Edit the table from the admin panel (see ../admin/). Typical seed values are:

KYC level Lifetime withdrawal cap (USD) Unlimited?
LEVEL_0 {{TBD: confirm with product team}} no
LEVEL_1 {{TBD}} no
LEVEL_2 {{TBD}} no
LEVEL_3 {{TBD}} no
LEVEL_4 n/a yes

The check is lifetime cumulativetotalWithdraws + thisWithdraw vs maxWithdrawByKyc (withdraw-check.service.ts:62-70), not per-period. A player who has historically withdrawn $5k at LEVEL_2 cannot withdraw a 6th $1k unless the cap is > $6k or they upgrade KYC.

4. Wagering requirement on withdrawals

Independent of KYC, every withdrawal also requires that lifetime wager ≥ lifetime withdrawal × WITHDRAW_WAGER_MULTIPLIER:

// withdraw-check.service.ts:80-93
const totalWageredRequired = totalWithdrawsAfter.mul(wagerMultiplier);
if (totalWagered.lt(totalWageredRequired)) {
  throw new ApiException(ApiCode.WITHDRAWAL_NOT_ENOUGH_WAGER, ...);
}

WITHDRAW_WAGER_MULTIPLIER defaults to 2 per .example.env:119, .local.env:147, and .test.env:113. This is anti-abuse — every dollar deposited must produce at least 2 USD of wager turnover before withdrawal, irrespective of KYC level.

4.1 Worked example

Player at LEVEL_2 with max_withdrawal_amount_usd = $10,000, WITHDRAW_WAGER_MULTIPLIER = 2:

  • Lifetime withdrawals so far: $3,000
  • Current request: $1,500
  • Lifetime wager so far: $8,000
totalWithdrawsAfter   = 3000 + 1500 = 4500
maxWithdrawByKyc      = 10000          → 4500 ≤ 10000 ✓ pass KYC cap
totalWageredRequired  = 4500 × 2     = 9000
totalWagered          = 8000           → 8000 < 9000 ✗ fail wager
wagerRequiredLeft     = 9000 − 8000   = $1,000

Player is told "You have to wager $1000.00 more to withdraw $1500.00".

5. Other KYC gates

Gate Behavior Source
Slot games (launchUrl, getUserPersonalInfo) Player at LEVEL_0 is rejected with KYC_NOT_COMPLETED apps/api/src/casino/slots/slot-games.service.ts:42
Promo codes — minKYCLevelToClaim Per-promo gate; common values LEVEL_1 or LEVEL_2 apps/api/src/promo/dto/admin.dto.ts:64, see bonuses-and-promos.md
Email verification LEVEL_0 → LEVEL_1 requires user.emailVerified = true kyc.service.ts:43
Affiliate code creation Auto-created on LEVEL_1 set kyc.service.ts:88

House games (blackjack, mines, plinko, etc.) and live casino do not check KYC — they are accessible at LEVEL_0. {{TBD: confirm with product team this is intentional jurisdiction-by-jurisdiction.}}

6. Document upload flow

Player-side upload is entirely Sumsub Web SDK. ebit-api never receives raw documents; only the Sumsub applicantId is persisted on UserKycVerificationRequest.id (kyc/sumsub/utils.tsgenerateRequestId). This is GDPR-friendly: deletion of a player's account leaves Sumsub data intact for compliance retention but breaks the link from ebit-side.

Sumsub Web SDK URL: rendered in ebit-fe with the access token returned from /kyc/upgrade. Token has 5-minute TTL — if the player abandons and resumes, the FE re-calls /kyc/upgrade.

7. Compliance jurisdictions

Country-level restrictions are enforced by GeoCheckService.checkRestrictions (apps/api/src/country/geo-check.service.ts), called from BetService.processTransactions (bet.service.ts:262). Restricted-country bets throw before any wallet deduction. KYC level does not bypass geographic restrictions.

The country-blocklist data lives in Country rows; admin-editable. {{TBD: confirm with product team the canonical blocked-jurisdiction list.}}

8. Edge cases

Case Behavior Source
Webhook out of order (Reviewed before Pending) handleApplicationReviewed looks up by verificationRequestId; if the request row doesn't exist, it's created on demand sumsub.service.ts:140
Duplicate webhook WaitMutex on SUMSUB_WEBHOOK_KEY(userId) serializes; idempotent because updateUserKycLevelNext is conditional on verdict sumsub.service.ts:89
Sumsub returns level lower than current TODO comment at sumsub.service.ts:174 notes this isn't currently prevented. {{TBD.}} same
User clicks upgrade twice KYC_UPGRADE_MUTEX_KEY mutex serializes (kyc.service.ts:36); cached token reused (SUMSUB_GET_TOKEN_CACHE_TTL = 300s) kyc.service.ts:36, 101
LEVEL_0 user tries withdrawal Falls into if (!withdrawKycLevelSettings) — admin must seed LEVEL_0 row in SiteConfig or the withdrawal is hard-rejected withdraw-check.service.ts:48
enable_kyc_sumsub flag off requestLevelNext throws KYC_IS_NOT_AVAILABLE; LEVEL_1 self-attest still works kyc.service.ts:110

9. Admin overrides

Capability Code
Edit per-level withdrawal limits SiteConfig (type = WITHDRAWALS) admin endpoint
Force a user to a specific level KycRepository.updateUserKycByAdmin (kyc.repository.ts:74)
Set/clear verificationPending Same
Inspect all verification requests for a user KycRepository.findUserKycWithRequests
Edit WITHDRAW_WAGER_MULTIPLIER env var; pod restart required
Toggle Sumsub feature flag enable_kyc_sumsub (apps/api/src/system/feature-flag/)