Here is a classic startup scenario:
You get a Jira ticket to "add identity verification to user onboarding." You check the docs of a vendor like Veriff or Sumsub, install their Web SDK, spin up an Express route to listen to webhooks, and flip a is_verified boolean in your database. It takes a couple of days, works perfectly in the sandbox, and passes QA.
Three months later, your CFO drops into your Slack DM with a screenshot of a $5,000 invoice.
"Hey, we only onboarded 1,500 new users this month. Why are we paying for 5,000 KYC checks?"
As developers, we rarely think about how our API design interacts with a vendor’s billing model. But when it comes to KYC (Know Your Customer), a lazy implementation will burn your company’s runway faster than almost any other integration.
Here is why your code is probably costing your company too much money, and how to refactor your onboarding architecture to fix it.
The Billing Reality: Attempts vs. Successes
Most developers assume KYC is billed per verified user. It usually isn't.
KYC vendors generally bill you in one of two ways:
- Pay-per-Attempt (e.g., Veriff Essential at $0.80/check): You pay every single time a user clicks "Submit" and sends their data to the vendor's servers. If they fail, you still pay.
- Pay-per-Success (e.g., iDenfy at $1.35/check): You only pay when a user actually passes the check. If they get rejected, it costs you $0.
In a perfect world, everyone passes on their first try. In reality, retail users are terrible at taking photos of their documents. They use expired IDs, upload blurry selfies in dark rooms, or accidentally hold up a credit card instead of a driver's license.
In fintech or crypto, user failure rates routinely hit 30% to 50%.
If you are using a pay-per-attempt vendor and your integration lets users blindly retry 5 times with a blurry photo, you just paid $4.00 to reject a single user.
1. Stop Useless API Calls (Client-Side Pre-Validation)
The easiest way to cut costs is to never trigger the KYC SDK if the document is obviously invalid. Before you initialize the vendor's session on your backend:
- Enforce Aspect Ratio and Resolution: If a user uploads an image, check the metadata in JavaScript. If the resolution is too low (e.g., under 1000px), reject it on the client side with a helpful UI tooltip. Do not waste an API call on a thumbnail.
- Run Local OCR on Expired Docs: If you use a lightweight, free OCR library on the frontend, parse the expiration date first. If the ID expired in 2024, block the submission before the vendor's billable SDK runs.
- Simple Input Masking: If the user has to type their document number or name manually before uploading, match it against basic regex. Don't send empty or garbled data to the KYC vendor.
2. Lock Down Your State Machine
A loose database schema is a breeding ground for duplicate KYC charges. If your UI lets a user click "Retry" while a webhook is still processing, you’re double-paying.
Keep a strict PostgreSQL state machine to lock down the onboarding flow.
CREATE TYPE kyc_status AS ENUM (
'NOT_STARTED',
'PENDING_UPLOAD',
'SUBMITTED_TO_VENDOR',
'VERIFIED',
'TEMPORARY_REJECTION',
'PERMANENT_REJECTION'
);
CREATE TABLE user_kyc (
user_id UUID PRIMARY KEY REFERENCES users(id),
status kyc_status DEFAULT 'NOT_STARTED',
attempt_count INT DEFAULT 0,
vendor_session_id VARCHAR(255),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
The Rules of the State:
If status is SUBMITTED_TO_VENDOR or VERIFIED, block any attempts to initialize a new SDK session.
If status is TEMPORARY_REJECTION (e.g., blurry photo), allow a retry but increment attempt_count. Cap retries at 3.
If status is PERMANENT_REJECTION (e.g., suspected fraud or fake document), freeze the UI entirely and flag the user for manual compliance review. Never let a suspicious user run unlimited automated attempts on your dime.
3. Handle Webhook Race Conditions Idempotently
Because identity verification is asynchronous, handling webhooks safely is non-negotiable. If you don't use database row-level locking, a double-fired webhook or a rapid user reload can cause race conditions.
Here is a clean Node.js / Express implementation that uses Knex.js to lock the KYC state during updates:
const express = require('express');
const router = express.Router();
const db = require('../db'); // Your database connection
const { verifyWebhookSignature } = require('../utils/crypto');
router.post('/v1/kyc-webhook', async (req, res) => {
const signature = req.headers['x-kyc-signature'];
const rawBody = req.rawBody;
// 1. Never skip signature validation in production
if (!verifyWebhookSignature(rawBody, signature, process.env.KYC_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { userId, eventType, result } = req.body;
try {
await db.transaction(async (trx) => {
// Use SELECT FOR UPDATE to lock the row and prevent race conditions
const kycRecord = await trx('user_kyc')
.where({ user_id: userId })
.forUpdate()
.first();
if (!kycRecord) {
return res.status(404).send('Record not found');
}
// If they are already verified, ignore stale webhooks
if (kycRecord.status === 'VERIFIED' || kycRecord.status === 'PERMANENT_REJECTION') {
return res.status(200).send('No-op');
}
if (eventType === 'verification.passed') {
await trx('user_kyc')
.where({ user_id: userId })
.update({ status: 'VERIFIED', updated_at: new Date() });
await trx('users')
.where({ id: userId })
.update({ is_verified: true });
}
else if (eventType === 'verification.failed') {
const isFraud = result.reason === 'spoofing' || result.reason === 'fake_document';
const newStatus = isFraud ? 'PERMANENT_REJECTION' : 'TEMPORARY_REJECTION';
await trx('user_kyc')
.where({ user_id: userId })
.update({
status: newStatus,
attempt_count: kycRecord.attempt_count + 1,
updated_at: new Date()
});
}
});
return res.status(200).send('Received');
} catch (err) {
console.error('KYC Webhook Error:', err);
return res.status(500).send('Internal Server Error');
}
});
Don't Forget the "AML Tax"
Many developers build their entire onboarding flow around Veriff’s $0.80 API only to realize during an audit that they also need to run Sanctions and PEP checks (AML screening).
Most entry-level KYC SDKs don't include this out of the box. If you buy AML checks as an add-on, it adds another $0.50 to $1.50 per check.
If your team is regulated (e.g., fintechs under MiCA or FinCEN rules), it is often cheaper and simpler to choose a vendor that bundles both into a single endpoint. For example, Sumsub's Compliance tier costs $1.85, but it bundles identity verification and AML screening in one API call—saving you from writing and maintaining a second integration.
Wrapping Up
Before writing a single line of integration code, sit down with your product manager and look at your target audience:
If you expect high document rejection rates (emerging markets, high-risk sectors), look for a vendor with pay-per-success pricing like iDenfy.
If you have highly tech-savvy users and near-zero fraud risk, a raw pay-per-attempt model like Veriff is the cheapest route.
If you need AML screening and want to keep your backend architecture simple, compare bundled compliance APIs.
If you just want to run your own numbers and compare monthly costs side-by-side based on your expected volume and failure rates, use our interactive KYC Cost Calculator.
For a complete architectural and cost breakdown of 9+ major KYC/AML APIs, including API limits, setup fees, and contract terms, we mapped out all the real developer data over at PrimeBiometry: KYC Pricing 2026: Real Vendor Rates & Developer Breakdown.
How are you handling KYC state lockouts on your backend? Let’s chat in the comments.
Top comments (0)