KYC Identity Verification¶
Overview¶
The KYC (Know Your Customer) flow verifies the identity of a new OneWallet user as a mandatory part of the registration process. The user photographs their ID document and takes a selfie; the KYC Service uses Google Gemini AI to perform OCR on the document and match the face against the selfie. After AI processing, the user reviews and confirms the extracted data. A human operator then performs a final approval before the user gains full access to payments. KYC cannot be skipped — the app blocks access to all payment features until fully_verified status is reached.
Participants¶
| Service | Role in This Flow |
|---|---|
| Flutter App | Captures photos; uploads to S3; drives the 4-step wizard; polls for OCR result |
| Auth Center (Serverpod) | Owns the kyc_verifications and user_profiles tables; exposes KYC RPC endpoints; fires-and-forgets to KYC Service |
| Garage S3 (object storage) | Stores encrypted KYC document and selfie files (minimum 5-year retention) |
| KYC Service (Node.js + Express) | Receives job via HTTP, enqueues to BullMQ, runs Gemini OCR and face match, writes result to PostgreSQL |
| BullMQ / Redis | Job queue for KYC processing (kyc-jobs queue) |
| Google Gemini API | AI service for OCR extraction and face matching |
| PostgreSQL (shared) | kyc_verifications table (Serverpod schema); user_profiles table |
| Admin Panel (SvelteKit) | Operator reviews OCR result; approves or rejects; triggers Wallet Registration in IPPS |
Diagram¶

