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.
Start 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?
Answer the check above to continue.
What 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.
A 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.
Where 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.