KYC tiers¶
Definition. Five-step identity verification ladder backed by Sumsub.
LEVEL_0is unverified;LEVEL_1is self-attested PII (no documents);LEVEL_2–LEVEL_4are 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 viaSiteConfig, not compiled in.
1. Levels¶
| 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 cumulative — totalWithdraws + 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.ts — generateRequestId). 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/) |
10. Related docs¶
wallet-and-balance.md— Withdrawal flow that consumes KYC limitsbonuses-and-promos.md—minKYCLevelToClaimgatebet-settlement.md— Geo restrictions are part of the same check../recipes/swap-kyc-provider.md— How to swap Sumsub for another provider../flows/dropbet-sign-up.md— Where LEVEL_0 → LEVEL_1 is invoked../data-model/—UserKyc,UserKycVerificationRequest,KycLevelenum