A card number is not one fact. It is three stacked together: a prefix that identifies the issuing bank and product, an account number the bank assigned, and a final check digit that exists so typing mistakes get caught before they cost a network round trip. Each part answers a different question, and each part carries a different amount of risk when it passes through your code.
This lesson is about the first and last of those parts. The check digit tells you whether a number is well-formed. The prefix, called the BIN (bank identification number, or IIN in the ISO standard), tells you who issued the card, whether it is debit or credit, and which country it belongs to. Neither one tells you whether the card has money on it, whether it is stolen, or whether the person typing it owns it. Only an authorization through your payment processor answers those. Keep that boundary in mind and the rest of this lesson is straightforward.
01Start with PCI scope, not code
Before you write a line, decide which digits your servers are allowed to see. This is the decision that matters most, and it is easy to get wrong by accident.
PCI DSS is the card industry's security standard. It applies to every system that stores, processes or transmits a full card number (the PAN). A server that receives a PAN in a request body is in scope, even if it never writes the number down. So is the log pipeline that captured that request body, and the error tracker that recorded it when something threw. Scope spreads along every path the number travels.
Modern checkouts avoid this by never letting the PAN reach the merchant at all. The card form is hosted by the processor (an iframe or a hosted page), the browser sends the number straight to the processor, and your server receives a token. If that is your setup, keep it. Nothing in this lesson is worth breaking it.
What you can still have, without a PAN:
- The BIN. The first six or eight digits. Processors expose it on the token or payment method object, and card-form libraries can hand it to you as the user types. PCI DSS treats the BIN on its own as data you can display and keep, which is why receipts show it.
- The last four. For display and for support conversations.
- The brand and length, which come from the BIN.
That is enough to run a BIN lookup, and a BIN lookup is where most of the useful signal lives.
Your checkout uses the processor's hosted card fields, so your server only ever receives a token. A teammate wants to add a server route that accepts the full card number so you can call a validation API on it. What is the main cost of that change?
02The Luhn check, done locally
The last digit of a card number is a Luhn check digit. Starting from the rightmost digit and moving left, you double every second digit, subtract 9 from any doubled value above 9, and add everything up. If the total is divisible by 10, the number is well-formed. A single mistyped digit always fails, and so do most swaps of two adjacent digits.
This is twelve lines of code with no network call, no credits and no scope implications beyond whatever already touches the number. If you need it, run it in the browser before the number ever leaves the form.
export function luhnValid(input) {
const digits = String(input).replace(/[\s-]/g, '');
if (!/^\d{12,19}$/.test(digits)) return false;
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return sum % 10 === 0;
}
console.log(luhnValid('4242 4242 4242 4242')); // true
console.log(luhnValid('4242 4242 4242 4241')); // falseimport re
def luhn_valid(value: str) -> bool:
digits = re.sub(r"[\s-]", "", str(value))
if not re.fullmatch(r"\d{12,19}", digits):
return False
total = 0
for i, ch in enumerate(reversed(digits)):
d = int(ch)
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
print(luhn_valid("4242 4242 4242 4242")) # True
print(luhn_valid("4242 4242 4242 4241")) # FalseLuhn passing means one thing: the number was not mistyped. Anyone can generate Luhn-valid numbers by the million, and test card generators do exactly that. Treat a pass as "worth sending to the processor" and a fail as "show the user a typo message". Never treat a pass as evidence of anything about the cardholder.
03Card Validator: when you are allowed to hold the number
Card Validator (cardvalidator) runs Luhn, identifies the scheme and returns a masked number, the BIN and the last four. It costs 2 credits per call.
curl "https://api.apiverve.com/v1/cardvalidator?number=4242424242424242" \
-H "x-api-key: YOUR_API_KEY"isValid is the Luhn result. cardNumber comes back masked; bin and last4 are what you keep.
The fields you can rely on on every plan are isValid, isPotentiallyValid, brand, card.niceType, card.code (the security code's name and length: CVV with 3 digits for Visa, CID with 4 for American Express), cardNumber (masked), bin and last4. On paid plans you also get isTestCard, which flags published processor test numbers like the one above, and a riskScore/riskLevel.
isPotentiallyValid is the field people misread. It answers "could this become valid if the user keeps typing?" A Visa prefix with one wrong digit at the end still returns isPotentiallyValid: true alongside isValid: false, because the brand and length are plausible even though the checksum is not. It is a field for live input hints, not for a server-side decision.
So when would you send a full number here, given everything above? Three honest cases: you are building or testing a payment integration with test numbers; you run back-office tooling that is already in PCI scope for other reasons; or you are a processor or platform yourself. If none of those describes you, run Luhn locally and send only the BIN to the next API.
04BIN lookup: what the prefix knows
BIN Lookup (binlookup) takes a bin of six to eight digits and returns the card's brand, funding type, issuing bank and country. It costs 10 credits per call.
curl "https://api.apiverve.com/v1/binlookup?bin=424242" \
-H "x-api-key: YOUR_API_KEY"The Stripe test BIN is a real record: a UK-issued Visa credit card. country and type are on every plan.
| Field | Plan | What you do with it |
|---|---|---|
brand | all | Show the right logo, route to the right acquirer |
type | all | CREDIT or DEBIT: affects fees and refund policy |
country | all | Issuing country, compared against billing and IP country |
issuer.name | all | Support conversations, issuer-specific rules |
number.lengths, number.code | all | Front-end formatting and security-code length |
category | paid | CLASSIC, BUSINESS, PURCHASING, PREPAID and more |
location.alpha2, location.currency | paid | ISO country code and the issuer's currency |
Prepaid status lives in category, which is a paid-plan field. On a paid key, bin=555555 comes back as a Mastercard debit card with category: "PREPAID". On a free key you still get type and country, and your code must work when category is null.
Notice that country comes back as a full country name (United Kingdom), not an ISO code. If you need to compare it with an IP lookup's country field, which is a two-letter code, either use the paid location.alpha2 or map names to codes yourself. Comparing United Kingdom to GB with === is a bug that will tell you every card is foreign.
A BIN lookup returns type: DEBIT and country: United States. Which conclusion is justified?
05What BIN data cannot tell you
It is worth being blunt here, because most misuse of BIN data comes from asking it questions it cannot answer.
It does not identify a person. Thousands or millions of cards share a BIN. A BIN is a product line at a bank.
It does not say whether the card is stolen. A stolen card has the same BIN as the legitimate one. Fraud signals come from the mismatch between the BIN and everything else: a UK-issued card, a US billing address and an IP address in a third country.
It is not always current or complete. Banks reassign ranges, and an eight-digit BIN may be more specific than the six-digit record your lookup returns. Some ranges have sparse records: 999999 returns a RuPay debit record with issuer.name: null and number.scheme: null. Treat nulls as "unknown", never as "fine".
Prepaid is not fraud. Prepaid cards are how many people without bank accounts pay online, and gift cards are prepaid too. They do carry different risk for subscriptions (they run out) and for chargebacks. Use them as a reason to review or to require a smaller first charge, not as a reason to block.
06A pre-authorization check you can ship
Put together, the server side takes a BIN, looks it up once per BIN, and returns facts your checkout uses. The card number itself never arrives. This version is ESM on Node 18+ and uses the same small client pattern as the rest of the certification.
const BASE = 'https://api.apiverve.com/v1';
const cache = new Map(); // BIN facts change slowly; one lookup per BIN per day is plenty
const DAY = 24 * 60 * 60 * 1000;
async function verve(api, params) {
const res = await fetch(`${BASE}/${api}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw new Error(body?.error || `HTTP ${res.status}`);
return body.data;
}
export async function binFacts(bin) {
const key = String(bin).replace(/\D/g, '').slice(0, 8);
if (key.length < 6) throw new Error('need 6-8 digits');
const hit = cache.get(key);
if (hit && Date.now() - hit.at < DAY) return hit.facts;
const d = await verve('binlookup', { bin: key });
const facts = {
bin: d.bin,
brand: d.brand ?? null,
funding: d.type ?? null, // CREDIT | DEBIT
issuerCountry: d.country ?? null, // uppercase name
issuerCountryCode: d.location?.alpha2 ?? null, // paid plans
prepaid: d.category ? d.category === 'PREPAID' : null, // null = unknown on this plan
issuer: d.issuer?.name ?? null,
};
cache.set(key, { at: Date.now(), facts });
return facts;
}
if (import.meta.url === `file://${process.argv[1]}`) {
console.log(await binFacts('424242'));
}import os, time, re, requests
BASE = "https://api.apiverve.com/v1"
_cache = {}
DAY = 24 * 60 * 60
def verve(api, params):
r = requests.get(f"{BASE}/{api}", params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]}, timeout=3)
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else None
if not r.ok or not body or body.get("status") != "ok":
raise RuntimeError((body or {}).get("error") or f"HTTP {r.status_code}")
return body["data"]
def bin_facts(bin_value: str) -> dict:
key = re.sub(r"\D", "", bin_value)[:8]
if len(key) < 6:
raise ValueError("need 6-8 digits")
hit = _cache.get(key)
if hit and time.time() - hit[0] < DAY:
return hit[1]
d = verve("binlookup", {"bin": key})
category = d.get("category")
facts = {
"bin": d.get("bin"),
"brand": d.get("brand"),
"funding": d.get("type"),
"issuer_country": d.get("country"),
"issuer_country_code": (d.get("location") or {}).get("alpha2"),
"prepaid": (category == "PREPAID") if category else None,
"issuer": (d.get("issuer") or {}).get("name"),
}
_cache[key] = (time.time(), facts)
return facts
if __name__ == "__main__":
print(bin_facts("424242"))Three decisions in that code are worth copying. The input is cut to eight digits before anything else happens, so even if a caller mistakenly sends a full number, only the BIN leaves your process. Prepaid is three-valued (true, false, null) so a free plan never silently reads as "not prepaid". And results are cached per BIN, because a busy checkout sees the same few hundred BINs over and over and there is no reason to pay 10 credits for each repeat.
07Where this goes next
On its own, a BIN lookup produces facts, not decisions. The decision comes from comparing those facts with the other things you know about the order: the billing country, the IP country, whether the email is disposable. You will wire that comparison up in the final lesson of this course, where card, contact and network signals feed one explainable risk score.
Before that, the other half of payments: money going out. Paying sellers, contractors or refunds to a bank account has its own validation problems, and a mistyped IBAN is a far more expensive mistake than a mistyped card. Continue to Validate bank details: IBAN, SWIFT and routing numbers.