Skip to main content

Command Palette

Search for a command to run...

Building a KYB system that doesn't hate you: A developer's guide to corporate verification

Step-by-step technical guide to implementing automated KYB verification, focusing on practical API integration and avoiding common pitfalls that cost teams weeks

Updated
6 min read
Building a KYB system that doesn't hate you: A developer's guide to corporate verification

By the end of this tutorial, you will have a working KYB verification system processing corporate identity checks in under 2 minutes instead of the industry average of 5-7 days.

What we are building

Last quarter, I watched a Series B fintech lose a £2M enterprise deal because their KYB check took 11 days. The compliance team was running manual verification across four different providers, missed a sanctioned Ultimate Beneficial Owner, and the prospect walked. This is not rare. According to Thomson Reuters' latest Cost of Compliance report, manual KYB still costs firms £1,200 per verification versus £150 for automated checks.

We are going to build a KYB orchestration layer that coordinates multiple verification steps: company registry lookup, UBO screening, document verification, and risk scoring. The system will handle the 42% of businesses that fail initial checks due to outdated registries (LexisNexis, February 2026) and reduce your false positive rate from the industry average of 40%.

Prerequisites

  • Node.js 18+ with TypeScript
  • API keys for company registry data (we will use a mock service)
  • Basic understanding of webhook handling
  • 30 minutes

Step 1: Define your KYB data types

First, we need to model the corporate entities we are verifying. Every KYB system needs these core types:

interface CompanyEntity {
  id: string;
  name: string;
  registrationNumber: string;
  jurisdiction: string;
  incorporationDate: string;
  status: 'active' | 'dissolved' | 'dormant';
  registeredAddress: Address;
  directors: Director[];
  ubos: UltimateBeneficialOwner[];
}

interface Director {
  name: string;
  dateOfBirth: string;
  nationality: string;
  appointmentDate: string;
  resigned?: string;
}

interface UltimateBeneficialOwner {
  name: string;
  ownershipPercentage: number;
  controlType: 'direct' | 'indirect' | 'voting_rights';
  sanctionsStatus: SanctionsCheckResult;
}

interface Address {
  line1: string;
  line2?: string;
  city: string;
  postalCode: string;
  country: string;
}

interface SanctionsCheckResult {
  isMatch: boolean;
  confidence: number;
  matchedLists: string[];
  lastChecked: string;
}

interface KYBResult {
  companyId: string;
  status: 'pass' | 'fail' | 'review';
  score: number;
  checks: VerificationCheck[];
  processedAt: string;
  nextReviewDate?: string;
}

interface VerificationCheck {
  type: 'registry' | 'sanctions' | 'documents' | 'risk_assessment';
  status: 'pass' | 'fail' | 'pending';
  details: string;
  confidence: number;
}

Step 2: Build the orchestration engine

The key insight here is parallel processing. Instead of running checks sequentially (the mistake that costs teams days), we coordinate multiple verification streams:

class KYBOrchestrator {
  private registryService: CompanyRegistryService;
  private sanctionsService: SanctionsService;
  private documentService: DocumentService;
  private riskEngine: RiskEngine;

  constructor(
    registryService: CompanyRegistryService,
    sanctionsService: SanctionsService,
    documentService: DocumentService,
    riskEngine: RiskEngine
  ) {
    this.registryService = registryService;
    this.sanctionsService = sanctionsService;
    this.documentService = documentService;
    this.riskEngine = riskEngine;
  }

  async verifyCompany(registrationNumber: string, jurisdiction: string): Promise<KYBResult> {
    const startTime = Date.now();

    try {
      // Step 1: Get company data from registry
      const companyData = await this.registryService.lookup(registrationNumber, jurisdiction);

      if (!companyData) {
        return {
          companyId: registrationNumber,
          status: 'fail',
          score: 0,
          checks: [{
            type: 'registry',
            status: 'fail',
            details: 'Company not found in registry',
            confidence: 100
          }],
          processedAt: new Date().toISOString()
        };
      }

      // Step 2: Run parallel checks
      const [sanctionsResults, documentResults, riskScore] = await Promise.all([
        this.runSanctionsChecks(companyData),
        this.documentService.verify(registrationNumber),
        this.riskEngine.assess(companyData)
      ]);

      // Step 3: Combine results
      const allChecks = [
        {
          type: 'registry' as const,
          status: 'pass' as const,
          details: `Verified with ${jurisdiction} registry`,
          confidence: 95
        },
        ...sanctionsResults,
        ...documentResults,
        {
          type: 'risk_assessment' as const,
          status: riskScore > 70 ? 'pass' as const : 'fail' as const,
          details: `Risk score: ${riskScore}/100`,
          confidence: 85
        }
      ];

      const overallStatus = this.determineOverallStatus(allChecks);
      const processingTime = Date.now() - startTime;

      console.log(`KYB verification completed in ${processingTime}ms`);

      return {
        companyId: companyData.id,
        status: overallStatus,
        score: riskScore,
        checks: allChecks,
        processedAt: new Date().toISOString(),
        nextReviewDate: this.calculateNextReview(overallStatus)
      };

    } catch (error) {
      console.error('KYB verification failed:', error);
      throw new Error(`KYB verification failed: ${error.message}`);
    }
  }

