AML Compliance Through Technology

How modern fintech companies use technology to meet anti-money laundering obligations efficiently, covering transaction monitoring, sanctions screening, and suspicious activity detection.

business9 min readBy Klivvr Engineering
Share:

Anti-money laundering (AML) compliance is one of the most significant operational burdens facing financial technology companies. Regulations require firms to monitor transactions, screen customers against sanctions lists, report suspicious activity, and maintain detailed records. Failure to comply results in fines that can reach hundreds of millions of dollars, loss of banking partnerships, and reputational damage that is nearly impossible to recover from.

At the same time, AML compliance cannot be treated as an afterthought bolted onto the product. It must be woven into the architecture from the beginning. At Klivvr, Oasis handles the identity and onboarding side of AML compliance, working in concert with other services that monitor ongoing transaction activity. This article discusses the technology strategies we use to meet AML obligations without drowning in manual processes or degrading the customer experience.

The Regulatory Landscape

Before diving into technology, it is important to understand what AML regulations actually require. While specifics vary by jurisdiction, the core obligations are consistent across most markets:

Customer Due Diligence (CDD) requires firms to verify the identity of every customer before establishing a business relationship. Enhanced Due Diligence (EDD) applies to higher-risk customers, such as politically exposed persons (PEPs) or customers from high-risk jurisdictions. Ongoing monitoring requires firms to watch for suspicious transaction patterns. Suspicious Activity Reporting (SAR) requires firms to report certain activities to financial intelligence units. Record keeping requires firms to maintain customer and transaction records for a minimum period, typically five to seven years.

Each of these obligations translates into specific technical capabilities that must be built into the product.

Customer Screening Architecture

Customer screening is the process of checking a customer's identity against sanctions lists, PEP databases, and adverse media sources. This check must happen at onboarding and must be repeated periodically throughout the customer relationship.

interface ScreeningResult {
  customerId: string;
  screenedAt: Date;
  matches: ScreeningMatch[];
  overallRisk: "clear" | "potential_match" | "confirmed_match";
  listsChecked: string[];
}
 
interface ScreeningMatch {
  listSource: string;
  matchedName: string;
  matchScore: number;
  matchType: "exact" | "fuzzy" | "alias";
  listEntry: ListEntryDetails;
}
 
interface ListEntryDetails {
  entityType: "individual" | "organization";
  listedReason: string;
  jurisdictions: string[];
  dateAdded: Date;
  aliases: string[];
}
 
class CustomerScreeningService {
  constructor(
    private screeningProviders: ScreeningProvider[],
    private matchResolver: MatchResolver,
    private auditLog: AuditLogger
  ) {}
 
  async screenCustomer(
    customer: CustomerIdentity
  ): Promise<ScreeningResult> {
    const allMatches: ScreeningMatch[] = [];
    const listsChecked: string[] = [];
 
    for (const provider of this.screeningProviders) {
      const providerResult = await provider.screen({
        fullName: customer.fullName,
        dateOfBirth: customer.dateOfBirth,
        nationality: customer.nationality,
        aliases: customer.knownAliases || [],
      });
 
      allMatches.push(...providerResult.matches);
      listsChecked.push(...providerResult.listsChecked);
    }
 
    const deduplicatedMatches = this.deduplicateMatches(allMatches);
    const resolvedMatches = await this.matchResolver.resolve(
      customer,
      deduplicatedMatches
    );
 
    const overallRisk = this.assessOverallRisk(resolvedMatches);
 
    const result: ScreeningResult = {
      customerId: customer.id,
      screenedAt: new Date(),
      matches: resolvedMatches,
      overallRisk,
      listsChecked: [...new Set(listsChecked)],
    };
 
    await this.auditLog.logScreening(result);
 
    return result;
  }
 
  private deduplicateMatches(matches: ScreeningMatch[]): ScreeningMatch[] {
    const seen = new Map<string, ScreeningMatch>();
 
    for (const match of matches) {
      const key = `${match.listSource}:${match.matchedName}`;
      const existing = seen.get(key);
 
      if (!existing || match.matchScore > existing.matchScore) {
        seen.set(key, match);
      }
    }
 
    return Array.from(seen.values());
  }
 
  private assessOverallRisk(
    matches: ScreeningMatch[]
  ): "clear" | "potential_match" | "confirmed_match" {
    if (matches.length === 0) return "clear";
 
    const hasHighConfidenceMatch = matches.some((m) => m.matchScore >= 0.95);
    if (hasHighConfidenceMatch) return "confirmed_match";
 
    return "potential_match";
  }
}

A critical design decision is how to handle potential matches. An exact name match against a sanctions list is straightforward, but most matches are fuzzy. "Mohammed Al-Rahman" might partially match "Muhammad Al-Rahmon" on a watchlist. The name matching algorithm must be sophisticated enough to catch genuine matches while avoiding an unmanageable number of false positives.

Ongoing Monitoring and Periodic Rescreening

