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
};
}
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
};
}
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
};
}
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
};
}
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()
};
}
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"]
}
}
}
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.'
});
}
});
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)