  private async runSanctionsChecks(company: CompanyEntity): Promise<VerificationCheck[]> {
    const checks: VerificationCheck[] = [];

    // Check directors
    for (const director of company.directors) {
      const result = await this.sanctionsService.checkPerson(director.name, director.dateOfBirth);
      checks.push({
        type: 'sanctions',
        status: result.isMatch ? 'fail' : 'pass',
        details: `Director ${director.name}: ${result.isMatch ? 'MATCH FOUND' : 'Clear'}`,
        confidence: result.confidence
      });
    }

    // Check UBOs
    for (const ubo of company.ubos) {
      const result = await this.sanctionsService.checkPerson(ubo.name);
      checks.push({
        type: 'sanctions',
        status: result.isMatch ? 'fail' : 'pass',
        details: `UBO ${ubo.name} (${ubo.ownershipPercentage}%): ${result.isMatch ? 'MATCH FOUND' : 'Clear'}`,
        confidence: result.confidence
      });
    }

    return checks;
  }

  private determineOverallStatus(checks: VerificationCheck[]): 'pass' | 'fail' | 'review' {
    const failedChecks = checks.filter(check => check.status === 'fail');
    const lowConfidenceChecks = checks.filter(check => check.confidence < 80);

    if (failedChecks.length > 0) {
      return 'fail';
    }

    if (lowConfidenceChecks.length > 2) {
      return 'review';
    }

    return 'pass';
  }

  private calculateNextReview(status: string): string {
    const now = new Date();
    const daysToAdd = status === 'pass' ? 365 : 30;
    now.setDate(now.getDate() + daysToAdd);
    return now.toISOString();
  }
}

Step 3: Handle the registry integration

This is where most teams get stuck. Company registries are inconsistent, slow, and often down. Here's how we handle UK Companies House as an example:

class CompanyRegistryService {
  private apiKey: string;
  private baseUrl: string;
  private cache: Map<string, CompanyEntity> = new Map();

  constructor(apiKey: string) {
    this.apiKey = apiKey;
    this.baseUrl = 'https://api.company-information.service.gov.uk';
  }

  async lookup(registrationNumber: string, jurisdiction: string): Promise<CompanyEntity | null> {
    const cacheKey = `${jurisdiction}-${registrationNumber}`;

    // Check cache first
    if (this.cache.has(cacheKey)) {
      return this.cache.get(cacheKey)!;
    }

    try {
      const response = await fetch(`${this.baseUrl}/company/${registrationNumber}`, {
        headers: {
          'authorisation': `Basic ${Buffer.from(this.apiKey + ':').toString('base64')}`,
          'Accept': 'application/json'
        },
        timeout: 10000 // 10 second timeout
      });

      if (!response.ok) {
        if (response.status === 404) {
          return null;
        }
        throw new Error(`Registry lookup failed: ${response.status}`);
      }

      const data = await response.json();
      const company = this.transformCompanyData(data);

      // Cache for 1 hour
      this.cache.set(cacheKey, company);
      setTimeout(() => this.cache.delete(cacheKey), 3600000);

      return company;

    } catch (error) {
      console.error(`Registry lookup failed for ${registrationNumber}:`, error);
      throw error;
    }
  }

  private transformCompanyData(rawData: any): CompanyEntity {
    return {
      id: rawData.company_number,
      name: rawData.company_name,
      registrationNumber: rawData.company_number,
      jurisdiction: 'GB',
      incorporationDate: rawData.date_of_creation,
      status: rawData.company_status,
      registeredAddress: {
        line1: rawData.registered_office_address.address_line_1,
        line2: rawData.registered_office_address.address_line_2,
        city: rawData.registered_office_address.locality,
        postalCode: rawData.registered_office_address.postal_code,
        country: rawData.registered_office_address.country
      },
      directors: [], // Would need separate API call
      ubos: [] // Would need separate API call  
    };
  }
}

Step 4: Test your implementation

Now let's test the complete flow:

async function testKYBFlow() {
  const registryService = new CompanyRegistryService(process.env.COMPANIES_HOUSE_API_KEY!);
  const sanctionsService = new MockSanctionsService();
  const documentService = new MockDocumentService();
  const riskEngine = new MockRiskEngine();

  const orchestrator = new KYBOrchestrator(
    registryService,
    sanctionsService, 
    documentService,
    riskEngine
  );

  try {
    // Test with a real UK company number
    const result = await orchestrator.verifyCompany('09382859', 'GB');

    console.log('KYB Result:', {
      status: result.status,
      score: result.score,
      totalChecks: result.checks.length,
      passedChecks: result.checks.filter(c => c.status === 'pass').length
    });

    // This should complete in under 2 minutes
    console.log('Verification completed successfully');

  } catch (error) {
    console.error('KYB test failed:', error);
  }
}

Production considerations

Before deploying to production, address these critical points:

  1. Rate limiting: Company registries often limit API calls. Implement exponential backoff and queue management.

  2. Data retention: Store verification results for audit trails but encrypt PII. The average compliance team faces 35% lower AML fines when they can prove systematic KYB processes.

  3. Monitoring: Track your false positive rate. At Zenoo, we see teams reduce false positives from 40% to under 8% with proper tuning.

  4. Webhook reliability: UBO data changes frequently. Set up webhooks for real-time updates rather than periodic re-checks.

  5. Jurisdiction handling: The system above handles GB companies. Expand gradually - each jurisdiction has different data structures and availability.

When you get this right, you will join the 76% of banks already using automated KYB technology, processing verifications in minutes instead of days. The business impact is immediate: faster onboarding, lower compliance costs, and significantly reduced regulatory risk.

Full implementation guides and API documentation at zenoo.com/docs