AML compliance does not end at onboarding. Sanctions lists are updated daily. A customer who was clear at onboarding might appear on a sanctions list six months later. Ongoing monitoring requires periodic rescreening of the entire customer base.

interface RescreeningSchedule {
  riskLevel: "low" | "medium" | "high";
  intervalDays: number;
}
 
const RESCREENING_SCHEDULES: RescreeningSchedule[] = [
  { riskLevel: "low", intervalDays: 365 },
  { riskLevel: "medium", intervalDays: 90 },
  { riskLevel: "high", intervalDays: 30 },
];
 
class OngoingMonitoringService {
  constructor(
    private customerStore: CustomerStore,
    private screeningService: CustomerScreeningService,
    private alertService: AlertService,
    private scheduler: TaskScheduler
  ) {}
 
  async scheduleRescreening(): Promise<void> {
    for (const schedule of RESCREENING_SCHEDULES) {
      const dueCustomers = await this.customerStore.findDueForRescreening(
        schedule.riskLevel,
        schedule.intervalDays
      );
 
      for (const customer of dueCustomers) {
        await this.scheduler.enqueue({
          type: "customer-rescreening",
          payload: { customerId: customer.id },
          priority: schedule.riskLevel === "high" ? "urgent" : "normal",
        });
      }
    }
  }
 
  async executeRescreening(customerId: string): Promise<void> {
    const customer = await this.customerStore.findById(customerId);
    const result = await this.screeningService.screenCustomer(customer);
 
    if (result.overallRisk !== "clear") {
      await this.alertService.createAlert({
        type: "rescreening-match",
        customerId,
        severity: result.overallRisk === "confirmed_match" ? "critical" : "warning",
        details: {
          matchCount: result.matches.length,
          topMatch: result.matches[0],
        },
      });
 
      if (result.overallRisk === "confirmed_match") {
        await this.customerStore.updateStatus(customerId, "suspended");
      }
    }
 
    await this.customerStore.updateLastScreeningDate(customerId, new Date());
  }
}

The rescreening frequency is risk-based. Low-risk customers are rescreened annually, medium-risk quarterly, and high-risk monthly. This tiered approach balances thoroughness with cost, as each screening check involves calls to external providers.

Transaction Pattern Analysis

Beyond screening, AML compliance requires monitoring transaction patterns for suspicious activity. While the detailed transaction monitoring lives in a separate service, Oasis contributes customer risk profiles and behavioral baselines that inform those checks.

interface CustomerRiskProfile {
  customerId: string;
  overallRiskScore: number;
  riskFactors: RiskFactor[];
  expectedTransactionPattern: TransactionPattern;
  lastUpdated: Date;
}
 
interface RiskFactor {
  factor: string;
  weight: number;
  score: number;
  source: string;
}
 
interface TransactionPattern {
  expectedMonthlyVolume: { min: number; max: number };
  expectedAverageAmount: number;
  typicalCounterpartyCountries: string[];
  typicalTransactionTypes: string[];
}
 
class CustomerRiskProfileService {
  constructor(
    private customerStore: CustomerStore,
    private transactionHistory: TransactionHistoryService
  ) {}
 
  async buildRiskProfile(customerId: string): Promise<CustomerRiskProfile> {
    const customer = await this.customerStore.findById(customerId);
    const history = await this.transactionHistory.getHistory(customerId, 90);
 
    const riskFactors: RiskFactor[] = [];
 
    riskFactors.push({
      factor: "country-risk",
      weight: 2.0,
      score: this.assessCountryRisk(customer.nationality, customer.residenceCountry),
      source: "customer-data",
    });
 
    riskFactors.push({
      factor: "pep-status",
      weight: 3.0,
      score: customer.isPEP ? 80 : 0,
      source: "screening",
    });
 
    riskFactors.push({
      factor: "account-age",
      weight: 1.0,
      score: this.assessAccountAgeRisk(customer.createdAt),
      source: "customer-data",
    });
 
    riskFactors.push({
      factor: "transaction-velocity",
      weight: 1.5,
      score: this.assessVelocityRisk(history),
      source: "transaction-history",
    });
 
    const overallScore = this.calculateWeightedScore(riskFactors);
 
    return {
      customerId,
      overallRiskScore: overallScore,
      riskFactors,
      expectedTransactionPattern: this.deriveExpectedPattern(history, customer),
      lastUpdated: new Date(),
    };
  }
 
  private assessCountryRisk(nationality: string, residence: string): number {
    const highRiskCountries = new Set(["AF", "IR", "KP", "SY", "YE"]);
    const mediumRiskCountries = new Set(["MM", "LY", "SO", "SS", "VE"]);
 
    if (highRiskCountries.has(nationality) || highRiskCountries.has(residence)) {
      return 90;
    }
    if (mediumRiskCountries.has(nationality) || mediumRiskCountries.has(residence)) {
      return 50;
    }
    return 10;
  }
 
