KYC Verification
KYC (Know Your Customer) verification ensures platform trust by verifying seller identities before they can offer services. It includes identity document verification, address verification, and business information for companies.
Overview
The KYC system provides:
- Identity verification – Personal information and ID documents
- Address verification – Proof of address
- Business verification – Company details for business accounts
- Admin approval workflow – Manual review and approval
- Document uploads – ID cards, passports, business registration
- Multi-step process – Draft → Pending → Approved
Identity Entity
export class Identity {
id: string;
userId: string;
// Personal Information
firstName: string;
lastName: string;
birthDate: Date;
nationality: string;
// Address Information
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
phoneNumber: string;
// ID Document
idDocumentType: IdDocumentType;
idDocumentId: string; // Upload ID for document
// Business Information (optional)
businessType: BusinessType;
business: BusinessDetails | null;
// Status
status: IdentityStatus;
KYCValidationDate: Date | null;
createdAt: Date;
updatedAt: Date;
}
Enums and Types
IdentityStatus
export enum IdentityStatus {
DRAFT = 'draft', // Incomplete, being filled out
PENDING = 'pending', // Submitted, awaiting admin review
APPROVED = 'approved', // Verified and approved
}
BusinessType
export enum BusinessType {
INDIVIDUAL = 'individual', // Freelancer/individual
COMPANY = 'company', // Registered business
}
IdDocumentType
export enum IdDocumentType {
PASSPORT = 'passport',
ID_CARD = 'id_card',
DRIVER_LICENSE = 'driver_license',
}
BusinessDetails
export class BusinessDetails {
name: string; // Company name
registrationNumber: string; // Business registration number
taxId: string; // Tax identification number
registrationDocument: string; // Upload ID for registration doc
}
Business Rules
Submission Rules
- Draft validation – Check all required fields before submission
- Individual requirements – firstName, lastName, birthDate, nationality, address, phone, ID document
- Company requirements – All individual requirements + business details
- Document uploads – ID document required, business doc for companies
- One identity per user – Cannot submit multiple identities
Approval Rules
- Admin verification – Manual review of documents
- Document validity – Check ID is valid and matches name
- Address verification – Verify address is legitimate
- Business verification – For companies, verify registration
- User update – Set
user.isKYCVerified = trueon approval
Identity Methods
Can Be Submitted
canBeSubmitted(): boolean {
return (
this.status === IdentityStatus.DRAFT &&
this.getMissingRequiredFields().length === 0
);
}
Can Be Approved
Get Missing Required Fields
getMissingRequiredFields(): string[] {
const missingFields = [];
// Personal info
if (!this.firstName) missingFields.push('firstName');
if (!this.lastName) missingFields.push('lastName');
if (!this.birthDate) missingFields.push('birthDate');
if (!this.nationality) missingFields.push('nationality');
// Address
if (!this.addressLine1) missingFields.push('addressLine1');
if (!this.city) missingFields.push('city');
if (!this.country) missingFields.push('country');
if (!this.phoneNumber) missingFields.push('phoneNumber');
// ID Document
if (!this.idDocumentType) missingFields.push('idDocumentType');
if (!this.idDocumentId) missingFields.push('idDocumentId');
// Business type
if (!this.businessType) missingFields.push('businessType');
// Business details for companies
if (this.businessType === BusinessType.COMPANY && !this.business) {
missingFields.push('business');
}
return missingFields;
}
Use Cases
Create Identity (Draft)
async function createIdentity(
data: any,
identityRepository: IdentityRepository,
user: User
) {
// Check if user already has an identity
const existingIdentity = await identityRepository.findByUserId(user.id);
if (existingIdentity) {
throw new IdentityAlreadyExists();
}
const validatedData = await new IdentityCreateValidator().validate(data);
const identity = Identity.create(
identityRepository.nextId(),
user.id,
validatedData.firstName,
validatedData.lastName,
validatedData.birthDate,
validatedData.nationality,
validatedData.businessType,
validatedData.business || null,
validatedData.addressLine1,
validatedData.addressLine2 || '',
validatedData.city,
validatedData.state || '',
validatedData.postalCode || '',
validatedData.country,
validatedData.phoneNumber,
validatedData.idDocumentType,
validatedData.idDocumentId,
IdentityStatus.DRAFT
);
await identityRepository.add(identity);
return toIdentityDto(identity);
}
Update Identity (Draft only)
async function updateIdentity(
data: any,
identityRepository: IdentityRepository,
user: User
) {
const identity = await identityRepository.findByUserId(user.id);
if (!identity) {
throw new IdentityNotFound();
}
// Can only update draft identities
if (identity.status !== IdentityStatus.DRAFT) {
throw new IdentityNotEditable('Cannot update submitted identity');
}
const validatedData = await new IdentityUpdateValidator().validate(data);
const updated = await identityRepository.update(identity.id, validatedData);
return toIdentityDto(updated);
}
Submit Identity for Review
async function submitIdentity(
identityRepository: IdentityRepository,
notifier: Notifier,
user: User
) {
const identity = await identityRepository.findByUserId(user.id);
if (!identity) {
throw new IdentityNotFound();
}
// Check if can be submitted
if (!identity.canBeSubmitted()) {
const missingFields = identity.getMissingRequiredFields();
throw new IdentityIncomplete(
`Missing required fields: ${missingFields.join(', ')}`
);
}
// Update status to pending
await identityRepository.update(identity.id, {
status: IdentityStatus.PENDING,
});
// Notify admins
await notifier.notifyAdminsNewKYC(identity, user);
return toIdentityDto(identity);
}
Approve Identity (Admin)
async function approveIdentity(
identityId: string,
identityRepository: IdentityRepository,
userRepository: UserRepository,
notifier: Notifier,
admin: User
) {
// Check admin permission
if (!(admin.isStaff && admin.hasPermission(Permission.MANAGE_KYC))) {
throw new ForbiddenError();
}
const identity = await identityRepository.get(identityId);
// Check if can be approved
if (!identity.canBeApproved()) {
throw new IdentityNotApprovable(
`Identity status is ${identity.status}, expected pending`
);
}
// Update identity status
await identityRepository.update(identityId, {
status: IdentityStatus.APPROVED,
KYCValidationDate: new Date(),
});
// Update user KYC status
await userRepository.update(identity.userId, {
isKYCVerified: true,
});
// Notify user
const user = await userRepository.get(identity.userId);
await notifier.sendKYCApprovedEmail(user);
return toIdentityDto(identity);
}
Reject Identity (Admin)
async function rejectIdentity(
identityId: string,
reason: string,
identityRepository: IdentityRepository,
userRepository: UserRepository,
notifier: Notifier,
admin: User
) {
// Check admin permission
if (!(admin.isStaff && admin.hasPermission(Permission.MANAGE_KYC))) {
throw new ForbiddenError();
}
const identity = await identityRepository.get(identityId);
if (identity.status !== IdentityStatus.PENDING) {
throw new IdentityNotRejectable();
}
// Reset to draft so user can fix and resubmit
await identityRepository.update(identityId, {
status: IdentityStatus.DRAFT,
});
// Notify user with rejection reason
const user = await userRepository.get(identity.userId);
await notifier.sendKYCRejectedEmail(user, reason);
return toIdentityDto(identity);
}
List Pending Identities (Admin)
async function listPendingIdentities(
identityRepository: IdentityRepository,
userRepository: UserRepository,
admin: User,
pagination: Pagination
) {
// Check admin permission
if (!(admin.isStaff && admin.hasPermission(Permission.VIEW_KYC))) {
throw new ForbiddenError();
}
const identities = await identityRepository.findAll(
{ status: IdentityStatus.PENDING },
pagination
);
// Enrich with user data
const userIds = identities.results.map(i => i.userId);
const users = await userRepository.findByIds(userIds);
const userMap = new Map(users.map(u => [u.id, u]));
return {
...identities,
results: identities.results.map(identity => ({
...toIdentityDto(identity),
user: toUserDto(userMap.get(identity.userId)!),
})),
};
}
Document Upload Flow
1. User uploads ID document
↓
2. File saved to storage (local or cloud)
↓
3. Upload record created with ID
↓
4. Identity.idDocumentId = upload.id
↓
5. Admin views document during review
↓
6. On approval, document marked as verified
Upload handling:
async function uploadIdDocument(
file: File,
storageService: StorageService,
user: User
) {
// Upload file
const uploadId = await storageService.upload(
file,
UploadObjectType.IDENTITY,
user.id,
UploadTag.ID_DOCUMENT
);
return { uploadId };
}
Validation Rules
Identity Creation
export class IdentityCreateValidator extends BaseValidator {
schema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
birthDate: z.coerce.date().refine(
date => {
const age = (Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
return age >= 18;
},
'Must be at least 18 years old'
),
nationality: z.string().min(2),
businessType: z.enum(['individual', 'company']),
business: z.object({
name: z.string().min(2),
registrationNumber: z.string().optional(),
taxId: z.string().optional(),
registrationDocument: z.string().optional(),
}).optional(),
addressLine1: z.string().min(5).max(100),
addressLine2: z.string().max(100).optional(),
city: z.string().min(2).max(50),
state: z.string().max(50).optional(),
postalCode: z.string().max(20).optional(),
country: z.string().min(2),
phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
idDocumentType: z.enum(['passport', 'id_card', 'driver_license']),
idDocumentId: z.string().uuid(),
});
async customValidation(data: any) {
// Verify business details for companies
if (data.businessType === 'company' && !data.business) {
this.errors.business = ['Business details required for companies'];
}
}
}
KYC Workflow
Complete KYC Flow
1. User creates identity (status = draft)
↓
2. User fills in personal information
↓
3. User uploads ID document
↓
4. User fills in address
↓
5. (If company) User adds business details
↓
6. User submits identity (status = pending)
↓
7. Admin receives notification
↓
8. Admin reviews documents
↓
9a. Admin approves:
- identity.status = approved
- user.isKYCVerified = true
- User can now create services
OR
9b. Admin rejects:
- identity.status = draft
- User notified with reason
- User can fix and resubmit
API Endpoints
Create Identity
POST /identities
Authorization: Bearer <access_token>
Content-Type: application/json
{
"firstName": "John",
"lastName": "Doe",
"birthDate": "1990-01-01",
"nationality": "US",
"businessType": "individual",
"addressLine1": "123 Main St",
"city": "New York",
"country": "US",
"phoneNumber": "+1234567890",
"idDocumentType": "passport",
"idDocumentId": "upload-123"
}
Get My Identity
Response:
{
"id": "identity-123",
"userId": "user-456",
"firstName": "John",
"lastName": "Doe",
"status": "draft",
"missingFields": ["idDocumentId", "phoneNumber"],
"canBeSubmitted": false
}
Submit Identity
Approve Identity (Admin)
Reject Identity (Admin)
POST /identities/:id/reject
Authorization: Bearer <access_token>
Content-Type: application/json
{
"reason": "ID document is not clear. Please upload a better quality image."
}
List Pending Identities (Admin)
Best Practices
✅ Do
- Validate all fields – Check completeness before submission
- Encrypt sensitive data – Store ID numbers securely
- Review manually – Don't auto-approve without human review
- Provide clear feedback – Explain rejection reasons
- Allow resubmission – Let users fix and resubmit
- Notify users – Send email on approval/rejection
- Log admin actions – Track who approved/rejected
❌ Don't
- Don't auto-approve – Always require manual review
- Don't expose documents publicly – Restrict access to admins
- Don't delete rejected identities – Keep for audit trail
- Don't skip age verification – Require 18+ for services
- Don't allow multiple identities – One per user
- Don't rush approval – Take time to verify properly
Security Considerations
Document Storage
- Store documents securely (encrypted at rest)
- Restrict access to admin users only
- Use signed URLs for temporary access
- Delete documents after retention period
Personal Data
- Comply with GDPR/data protection laws
- Allow users to download their data
- Implement right to be forgotten
- Secure transmission (HTTPS only)
Admin Access
- Log all KYC actions
- Require specific permission (MANAGE_KYC)
- Two-factor authentication for admins
- Regular audits of admin activities
Summary
The KYC verification system provides:
- Identity verification for platform trust
- Multi-step workflow (draft → pending → approved)
- Admin approval with manual review
- Document uploads for ID verification
- Business verification for companies
- Secure handling of sensitive data
- Rejection with feedback for improvements
This system ensures platform security while maintaining a user-friendly verification process.