Integrating Third-Party Verification APIs

Practical strategies for integrating third-party identity verification APIs, covering adapter patterns, error handling, rate limiting, and provider management in TypeScript.

technical10 min readBy Klivvr Engineering
Share:

No fintech company builds every identity verification capability in-house. Document OCR, sanctions list screening, credit bureau lookups, biometric matching -- these require specialized data, models, and infrastructure that third-party providers have spent years building. The engineering challenge is not whether to integrate external providers but how to do so in a way that is resilient, testable, maintainable, and cost-effective.

At Klivvr, Oasis integrates with multiple third-party verification providers. Over time, we have developed a set of integration patterns that insulate the core application from provider-specific concerns, handle failures gracefully, and allow us to switch or add providers without disrupting the verification pipeline. This article describes those patterns in detail.

The Adapter Pattern: Isolating Provider Dependencies

The single most important pattern for third-party integrations is the adapter. Every external provider is accessed through an adapter that translates between the provider's API and Oasis's internal domain model. The core application never imports provider-specific types or calls provider SDKs directly.

interface VerificationProvider {
  name: string;
  verifyDocument(request: DocumentVerificationRequest): Promise<DocumentVerificationResponse>;
  verifyIdentity(request: IdentityVerificationRequest): Promise<IdentityVerificationResponse>;
  checkWatchlist(request: WatchlistCheckRequest): Promise<WatchlistCheckResponse>;
  healthCheck(): Promise<ProviderHealth>;
}
 
interface DocumentVerificationRequest {
  documentType: DocumentType;
  frontImage: Buffer;
  backImage?: Buffer;
  customerCountry: string;
}
 
interface DocumentVerificationResponse {
  isAuthentic: boolean;
  confidence: number;
  extractedFields: Record<string, string>;
  issues: string[];
  rawProviderResponse?: unknown;
}
 
class ProviderAlphaAdapter implements VerificationProvider {
  name = "provider-alpha";
 
  constructor(
    private client: ProviderAlphaSDK,
    private config: ProviderAlphaConfig
  ) {}
 
  async verifyDocument(
    request: DocumentVerificationRequest
  ): Promise<DocumentVerificationResponse> {
    const providerRequest = {
      doc_type: this.mapDocumentType(request.documentType),
      front_side: request.frontImage.toString("base64"),
      back_side: request.backImage?.toString("base64") || null,
      country_code: request.customerCountry.toUpperCase(),
      webhook_url: null,
      options: {
        extract_fields: true,
        check_authenticity: true,
      },
    };
 
    const providerResponse = await this.client.documents.verify(providerRequest);
 
    return {
      isAuthentic: providerResponse.result.authenticity_status === "GENUINE",
      confidence: providerResponse.result.confidence_score / 100,
      extractedFields: this.normalizeFields(providerResponse.result.extracted_data),
      issues: providerResponse.result.warnings || [],
      rawProviderResponse: providerResponse,
    };
  }
 
  async verifyIdentity(
    request: IdentityVerificationRequest
  ): Promise<IdentityVerificationResponse> {
    const providerRequest = {
      person: {
        first_name: request.firstName,
        last_name: request.lastName,
        birth_date: request.dateOfBirth,
        country: request.country,
      },
      checks: ["identity_verification", "address_verification"],
    };
 
    const providerResponse = await this.client.identity.verify(providerRequest);
 
    return {
      verified: providerResponse.status === "VERIFIED",
      matchScore: providerResponse.match_score / 100,
      checkResults: providerResponse.checks.map((c: any) => ({
        checkType: c.type,
        passed: c.result === "PASS",
        details: c.details,
      })),
    };
  }
 
  async checkWatchlist(
    request: WatchlistCheckRequest
  ): Promise<WatchlistCheckResponse> {
    const providerResponse = await this.client.screening.check({
      name: request.fullName,
      dob: request.dateOfBirth,
      nationality: request.nationality,
    });
 
    return {
      hasMatches: providerResponse.hits.length > 0,
      matches: providerResponse.hits.map((hit: any) => ({
        listName: hit.source_list,
        matchedName: hit.matched_name,
        matchScore: hit.score / 100,
        entityType: hit.entity_type.toLowerCase(),
      })),
      listsChecked: providerResponse.lists_searched,
    };
  }
 
  async healthCheck(): Promise<ProviderHealth> {
    try {
      const start = Date.now();
      await this.client.status.ping();
      return {
        healthy: true,
        latencyMs: Date.now() - start,
        lastChecked: new Date(),
      };
    } catch {
      return {
        healthy: false,
        latencyMs: -1,
        lastChecked: new Date(),
      };
    }
  }
 
