DEV Community

Cover image for KYC Verification API: Automate Identity Checks
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

KYC Verification API: Automate Identity Checks

Most fintech startups encounter their first fraud ring within the first year of launch.

It starts innocuously. Signups from different emails, different names, different card numbers. Looks like normal growth. Then the chargebacks start. Then more chargebacks. Then the payment processor calls.

A typical case: hundreds of "users" that turn out to be the same person (or small group). They create accounts with stolen cards, extract value, and disappear before anyone notices. Damage can easily reach six figures—fraudulent transactions plus chargeback fees plus a payment processor threatening termination.

The post-mortem was brutal. Every single one of those fraudulent accounts had red flags that should have been caught:

  • 340 used disposable email services
  • 512 had phone numbers from different countries than their billing addresses
  • 623 signed up from IPs that didn't match their claimed locations
  • 78 used cards issued in countries that didn't match anything else

The data was there. Nobody was checking it.

What KYC Actually Is (And Isn't)

KYC—Know Your Customer—is a regulatory requirement for financial services, but the concept applies to any business that needs to verify identity before granting access or processing payments.

What KYC is:

  • Verifying that a user is who they claim to be
  • Checking that provided information is consistent
  • Flagging suspicious patterns before damage occurs

What KYC is not:

  • A silver bullet that catches all fraud
  • A replacement for human judgment on edge cases
  • An excuse to reject legitimate users

The goal isn't to catch every bad actor. It's to make fraud expensive and inconvenient enough that they go somewhere else—while keeping the friction low enough that real users don't bounce.

The Four Pillars of Basic KYC

A solid KYC check verifies four things:

1. Email — Is it real? Does it accept mail? Is it associated with businesses or throwaway services?

2. Phone — Is the number valid? What country is it registered in? Is it a real carrier or a VoIP service?

3. Location — Where is this person actually connecting from? Does it match their claims?

4. Payment — Where was the card issued? What bank? Does it match the user's profile?

Each check provides signal. Combined, they tell a story. And when the story doesn't add up, you investigate before it costs you money.

Building the Verification Functions

Let's build each check, then combine them into a complete pipeline.

Email Verification

Email is your first line of defense. Invalid or suspicious emails correlate strongly with fraud.

async function verifyEmail(email) {
  const res = await fetch(
    `https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`,
    { headers: { 'x-api-key': process.env.APIVERVE_KEY } }
  );
  const { data } = await res.json();

  // Calculate email risk
  let riskScore = 0;
  const flags = [];

  if (!data.isValid) {
    flags.push('invalid_format');
    riskScore += 100; // Instant rejection
  }

  if (!data.isMxValid) {
    flags.push('no_mail_server');
    riskScore += 80; // Domain doesn't accept email
  }

  if (!data.isSmtpValid) {
    flags.push('undeliverable');
    riskScore += 50; // Mailbox doesn't exist or rejects
  }

  // Disposable email detection
  if (data.isFreeEmail && !data.isSmtpValid) {
    flags.push('likely_disposable');
    riskScore += 60;
  }

  // Common throwaway domains (add to this list based on your experience)
  const disposableDomains = ['tempmail.com', 'guerrillamail.com', '10minutemail.com', 'throwaway.email'];
  if (disposableDomains.some(d => data.domain?.endsWith(d))) {
    flags.push('known_disposable');
    riskScore += 100;
  }

  // Positive signals
  if (data.isCompanyEmail) {
    flags.push('corporate_email');
    riskScore -= 20; // Business users are lower risk
  }

  return {
    email,
    valid: data.isValid && data.isMxValid,
    domain: data.domain,
    deliverable: data.isSmtpValid,
    corporate: data.isCompanyEmail,
    free: data.isFreeEmail,
    riskScore: Math.max(0, riskScore),
    flags
  };
}
Enter fullscreen mode Exit fullscreen mode

What we're looking for:

  • isValid: false — Syntax error, immediate rejection
  • isMxValid: false — Domain doesn't accept email, major red flag
  • isSmtpValid: false — Mailbox doesn't exist or actively rejects
  • isCompanyEmail: true — Corporate domains are generally trustworthy
  • isFreeEmail: true + isSmtpValid: false — Likely a disposable service

Phone Verification

Phone numbers add another dimension. International fraud often involves mismatched phone countries.

async function verifyPhone(number, claimedCountry) {
  const res = await fetch(
    `https://api.apiverve.com/v1/phonenumbervalidator?number=${encodeURIComponent(number)}&country=${claimedCountry}`,
    { headers: { 'x-api-key': process.env.APIVERVE_KEY } }
  );
  const { data } = await res.json();

  let riskScore = 0;
  const flags = [];

  if (!data.isvalid) {
    flags.push('invalid_number');
    riskScore += 70;
  }

  // Country mismatch is suspicious
  const phoneCountry = data.detectedCountry;
  if (phoneCountry && phoneCountry !== claimedCountry.toUpperCase()) {
    flags.push('country_mismatch');
    riskScore += 40;
  }

  // VoIP numbers are riskier
  if (data.type === 'voip') {
    flags.push('voip_number');
    riskScore += 25;
  }

  // Premium rate numbers
  if (data.type === 'premium_rate') {
    flags.push('premium_rate');
    riskScore += 50;
  }

  // Mobile/landline are fine
  if (['mobile', 'fixed_line', 'fixed_line_or_mobile'].includes(data.type)) {
    flags.push('standard_carrier');
    riskScore -= 10;
  }

  return {
    number: data.formatted?.e164 || number,
    valid: data.isvalid,
    type: data.type,
    country: phoneCountry,
    carrier: data.carrier?.name || 'Unknown',
    claimedCountry,
    riskScore: Math.max(0, riskScore),
    flags
  };
}
Enter fullscreen mode Exit fullscreen mode

What we're looking for:

  • isvalid: false — Number doesn't exist
  • detectedCountry !== claimedCountry — User claims US, phone is from Nigeria
  • type: 'voip' — Google Voice, Skype, etc. Not inherently bad, but higher risk
  • type: 'mobile' — Normal carrier, good sign

IP Geolocation

The IP reveals where users are actually connecting from—regardless of what they claim on the form.

async function verifyIP(ip, claimedCountry) {
  const res = await fetch(
    `https://api.apiverve.com/v1/iplookup?ip=${encodeURIComponent(ip)}`,
    { headers: { 'x-api-key': process.env.APIVERVE_KEY } }
  );
  const { data } = await res.json();

  let riskScore = 0;
  const flags = [];

  const actualCountry = data.countryCode;

  // Major red flag: IP country doesn't match claimed country
  if (actualCountry && actualCountry !== claimedCountry.toUpperCase()) {
    flags.push('ip_country_mismatch');
    riskScore += 35;
  }

  // High-risk countries (customize based on your fraud data)
  const highRiskCountries = ['NG', 'GH', 'PK', 'BD', 'PH'];
  if (highRiskCountries.includes(actualCountry)) {
    flags.push('high_risk_country');
    riskScore += 30;
  }

  return {
    ip,
    country: data.country,
    countryCode: actualCountry,
    region: data.region,
    city: data.city,
    timezone: data.timezone,
    claimedCountry,
    riskScore: Math.max(0, riskScore),
    flags
  };
}
Enter fullscreen mode Exit fullscreen mode

Important note: IP geolocation isn't definitive. VPNs, corporate proxies, and travel all cause legitimate mismatches. Use it as signal, not judgment.

BIN Lookup (Card Verification)

The BIN (Bank Identification Number) is the first 6-8 digits of a credit card. It identifies the issuing bank and country—information that should match the user's profile.

async function verifyCard(cardNumber, claimedCountry) {
  // Never log or store full card numbers
  const bin = cardNumber.replace(/\s+/g, '').substring(0, 6);

  const res = await fetch(
    `https://api.apiverve.com/v1/binlookup?bin=${bin}`,
    { headers: { 'x-api-key': process.env.APIVERVE_KEY } }
  );
  const { data } = await res.json();

  let riskScore = 0;
  const flags = [];

  // Card issued in different country
  const cardCountry = data.countryCode;
  if (cardCountry && cardCountry !== claimedCountry.toUpperCase()) {
    flags.push('card_country_mismatch');
    riskScore += 40;
  }

  // Prepaid cards are higher risk
  if (data.prepaid === true || data.category?.toLowerCase().includes('prepaid')) {
    flags.push('prepaid_card');
    riskScore += 35;
  }

  // Corporate cards are usually legit
  if (data.category?.toLowerCase().includes('corporate') ||
      data.category?.toLowerCase().includes('business')) {
    flags.push('corporate_card');
    riskScore -= 15;
  }

  return {
    bin,
    brand: data.brand,
    type: data.type,
    category: data.category,
    issuer: data.issuer?.name || 'Unknown',
    issuerCountry: data.country,
    countryCode: cardCountry,
    claimedCountry,
    prepaid: data.prepaid,
    riskScore: Math.max(0, riskScore),
    flags
  };
}
Enter fullscreen mode Exit fullscreen mode

What we're looking for:

  • countryCode !== claimedCountry — User in US with card from Eastern Europe
  • prepaid: true — Often used to obscure identity (but not always fraud)
  • corporate/business category — Usually legitimate business expenses

The Complete KYC Pipeline

Now let's combine everything into a unified verification flow:

async function runKYCVerification(userData) {
  const {
    email,
    phone,
    country,      // User's claimed country
    ip,           // From request headers
    cardNumber    // Optional
  } = userData;

  // Run all verifications in parallel
  const [emailResult, phoneResult, ipResult, cardResult] = await Promise.all([
    verifyEmail(email),
    verifyPhone(phone, country),
    verifyIP(ip, country),
    cardNumber ? verifyCard(cardNumber, country) : Promise.resolve(null)
  ]);

  // Aggregate risk score
  let totalRisk = 0;
  const allFlags = [];

  totalRisk += emailResult.riskScore;
  allFlags.push(...emailResult.flags.map(f => `email:${f}`));

  totalRisk += phoneResult.riskScore;
  allFlags.push(...phoneResult.flags.map(f => `phone:${f}`));

  totalRisk += ipResult.riskScore;
  allFlags.push(...ipResult.flags.map(f => `ip:${f}`));

  if (cardResult) {
    totalRisk += cardResult.riskScore;
    allFlags.push(...cardResult.flags.map(f => `card:${f}`));
  }

  // Cross-reference checks (these combinations are particularly suspicious)

  // Email country (from domain) vs IP country
  // Phone country vs IP country vs Card country
  const countries = new Set([
    phoneResult.country,
    ipResult.countryCode,
    cardResult?.countryCode
  ].filter(Boolean));

  if (countries.size > 2) {
    // Three different countries is very suspicious
    allFlags.push('cross:multiple_countries');
    totalRisk += 50;
  }

  // Determine action based on total risk
  let decision;
  let confidence;

  if (totalRisk >= 150) {
    decision = 'reject';
    confidence = 'high';
  } else if (totalRisk >= 80) {
    decision = 'manual_review';
    confidence = 'medium';
  } else if (totalRisk >= 40) {
    decision = 'approve_with_limits';
    confidence = 'medium';
  } else {
    decision = 'approve';
    confidence = 'high';
  }

  return {
    decision,
    confidence,
    riskScore: totalRisk,
    flags: allFlags,
    verification: {
      email: emailResult,
      phone: phoneResult,
      ip: ipResult,
      card: cardResult
    },
    timestamp: new Date().toISOString()
  };
}
Enter fullscreen mode Exit fullscreen mode

What the Output Looks Like

Here's a real example—a user who should trigger manual review:

{
  "decision": "manual_review",
  "confidence": "medium",
  "riskScore": 95,
  "flags": [
    "email:corporate_email",
    "phone:voip_number",
    "phone:country_mismatch",
    "ip:ip_country_mismatch",
    "card:card_country_mismatch"
  ],
  "verification": {
    "email": {
      "email": "john.smith@acme-corp.com",
      "valid": true,
      "corporate": true,
      "riskScore": 0,
      "flags": ["corporate_email"]
    },
    "phone": {
      "number": "+1-650-555-1234",
      "valid": true,
      "type": "voip",
      "country": "US",
      "claimedCountry": "GB",
      "riskScore": 65,
      "flags": ["voip_number", "country_mismatch"]
    },
    "ip": {
      "country": "Germany",
      "countryCode": "DE",
      "city": "Frankfurt",
      "claimedCountry": "GB",
      "riskScore": 35,
      "flags": ["ip_country_mismatch"]
    },
    "card": {
      "brand": "VISA",
      "type": "CREDIT",
      "issuerCountry": "United States",
      "countryCode": "US",
      "claimedCountry": "GB",
      "riskScore": 40,
      "flags": ["card_country_mismatch"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This user has a legitimate corporate email (good sign), but everything else is mismatched. They claim to be in the UK, but have a US VoIP phone, connect from Germany, and have a US-issued card.

Is this fraud? Maybe not. Could be a British employee of an American company traveling in Germany. But it's suspicious enough to warrant a human look before approving high-value transactions.

Integrating Into Your Signup Flow

app.post('/signup', async (req, res) => {
  const { email, phone, country, cardNumber } = req.body;
  const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;

  try {
    const kyc = await runKYCVerification({
      email,
      phone,
      country,
      ip,
      cardNumber
    });

    // Always log for compliance and fraud analysis
    await logKYCResult({
      email,
      ip,
      result: kyc,
      timestamp: new Date()
    });

    switch (kyc.decision) {
      case 'reject':
        // Don't reveal why - that helps fraudsters
        return res.status(400).json({
          error: 'Unable to verify your information. Please contact support.'
        });

      case 'manual_review':
        const user = await createUser(email, {
          status: 'pending_verification',
          kycResult: kyc
        });

        // Alert compliance team
        await notifyCompliance({
          userId: user.id,
          email,
          riskScore: kyc.riskScore,
          flags: kyc.flags
        });

        return res.json({
          success: true,
          message: 'Account created. Verification may take up to 24 hours.'
        });

      case 'approve_with_limits':
        const limitedUser = await createUser(email, {
          status: 'active',
          limits: {
            dailyTransaction: 500,
            singleTransaction: 100
          },
          kycResult: kyc
        });

        return res.json({
          success: true,
          message: 'Account created with initial limits. Complete verification to increase limits.'
        });

      case 'approve':
        const fullUser = await createUser(email, {
          status: 'active',
          kycResult: kyc
        });

        return res.json({ success: true });
    }

  } catch (err) {
    console.error('KYC verification failed:', err);
    // Fail open or closed based on your risk tolerance
    return res.status(500).json({
      error: 'Verification temporarily unavailable. Please try again.'
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Tuning for Your Business

The risk thresholds above are starting points. You need to tune them based on:

Your fraud rate. If you're getting hit hard, tighten the thresholds. If legitimate users are complaining about rejections, loosen them.

Your business model.

  • High-value B2B SaaS: Weight corporate email heavily, be lenient on location mismatches (business travelers)
  • Consumer fintech: Strict on card mismatches, flag all prepaid cards
  • E-commerce: Focus on card verification, shipping address matching

Your data. After a few months, you'll have fraud data. Analyze it. What patterns do your actual fraudsters show? Adjust weights accordingly.

Geography. "High-risk countries" is not a universal list. It depends on your market. If you serve customers in Nigeria, having Nigerian IPs and phones is expected, not suspicious.

What This Doesn't Replace

Automated KYC is your first line of defense. It catches the obvious stuff. But it doesn't replace:

Document verification. For regulated industries (financial services, healthcare), you need to verify government IDs. This requires specialized services.

Sanctions screening. OFAC, EU sanctions lists, PEP (Politically Exposed Persons) screening—these are separate requirements.

Ongoing monitoring. KYC isn't just at signup. You need to watch for behavioral changes that suggest account takeover or misuse.

Human review. Edge cases need human judgment. The automation triages; humans decide.

The Numbers

What does this cost?

  • Email validation: 1 credit
  • Phone validation: 1 credit
  • IP lookup: 1 credit
  • BIN lookup: 1 credit
  • Total: 4 credits per signup

On the Starter plan ({{plan.starter.price}}/month, {{plan.starter.calls}} credits), that's thousands of verified signups per month. Compare that to:

  • Average chargeback cost: $25-50 per incident (fee + loss + processing)
  • Average fraud loss when not caught: $150-500
  • Single fraud ring (like the one I opened with): $100,000+

Four credits to potentially save thousands. The ROI is obvious.


KYC automation doesn't catch every bad actor. But it catches the lazy ones—the fraudsters who use obvious disposable emails, mismatched phone numbers, and cards from unexpected countries. That's a lot of fraud.

The sophisticated fraudsters who use clean data and legitimate-looking profiles? Those still need human review. But now your compliance team is reviewing dozens of suspicious cases, not thousands.

The Email Validator, Phone Validator, IP Lookup, and BIN Lookup all use the same API key, same response format, same authentication. Four checks, one pipeline, one bill.

Get your API key and start building a verification system that actually works.


Originally published at APIVerve Blog

Top comments (0)