Skip to content

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

  1. Draft validation – Check all required fields before submission
  2. Individual requirements – firstName, lastName, birthDate, nationality, address, phone, ID document
  3. Company requirements – All individual requirements + business details
  4. Document uploads – ID document required, business doc for companies
  5. One identity per user – Cannot submit multiple identities

Approval Rules

  1. Admin verification – Manual review of documents
  2. Document validity – Check ID is valid and matches name
  3. Address verification – Verify address is legitimate
  4. Business verification – For companies, verify registration
  5. User update – Set user.isKYCVerified = true on approval

Identity Methods

Can Be Submitted

canBeSubmitted(): boolean {
  return (
    this.status === IdentityStatus.DRAFT &&
    this.getMissingRequiredFields().length === 0
  );
}

Can Be Approved

canBeApproved(): boolean {
  return this.status === IdentityStatus.PENDING;
}

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

GET /identities/me
Authorization: Bearer <access_token>

Response:

{
  "id": "identity-123",
  "userId": "user-456",
  "firstName": "John",
  "lastName": "Doe",
  "status": "draft",
  "missingFields": ["idDocumentId", "phoneNumber"],
  "canBeSubmitted": false
}

Submit Identity

POST /identities/me/submit
Authorization: Bearer <access_token>

Approve Identity (Admin)

POST /identities/:id/approve
Authorization: Bearer <access_token>

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)

GET /identities?status=pending&page=1&limit=20
Authorization: Bearer <access_token>

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.