  private mapDocumentType(type: DocumentType): string {
    const mapping: Record<DocumentType, string> = {
      PASSPORT: "PASSPORT",
      NATIONAL_ID: "NATIONAL_ID_CARD",
      DRIVERS_LICENSE: "DRIVING_LICENSE",
      UTILITY_BILL: "UTILITY_BILL",
      BANK_STATEMENT: "BANK_STATEMENT",
    };
    return mapping[type] || type;
  }
 
  private normalizeFields(
    providerFields: Record<string, any>
  ): Record<string, string> {
    const normalized: Record<string, string> = {};
    const fieldMapping: Record<string, string> = {
      first_name: "first_name",
      last_name: "last_name",
      full_name: "full_name",
      birth_date: "date_of_birth",
      document_no: "document_number",
      expiry: "expiry_date",
      nationality_code: "nationality",
      address_line1: "address_line_1",
      address_city: "address_city",
    };
 
    for (const [providerKey, internalKey] of Object.entries(fieldMapping)) {
      if (providerFields[providerKey]) {
        normalized[internalKey] = String(providerFields[providerKey]);
      }
    }
 
    return normalized;
  }
}

The adapter handles three responsibilities: translating request formats (Oasis domain types to provider-specific formats), translating response formats (provider-specific formats back to Oasis domain types), and normalizing data (mapping provider-specific field names and value ranges to a consistent internal representation). The core application remains blissfully unaware of any provider's API quirks.

Provider Management and Routing

With multiple providers integrated, the system needs logic to decide which provider to use for each request. This decision depends on the document type, the customer's country, the provider's current health, and cost considerations.

interface ProviderCapability {
  provider: VerificationProvider;
  supportedDocumentTypes: DocumentType[];
  supportedCountries: string[];
  costPerCheck: number;
  averageLatencyMs: number;
  priority: number;
}
 
class ProviderRouter {
  private capabilities: ProviderCapability[] = [];
  private healthCache: Map<string, ProviderHealth> = new Map();
 
  register(capability: ProviderCapability): void {
    this.capabilities.push(capability);
  }
 
  async selectProvider(
    documentType: DocumentType,
    country: string
  ): Promise<VerificationProvider> {
    const candidates = this.capabilities
      .filter(
        (c) =>
          c.supportedDocumentTypes.includes(documentType) &&
          c.supportedCountries.includes(country)
      )
      .sort((a, b) => a.priority - b.priority);
 
    if (candidates.length === 0) {
      throw new NoProviderAvailableError(
        `No provider supports ${documentType} for ${country}`
      );
    }
 
    for (const candidate of candidates) {
      const health = await this.getHealthStatus(candidate.provider);
      if (health.healthy) {
        return candidate.provider;
      }
    }
 
    throw new AllProvidersUnhealthyError(
      candidates.map((c) => c.provider.name)
    );
  }
 
  async selectProviderWithFallback(
    documentType: DocumentType,
    country: string
  ): Promise<VerificationProvider[]> {
    const candidates = this.capabilities
      .filter(
        (c) =>
          c.supportedDocumentTypes.includes(documentType) &&
          c.supportedCountries.includes(country)
      )
      .sort((a, b) => a.priority - b.priority);
 
    const healthyProviders: VerificationProvider[] = [];
 
    for (const candidate of candidates) {
      const health = await this.getHealthStatus(candidate.provider);
      if (health.healthy) {
        healthyProviders.push(candidate.provider);
      }
    }
 
    if (healthyProviders.length === 0) {
      throw new AllProvidersUnhealthyError(
        candidates.map((c) => c.provider.name)
      );
    }
 
    return healthyProviders;
  }
 
  private async getHealthStatus(
    provider: VerificationProvider
  ): Promise<ProviderHealth> {
    const cached = this.healthCache.get(provider.name);
 
    if (cached && Date.now() - cached.lastChecked.getTime() < 30_000) {
      return cached;
    }
 
    const health = await provider.healthCheck();
    this.healthCache.set(provider.name, health);
    return health;
  }
}

Health checks are cached for 30 seconds to avoid hammering provider APIs while still detecting outages quickly. The router returns providers in priority order, allowing the caller to implement fallback logic.

Rate Limiting and Cost Control

Third-party verification APIs are typically billed per call, and most impose rate limits. Exceeding rate limits causes requests to fail, and uncontrolled API calls can generate surprising invoices.

interface RateLimitConfig {
  provider: string;
  maxRequestsPerSecond: number;
  maxRequestsPerDay: number;
  maxMonthlyCost: number;
  costPerRequest: number;
}
 
