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

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:
Rate limiting: Company registries often limit API calls. Implement exponential backoff and queue management.
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.
Monitoring: Track your false positive rate. At Zenoo, we see teams reduce false positives from 40% to under 8% with proper tuning.
Webhook reliability: UBO data changes frequently. Set up webhooks for real-time updates rather than periodic re-checks.
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