  private assessAccountAgeRisk(createdAt: Date): number {
    const daysSinceCreation =
      (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
    if (daysSinceCreation < 30) return 60;
    if (daysSinceCreation < 90) return 30;
    return 10;
  }
 
  private assessVelocityRisk(history: TransactionSummary): number {
    if (history.transactionCount === 0) return 0;
    const avgDaily = history.transactionCount / 90;
    if (avgDaily > 20) return 70;
    if (avgDaily > 10) return 40;
    return 10;
  }
 
  private calculateWeightedScore(factors: RiskFactor[]): number {
    const totalWeight = factors.reduce((sum, f) => sum + f.weight, 0);
    const weightedSum = factors.reduce((sum, f) => sum + f.score * f.weight, 0);
    return totalWeight > 0 ? weightedSum / totalWeight : 0;
  }
 
  private deriveExpectedPattern(
    history: TransactionSummary,
    customer: Customer
  ): TransactionPattern {
    return {
      expectedMonthlyVolume: {
        min: Math.max(0, history.monthlyAvgCount - history.monthlyStdDevCount * 2),
        max: history.monthlyAvgCount + history.monthlyStdDevCount * 2,
      },
      expectedAverageAmount: history.averageAmount,
      typicalCounterpartyCountries: history.topCounterpartyCountries,
      typicalTransactionTypes: history.topTransactionTypes,
    };
  }
}

Risk profiles are recalculated periodically and after significant events (large transactions, changes in transaction patterns, updated screening results). The profiles feed into the transaction monitoring service, which uses them to calibrate alert thresholds on a per-customer basis.

Record Keeping and Audit Readiness

Regulators can request records going back years. The system must be able to produce a complete history of all compliance-related actions taken for any customer, including screening results, risk assessments, verification decisions, and any alerts or reports filed.

interface ComplianceRecord {
  id: string;
  customerId: string;
  recordType: string;
  content: Record<string, unknown>;
  createdAt: Date;
  retentionExpiresAt: Date;
}
 
class ComplianceRecordStore {
  private readonly RETENTION_YEARS = 7;
 
  async store(record: Omit<ComplianceRecord, "id" | "retentionExpiresAt">): Promise<string> {
    const id = generateUUID();
    const retentionExpiresAt = new Date(
      record.createdAt.getTime() + this.RETENTION_YEARS * 365 * 24 * 60 * 60 * 1000
    );
 
    await this.database.insert("compliance_records", {
      id,
      ...record,
      retentionExpiresAt,
    });
 
    return id;
  }
 
  async getCustomerTimeline(customerId: string): Promise<ComplianceRecord[]> {
    return this.database.query(
      "SELECT * FROM compliance_records WHERE customer_id = $1 ORDER BY created_at ASC",
      [customerId]
    );
  }
 
  async generateRegulatoryReport(
    customerId: string,
    dateRange: DateRange
  ): Promise<RegulatoryReport> {
    const records = await this.database.query(
      `SELECT * FROM compliance_records
       WHERE customer_id = $1
       AND created_at >= $2
       AND created_at <= $3
       ORDER BY created_at ASC`,
      [customerId, dateRange.from, dateRange.to]
    );
 
    return {
      customerId,
      dateRange,
      records,
      generatedAt: new Date(),
      recordCount: records.length,
    };
  }
}

Records are stored in an append-only fashion with no deletion capability in the application layer. Retention periods are enforced by a separate cleanup process that only deletes records after the regulatory retention period has expired.

Balancing Compliance and Customer Experience

The greatest challenge in AML compliance technology is not building the checks themselves but integrating them into the product without creating an adversarial experience. Customers who are asked too many questions feel interrogated. Customers who are blocked without explanation feel frustrated. Customers who are subjected to long delays feel ignored.

The approach we take in Oasis is transparency with proportionality. We explain why we need information, we only ask for what is required for the customer's specific risk level, and we process checks as quickly as technology allows. When manual review is required, we communicate estimated timelines and provide updates.

Ultimately, AML compliance technology should be invisible to the vast majority of customers. The checks happen, the records are kept, the monitoring runs, but the customer experience remains smooth. Only when genuine risk is detected should the system become visible, and even then, the response should be measured and professional.

Conclusion

AML compliance is a non-negotiable aspect of operating a financial services product. The technology strategies described here -- customer screening with fuzzy matching, risk-based ongoing monitoring, customer risk profiling, and comprehensive record keeping -- represent the minimum viable compliance infrastructure.

The key principle is to automate aggressively but escalate intelligently. The system should handle the vast majority of cases automatically, routing only genuinely ambiguous situations to human reviewers. This keeps compliance costs manageable while ensuring that no genuine risk slips through the cracks. As regulations evolve and new risk patterns emerge, the modular architecture of Oasis allows us to add new checks, adjust thresholds, and integrate new data sources without rebuilding the system from scratch.

Related Articles

technical

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.

10 min read
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