class ProviderRateLimiter {
  private counters: Map<string, RateLimitCounters> = new Map();
 
  constructor(private configs: RateLimitConfig[]) {
    for (const config of configs) {
      this.counters.set(config.provider, {
        secondBucket: { count: 0, resetAt: Date.now() + 1000 },
        dailyCount: 0,
        dailyResetAt: this.endOfDay(),
        monthlyCost: 0,
        monthlyResetAt: this.endOfMonth(),
      });
    }
  }
 
  async acquirePermit(providerName: string): Promise<RateLimitPermit> {
    const config = this.configs.find((c) => c.provider === providerName);
    if (!config) {
      throw new Error(`No rate limit config for provider: ${providerName}`);
    }
 
    const counters = this.counters.get(providerName)!;
    this.resetExpiredBuckets(counters);
 
    if (counters.secondBucket.count >= config.maxRequestsPerSecond) {
      const waitMs = counters.secondBucket.resetAt - Date.now();
      return {
        granted: false,
        reason: "per_second_limit",
        retryAfterMs: Math.max(0, waitMs),
      };
    }
 
    if (counters.dailyCount >= config.maxRequestsPerDay) {
      return {
        granted: false,
        reason: "daily_limit",
        retryAfterMs: counters.dailyResetAt - Date.now(),
      };
    }
 
    if (counters.monthlyCost + config.costPerRequest > config.maxMonthlyCost) {
      return {
        granted: false,
        reason: "monthly_cost_limit",
        retryAfterMs: counters.monthlyResetAt - Date.now(),
      };
    }
 
    counters.secondBucket.count += 1;
    counters.dailyCount += 1;
    counters.monthlyCost += config.costPerRequest;
 
    return { granted: true, reason: null, retryAfterMs: 0 };
  }
 
  private resetExpiredBuckets(counters: RateLimitCounters): void {
    const now = Date.now();
 
    if (now >= counters.secondBucket.resetAt) {
      counters.secondBucket = { count: 0, resetAt: now + 1000 };
    }
 
    if (now >= counters.dailyResetAt) {
      counters.dailyCount = 0;
      counters.dailyResetAt = this.endOfDay();
    }
 
    if (now >= counters.monthlyResetAt) {
      counters.monthlyCost = 0;
      counters.monthlyResetAt = this.endOfMonth();
    }
  }
 
  private endOfDay(): number {
    const d = new Date();
    d.setHours(23, 59, 59, 999);
    return d.getTime();
  }
 
  private endOfMonth(): number {
    const d = new Date();
    d.setMonth(d.getMonth() + 1, 0);
    d.setHours(23, 59, 59, 999);
    return d.getTime();
  }
}

The rate limiter enforces three tiers of limits: per-second (to avoid bursting past provider rate limits), per-day (to catch runaway processes), and per-month by cost (to prevent bill shock). When a limit is reached, the system can either queue the request, fall back to a different provider, or return a degraded response.

Testing with Provider Simulators

External APIs cannot be called in unit tests. They are slow, expensive, nondeterministic, and often require production credentials. We solve this with provider simulators that implement the same adapter interface but return deterministic responses.

class SimulatedProviderAlpha implements VerificationProvider {
  name = "simulated-provider-alpha";
 
  private documentResponses: Map<string, DocumentVerificationResponse> = new Map();
  private defaultDocumentResponse: DocumentVerificationResponse = {
    isAuthentic: true,
    confidence: 0.95,
    extractedFields: {
      full_name: "John Doe",
      date_of_birth: "1990-01-15",
      document_number: "AB1234567",
      nationality: "GB",
    },
    issues: [],
  };
 
  configureDocumentResponse(
    documentType: string,
    response: DocumentVerificationResponse
  ): void {
    this.documentResponses.set(documentType, response);
  }
 
  async verifyDocument(
    request: DocumentVerificationRequest
  ): Promise<DocumentVerificationResponse> {
    const configured = this.documentResponses.get(request.documentType);
    return configured || this.defaultDocumentResponse;
  }
 
  async verifyIdentity(
    request: IdentityVerificationRequest
  ): Promise<IdentityVerificationResponse> {
    return {
      verified: true,
      matchScore: 0.92,
      checkResults: [
        { checkType: "identity", passed: true, details: "Name and DOB match" },
        { checkType: "address", passed: true, details: "Address confirmed" },
      ],
    };
  }
 
  async checkWatchlist(
    request: WatchlistCheckRequest
  ): Promise<WatchlistCheckResponse> {
    return {
      hasMatches: false,
      matches: [],
      listsChecked: ["OFAC", "EU_SANCTIONS", "UN_SANCTIONS"],
    };
  }
 
