CPF/CNPJ validation in a signup or checkout flow
Search volume for "CPF validation API" is high and the honest answer is usually "do it locally, for free, in twenty lines". Here is when that is wrong, and what to do instead.
If your requirement is "reject typos in a CPF/CNPJ field", you do not need an API. The check digits are computed from the number itself; validation is a pure function, offline, microseconds, free. Working code is below and there is a free browser tool to test against.
You need a remote service only when the question is different — see when you actually do need a lookup.
That said, "just validate it" hides several product decisions that are worth getting right, because each one shows up later as a support ticket: where to validate, what to store, how to display, and what to do about the fact that a structurally valid CPF says almost nothing about the person typing it.
Where to validate
Three layers, all cheap, each catching what the previous one missed:
| Layer | What it catches | Cost |
|---|---|---|
| Client-side, on blur | Typos, while the user still remembers what they meant to type | Zero. Best UX-per-line-of-code in the whole flow. |
| Server-side, on write | API clients, imports, scripts, and anyone bypassing your form | Zero. Non-negotiable — client validation is a convenience, not a control. |
| Database constraint | Migrations, backfills, that one-off SQL update | A check constraint on the normalized column. |
The common failure: a customer record with a broken CNPJ sits in the database for eight months and surfaces the first time someone tries to generate a fiscal document for them — usually at month end, usually under time pressure, and the customer is now unreachable. Validate at the boundary where the data enters.
The code
Full derivation, worked arithmetic and the three classic mistakes are in the check-digit reference. The short version:
const digits = (s) => String(s ?? "").replace(/\D/g, "");
function checkDigit(nums, weights) {
const sum = weights.reduce((acc, w, i) => acc + nums[i] * w, 0);
const rest = sum % 11;
return rest < 2 ? 0 : 11 - rest; // NOT (11 - rest) % 10
}
export function isValidCPF(v) {
const d = digits(v);
if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false; // repeated-digit guard
const n = [...d].map(Number);
return checkDigit(n, [10, 9, 8, 7, 6, 5, 4, 3, 2]) === n[9]
&& checkDigit(n, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]) === n[10];
}
export function isValidCNPJ(v) {
const d = digits(v);
if (d.length !== 14 || /^(\d)\1{13}$/.test(d)) return false;
const n = [...d].map(Number);
const w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
return checkDigit(n, w1) === n[12] && checkDigit(n, [6, ...w1]) === n[13];
}
export const isValidTaxId = (v) =>
digits(v).length === 11 ? isValidCPF(v) : isValidCNPJ(v);Ports in Python, PHP, Laravel (as a Rule), Go, C# and Ruby.
Storage and display
The single highest-value decision, and it takes one line:
Persist "11222333000181", never "11.222.333/0001-81". Otherwise
11.222.333/0001-81 and 11222333000181 become two different rows, your unique index
does nothing, and deduplication becomes a project. Add the index on the normalized column and mask in the
view layer.
alter table customers
add column tax_id_digits text
generated always as (regexp_replace(tax_id, '\D', '', 'g')) stored;
create unique index customers_tax_id_unique
on customers (tax_id_digits)
where tax_id_digits is not null and tax_id_digits <> '';A generated column keeps the normalization in the database, so an import script that forgets to normalize cannot create a duplicate.
Input masking
Mask progressively as the user types, and switch format at 11 digits — a CPF is
000.000.000-00 and a CNPJ is 00.000.000/0000-00, and a single field usually
has to accept both:
export function maskTaxId(raw) {
const d = digits(raw).slice(0, 14);
if (d.length <= 11) {
return d
.replace(/^(\d{3})(\d)/, "$1.$2")
.replace(/^(\d{3})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d{1,2})$/, ".$1-$2");
}
return d
.replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d{1,2})$/, "$1-$2");
}Paste handling matters more than people expect: users paste from spreadsheets, with a leading
apostrophe, non-breaking spaces or a trailing tab. Because digits() strips everything
non-numeric, all of that just works — which is the argument for normalizing aggressively rather than
validating the formatted string.
When you actually do need a remote lookup
Check digits answer "is this well-formed". They cannot answer:
- Is this CNPJ registered and active, and what is its legal name and address?
- Is the company's registration suspended or cancelled?
- What are its activity codes (CNAE), and does its declared activity match what it is invoicing me for?
- Does this CPF belong to the person presenting it? (No public API answers this; that is identity verification, a different and regulated product.)
Those are registry-lookup questions. If you are onboarding suppliers, releasing payouts, or have AML/KYC obligations, they are worth paying for — from a provider with a defensible data source.
| Local check digit | Registry lookup | |
|---|---|---|
| Latency | Microseconds | Network round trip |
| Cost per check | Zero | Metered |
| Works offline | Yes | No |
| Rate limits | None | Yes |
| Catches typos | Yes | Yes |
| Catches valid-but-unregistered | No | Yes |
| Sends personal data to a third party | No | Yes |
The right architecture is both, in order: local on every keystroke, remote once, at the moment the answer changes a decision. Calling a paid lookup on every form blur is a common and expensive anti-pattern — it turns a free operation into a metered one and adds latency to typing.
Under the LGPD, a CPF identifies a natural person. Transmitting one to a third-party lookup service is a processing activity that needs a legal basis and belongs in your privacy documentation. Local validation avoids the question entirely, which is a real and often-overlooked argument for doing it locally wherever it suffices.
Test data
Never put a real person's CPF in a fixture. Generate structurally valid synthetic numbers:
export function fakeCPF() {
const base = Array.from({ length: 9 }, () => Math.floor(Math.random() * 10));
const d1 = checkDigit(base, [10, 9, 8, 7, 6, 5, 4, 3, 2]);
const d2 = checkDigit([...base, d1], [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]);
return [...base, d1, d2].join("");
}Or use the free generator and validator, which runs entirely in your browser — nothing is sent anywhere, which also makes it safe to paste a real number into when you are debugging.
Values worth having in your test table: a valid CPF, a valid CNPJ, one of each with the last digit
incremented, 000.000.000-00, 111.111.111-11, a 10-digit string, a 15-digit
string, an empty string, and a CPF pasted with surrounding whitespace.
Where document generation comes in
Validation at the form is prevention. There is a second place it matters: the moment a tax ID gets printed on something a customer or an accountant will read.
FaturaPDF runs the same check on every documento in the payload and returns a 400 naming
the offending party rather than rendering the PDF. It is a last line of defence — the form should have
caught it — but it is the line that prevents a wrong CNPJ from reaching a customer's bookkeeping:
{
"error": "invalid_params",
"message": "emitente.documento \"11.222.333/0001-99\" tem formato de CNPJ mas digito verificador invalido"
}To be clear about scope: there is no standalone validation endpoint, and we do not think there should be — you would be paying for a network round trip to run twenty lines of arithmetic. The validation is part of generating a correct document.
Frequently asked questions
Is there a free CPF/CNPJ validation API?
There are several, but you almost certainly do not need one: the check digits are computed from the number itself, so validation is a pure local function with no network call. Use the code above. Pay for a service only when you need registry data — whether the CNPJ exists, its legal name, its status.
Does check-digit validation prove the person exists?
No. It proves the number is internally consistent. Roughly one in eleven random 9-digit bases yields a structurally valid CPF, so it catches typos, not fabrication. Existence and status require a registry lookup; identity requires a regulated verification product.
Should I store the CPF masked or as digits?
Digits only, with a unique index on the normalized column, and mask at render time. Storing the mask means the same document can exist twice in different formats, which quietly breaks deduplication and lookups.
How do I handle the alphanumeric CNPJ?
Widen your column and input mask to 14 alphanumeric characters now — that part is safe and cheap. The check-digit rule uses the same mod-11 weights with each character valued at charCode - 48; see the reference, and confirm the current official rule before switching your validator.
Where FaturaPDF actually helps
We do not sell a validation endpoint — the code below is free and better. What FaturaPDF does is refuse to print an invalid CPF/CNPJ on a document: send a bad one and you get a 400 naming the party, instead of a PDF with a wrong tax ID that reaches a customer.
Get an API key on RapidAPI → Or try the free browser generator