Prerequisites¶
- User has completed email OTP verification and has a valid
registration_token - User record exists in
public.userswithstatus = 'registration_in_progress' - S3 bucket
kyc-datais accessible with SSE encryption enabled KYC_SERVICE_SECRETenvironment variable is set in both Serverpod and KYC Service- Google Gemini API key is configured in KYC Service
Steps¶
Step 1: User Photographs Documents (Flutter Wizard)¶
Service: Flutter App (/kyc/upload screen)
Action: The app guides the user through a 3-step photo sequence using the device camera:
1. Passport / ID document — back camera (CameraLensDirection.back)
2. Selfie holding the document — front camera (CameraLensDirection.front)
3. Visa stamp — back camera (optional)
Data: Three in-memory image files ready for upload.
Step 2: Upload Files to S3¶
Service: Flutter App → Garage S3
Action: Flutter requests presigned PUT URLs from Auth Center. Uploads each photo directly to S3 under keys like kyc/{userId}/{uuid}/passport.jpg, kyc/{userId}/{uuid}/selfie.jpg.
Data: S3 object keys stored locally in the app for the next step.
Step 3: Initialize KYC Record¶
Service: Flutter App → Auth Center (initializeKyc)
Action: Calls KycEndpoint.initializeKyc(documentFrontUrl, documentBackUrl, selfieUrl). Serverpod creates (or updates) a kyc_verifications row with the S3 keys and status = 'pending'.
Data:
INSERT INTO kyc_verifications (userId, documentFrontUrl, selfieUrl, status)
VALUES (<userId>, 'kyc/.../passport.jpg', 'kyc/.../selfie.jpg', 'pending')
ON CONFLICT (userId) DO UPDATE SET documentFrontUrl=..., selfieUrl=..., status='pending';
Step 4: Submit for Review¶
Service: Flutter App → Auth Center (submitForReview)
Action: Calls KycEndpoint.submitForReview(). Serverpod updates kyc_verifications.status = 'in_review' and fires-and-forgets an HTTP POST to the KYC Service.
Data:
POST http://kyc-service:3003/api/v1/process
X-Service-Token: <kycServiceSecret>
{
"userId": <userId>,
"kycId": <kycVerificationId>,
"passportKey": "kyc/.../passport.jpg",
"selfieKey": "kyc/.../selfie.jpg",
"stampKey": "kyc/.../stamp.jpg" (optional)
}
Flutter navigates to /kyc/processing screen. Serverpod does not await the KYC Service response.
Step 5: KYC Job Enqueued¶
Service: KYC Service (Node.js/Express)
Action: Receives the HTTP POST, validates X-Service-Token, and adds a job to BullMQ queue kyc-jobs with:
- attempts: 5
- Exponential backoff: 15s → 30s → 60s → 120s → 240s
Returns HTTP 202 to Serverpod immediately.
Step 6: Flutter Polls for OCR Status¶
Service: Flutter App (/kyc/processing screen)
Action: Every 10 seconds, calls KycEndpoint.getKycStatus(). Waits until status changes from in_review to either ocr_complete or ocr_failed.
Step 7: KYC Worker — Generate Presigned URLs¶
Service: KYC Service (BullMQ Worker, concurrency=3)
Action: Worker picks up the job. Calls s3.getPresignedUrl() for the passport and selfie S3 keys to generate temporary GET URLs accessible to the Gemini API.
Data: Presigned GET URLs with short TTL.
Step 8: KYC Worker — OCR on ID Document¶
Service: KYC Service → Google Gemini API
Action: Calls gemini.extractOcr(passportUrl). Gemini reads the document photo and extracts structured identity fields.
Data returned:
{
"fullNameTh": "นาย สมชาย ใจดี",
"fullNameEn": "Somchai Jaidee",
"dateOfBirth": "1990-05-15",
"nationality": "Thai",
"nationalIdNumber": "1234567890123",
"address": "...",
"dateOfIssue": "2020-01-01",
"dateOfExpiry": "2030-01-01",
"gender": "Male"
}
Validation: If all OCR fields are null → UnrecoverableError('INVALID_PHOTO:passport:...'). No retry — immediately marks job as failed with failureCode = 'invalid_passport'.
Step 9: KYC Worker — Face Match¶
Service: KYC Service → Google Gemini API
Action: Calls gemini.matchFaces(passportUrl, selfieUrl). Gemini compares the face in the ID document against the selfie.
Data returned: { score: 0.93, reasoning: "Faces match with high confidence" }
Validation: If score = 0 AND reasoning contains "no face" / "impossible" / "cannot" → UnrecoverableError('INVALID_PHOTO:selfie:...'). No retry — failureCode = 'invalid_selfie'.
Step 10: KYC Worker — Write Result to Database¶
Service: KYC Service → PostgreSQL
Action: Calls db.updateKycResult(). Updates kyc_verifications with OCR data and face match score.
Data:
UPDATE kyc_verifications
SET status = 'ocr_complete',
ocrResult = '{"ocrData": {...}, "faceMatchScore": 0.93, "faceMatchReasoning": "..."}',
reviewedAt = NOW()
WHERE id = <kycId>;
On failure after exhausted retries:
UPDATE kyc_verifications
SET status = 'ocr_failed',
ocrResult = '{"error": "...", "failureCode": "processing_error", "failedAt": "..."}',
reviewedAt = NOW()
WHERE id = <kycId>;
Step 11: Flutter Routes to Review Screen¶
Service: Flutter App
Action: getKycStatus() returns ocr_complete. App fetches OCR data via KycEndpoint.getOcrResult() and navigates to /kyc/review.
On ocr_failed: shows error screen. invalid_passport / invalid_selfie → "Retake photos" button only. processing_error → both "Try again" (retry without re-upload) and "Retake photos".
Step 12: User Reviews and Edits OCR Data¶
Service: Flutter App (/kyc/review screen)
Action: User sees 10 pre-filled fields from OCR. User corrects any errors. Taps "Yes, continue".
Action: Calls KycEndpoint.storeReviewedData(fields...). Flutter navigates to /kyc/terms.
Step 13: User Accepts Terms & Conditions¶
Service: Flutter App (/kyc/terms screen)
Action: User reads T&C, checks the checkbox, taps "Next". Calls KycEndpoint.confirmKycData(fields...).
Step 14: KYC Data Confirmed — Phase 1 Activation¶
Service: Auth Center (Serverpod)
Action: confirmKycData() executes:
1. Upserts user_profiles with all OCR-reviewed fields and termsAcceptedAt = NOW()
2. Updates kyc_verifications.status = 'pending_operator_review'
3. Calls activatePhase1(): creates internal TigerBeetle account for the user (wallet balance account)
4. Updates users.status to reflect KYC pending review state
Data:
-- user_profiles upsert:
INSERT INTO user_profiles (userId, fullNameTh, fullNameEn, dateOfBirth, ..., termsAcceptedAt)
VALUES (<userId>, ..., NOW())
ON CONFLICT (userId) DO UPDATE SET ...;
-- kyc_verifications update:
UPDATE kyc_verifications SET status='pending_operator_review' WHERE userId=<userId>;
Flutter navigates to /kyc/pending. User can now access /home in read-only mode while waiting.
Step 15: Operator Reviews in Admin Panel¶
Service: Admin Panel (SvelteKit)
Action: Operator sees the submission in the KYC review queue (status = pending_operator_review). Opens the detail view: presigned S3 URLs for document photos, OCR-extracted fields. Decides: Approve or Reject.
Step 16: Operator Approves — Phase 2 Activation¶
Service: Admin Panel → Auth Center (Serverpod)
Action: Operator clicks "Approve". Serverpod:
1. Updates kyc_verifications.status = 'fully_verified', approvedAt = NOW()
2. Creates Blnk Identity in blnkfinance and links existing balance account
3. Registers user wallet in IPPS PPXC: POST /register-wallet-user via KYCService
IPPS registration:
POST /register-wallet-user
{
"externalWalletUserId": "user_<userId>",
"customerName": "Somchai Jaidee",
"taxId": "1234567890123"
}
→ { "walletId": "400110891234567" }
walletId saved to ppxc_wallets in IPPS Adapter DB. Stored in pm.tb_account_map as ipps_wallet_id.
- Sends push notification to user: "Your identity has been verified"
Step 17: User Gains Full Access¶
Service: Flutter App
Action: On next app open or push notification tap, Auth Center returns kycStatus = fully_verified. App unlocks all payment features.
State Transitions¶
pending
→ in_review (submitForReview — photos uploaded, job queued)
→ ocr_complete (Gemini OCR + face match succeeded)
| → pending_operator_review (confirmKycData + T&C accepted, Phase 1 activated)
| → fully_verified (operator approved, Phase 2 activated)
| → rejected (operator rejected)
→ ocr_failed (all retries exhausted: invalid photo or API error)
→ in_review (retryKycProcessing — reuse same S3 keys, new BullMQ job)
→ pending (user chooses "Retake photos" — new upload required)
| Status | Home Access | Payment Access | KYC Screens |
|---|---|---|---|
pending |
No (→ /kyc) |
No | Yes |
in_review |
No (→ /kyc) |
No | Yes |
ocr_complete |
No (→ /kyc) |
No | Yes |
ocr_failed |
No (→ /kyc) |
No | Yes |
pending_operator_review |
Yes (read-only) | No | No (→ /kyc/pending) |
fully_verified |
Yes | Yes | No |
rejected |
No | No | Yes (restart) |
Error Cases¶
| Scenario | Where Detected | Outcome | Recovery |
|---|---|---|---|
| Passport photo unreadable (all OCR fields null) | KYC Worker (Step 8) | UnrecoverableError; no retry; failureCode = 'invalid_passport' |
User retakes passport photo |
| Selfie has no detectable face | KYC Worker (Step 9) | UnrecoverableError; no retry; failureCode = 'invalid_selfie' |
User retakes selfie |
| Gemini API 429 rate limit | KYC Worker | BullMQ retries with exponential backoff (up to 5 attempts: 15s → 240s) | Automatic |
| Gemini API error (network, 5xx) | KYC Worker | BullMQ retries with backoff; on 5th failure: failureCode = 'processing_error' |
User can retry without re-upload via retryKycProcessing() |
| KYC Service unreachable | Auth Center submitForReview |
Fire-and-forget; no error to user; status remains in_review; Serverpod can retry |
Manual retry via retryKycProcessing() |
| Operator rejects KYC | Admin Panel (Step 15) | status = 'rejected'; user notified; must restart from photo upload |
User re-submits |
| IPPS wallet registration fails (Step 16) | Auth Center Phase 2 | fully_verified not set; alert to operations |
Ops retries wallet registration manually |
| User abandons registration (7-day TTL) | Cron job (daily 02:00) | users.is_archived = true, status = 'registration_expired'; S3 files deleted after 30 days |
User must start fresh registration |
Related Specs¶
/docs/03-kyc-module.md— Full KYC module documentation (4-step mobile flow, data model, retry strategy, business rules)/docs/02-auth-module.md— Registration flow context (RegistrationService, registration_token lifecycle, temp user cleanup)/docs/05-ipps-ppxc-adapter.md— IPPS Wallet Registration endpoint (POST /register-wallet-user,ppxc_walletstable)