  async healthCheck(): Promise<ProviderHealth> {
    return { healthy: true, latencyMs: 5, lastChecked: new Date() };
  }
}
 
class FailingProviderSimulator implements VerificationProvider {
  name = "failing-provider";
  private failureMode: "timeout" | "error" | "rate_limited" = "error";
 
  setFailureMode(mode: "timeout" | "error" | "rate_limited"): void {
    this.failureMode = mode;
  }
 
  async verifyDocument(): Promise<never> {
    return this.fail();
  }
 
  async verifyIdentity(): Promise<never> {
    return this.fail();
  }
 
  async checkWatchlist(): Promise<never> {
    return this.fail();
  }
 
  async healthCheck(): Promise<ProviderHealth> {
    return { healthy: false, latencyMs: -1, lastChecked: new Date() };
  }
 
  private async fail(): Promise<never> {
    switch (this.failureMode) {
      case "timeout":
        await new Promise((resolve) => setTimeout(resolve, 30_000));
        throw new Error("Request timed out");
      case "rate_limited":
        throw new ProviderRateLimitedError("Rate limit exceeded", 60);
      case "error":
      default:
        throw new ProviderUnavailableError("Service unavailable");
    }
  }
}

The simulated provider returns configurable responses, allowing tests to cover all code paths: successful verification, failed verification, partial matches, and various error conditions. The failing provider simulator specifically tests the resilience patterns (retry logic, fallback chains, circuit breakers) in isolation.

Webhook Handling for Asynchronous Providers

Some verification providers work asynchronously. You submit a verification request and receive a webhook callback when the result is ready. Handling webhooks introduces additional complexity around idempotency, ordering, and authentication.

interface WebhookPayload {
  eventType: string;
  requestId: string;
  timestamp: string;
  data: Record<string, unknown>;
  signature: string;
}
 
class WebhookHandler {
  constructor(
    private signatureVerifier: WebhookSignatureVerifier,
    private idempotencyStore: IdempotencyStore,
    private verificationStore: VerificationRequestStore,
    private eventBus: EventBus
  ) {}
 
  async handleWebhook(
    providerName: string,
    payload: WebhookPayload,
    headers: Record<string, string>
  ): Promise<WebhookResult> {
    const isValid = await this.signatureVerifier.verify(
      providerName,
      payload,
      headers
    );
 
    if (!isValid) {
      return { accepted: false, reason: "Invalid signature" };
    }
 
    const idempotencyKey = `${providerName}:${payload.requestId}:${payload.eventType}`;
    const alreadyProcessed = await this.idempotencyStore.exists(idempotencyKey);
 
    if (alreadyProcessed) {
      return { accepted: true, reason: "Already processed (idempotent)" };
    }
 
    await this.idempotencyStore.mark(idempotencyKey);
 
    const verificationRequest = await this.verificationStore.findByProviderRequestId(
      providerName,
      payload.requestId
    );
 
    if (!verificationRequest) {
      return { accepted: false, reason: "Unknown request ID" };
    }
 
    await this.eventBus.publish(`verification.${payload.eventType}`, {
      verificationRequestId: verificationRequest.id,
      providerName,
      providerData: payload.data,
    });
 
    return { accepted: true, reason: "Processed" };
  }
}

Signature verification prevents spoofed webhooks. Idempotency handling ensures that redelivered webhooks (which happen frequently with webhook-based APIs) do not cause duplicate processing. The handler translates the webhook payload into a domain event and publishes it to the internal event bus, allowing the verification pipeline to resume processing.

Conclusion

Third-party verification API integration is one of those areas where getting the architecture right early pays dividends for years. The adapter pattern isolates provider dependencies and makes switching providers a localized change. The provider router with health checks enables automatic failover. Rate limiting and cost controls prevent operational surprises. Simulators enable thorough testing without external dependencies. And webhook handling with idempotency ensures reliable asynchronous processing.

The key principle is to treat external providers as unreliable by default. They will go down, they will change their APIs, they will be slow, and they will return unexpected responses. Building resilience into every integration point, through timeouts, retries, fallbacks, and circuit breakers, ensures that provider issues do not become customer-facing incidents.

Related Articles

business

Data Privacy in Customer Onboarding

Strategies for protecting customer data during the onboarding process, covering data minimization, encryption, consent management, and regulatory compliance.

9 min read
technical

Risk-Based Authentication Strategies

Implementing risk-based authentication that adapts security requirements to the threat level of each request, balancing security with user experience in TypeScript.

9 min read