close

DEV Community

Cover image for Your First BIN Lookup Integration in Python (With Real Fraud Use Cases)
Sam
Sam

Posted on

Your First BIN Lookup Integration in Python (With Real Fraud Use Cases)

A merchant friend of mine runs a small Shopify store selling mechanical keyboards. Last spring, he messaged me in a panic. Overnight, 47 orders had gone through. All digital gift cards. All different cards. All shipping to a single address in a country he had never sold to before.

By the time his payment processor flagged the pattern, the chargebacks were already rolling in. Three weeks later, he had eaten $4,200 in losses, spent hours filing disputes, and nearly lost his merchant account.

Here is the part that stings: a five-dollar API call on each of those orders would have caught it.

That is what BIN lookups do. The Bank Identification Number is the first six to eight digits of any payment card. It tells you who issued the card, what country it came from, whether it is a credit or debit card, and whether it is prepaid. None of that stops a determined fraudster forever, but it stops lazy fraud instantly and gives your risk engine real data to work with.

This tutorial walks you through integrating a BIN API in Python, handling it properly, and turning the response fields into actual fraud rules you can ship.


TL;DR

  • A BIN is the first 6-8 digits of a card number. It identifies the issuing bank, country, card type, and brand.
  • You call a BIN API with a GET request, pass your API key in headers, and get a JSON object back.
  • Key fields for fraud: Brand, Type (debit/credit), CountryName, and whether the card is prepaid.
  • Handle 429 rate limit responses with exponential backoff. Always set a timeout.
  • Four rule examples are included at the end you can drop into a checkout flow today.

What Is a BIN and Why Does It Matter for Fraud?

Every Visa, Mastercard, Amex, or Discover card starts with a block of digits that identifies the issuing bank. That block is the BIN, sometimes called the IIN (Issuer Identification Number). Originally it was always six digits. Most modern implementations support eight.

When a card number hits your checkout, you already have the BIN before the customer even clicks "place order." That gives you a narrow window to run a check that can tell you quite a lot:

  • Brand: Is it Visa, Mastercard, Amex?
  • Type: Is it credit, debit, or prepaid?
  • Issuer: Which bank issued it, and from which country?
  • Country mismatch: Is the billing address in one country while the card was issued in another?

None of these signals alone proves fraud. But combined with your order data, they give you patterns you can act on.


How Do You Call a BIN API in Python?

The mechanics are simple. You send a GET request to the lookup endpoint with the BIN as a query parameter, and your credentials in the request headers.

Here is a minimal working example using the requests library:

import requests

API_KEY = "bsl_your_api_key_here"
USER_ID = "your-uuid-here"
BASE_URL = "https://api.binsearchlookup.com/lookup"

def lookup_bin(bin_number: str) -> dict:
    headers = {
        "X-API-Key": API_KEY,
        "X-User-ID": USER_ID,
    }
    params = {"bin": bin_number}

    response = requests.get(BASE_URL, headers=headers, params=params, timeout=5)
    response.raise_for_status()
    return response.json()

result = lookup_bin("551029")
print(result)
Enter fullscreen mode Exit fullscreen mode

That is the whole happy path. The API returns JSON immediately, typically under 100ms. The response for BIN 551029 looks like this:

{
  "bin": "551029",
  "success": true,
  "data": {
    "BIN": "551029",
    "Brand": "MASTERCARD",
    "Type": "DEBIT",
    "Category": "STANDARD",
    "Issuer": "BANK OF MONTREAL",
    "IssuerPhone": "+18772255266",
    "IssuerUrl": "http://www.bmo.com",
    "isoCode2": "CA",
    "isoCode3": "CAN",
    "CountryName": "CANADA",
    "similarBins": [],
    "cached": false,
    "responseTime": 2
  },
  "statusCode": 200,
  "responseTime": 17
}
Enter fullscreen mode Exit fullscreen mode

The data object is where you will spend most of your time. Notice Brand, Type, CountryName, and isoCode2. Those four fields cover the majority of fraud rules you will want to write.

If you want to test the API without wiring up a full integration, binsearchlookup.com offers a free tier with 1,000 requests per month. That is enough to validate your logic and run experiments before committing to a paid plan.


How Do You Handle Errors, Timeouts, and Rate Limits?

The happy path is 10 lines of code. The production-ready version is where most tutorials stop being honest with you.

Timeouts

Always pass a timeout argument to requests.get. Without it, a slow DNS response or a hiccup on the API side will hang your checkout thread indefinitely.

response = requests.get(BASE_URL, headers=headers, params=params, timeout=5)
Enter fullscreen mode Exit fullscreen mode

Five seconds is generous for an API that normally responds in under 100ms. You may want to tighten it to two seconds if BIN lookup is blocking your order flow.

HTTP Errors

raise_for_status() converts any 4xx or 5xx into a Python exception, which is fine for development. In production, you want to handle specific codes differently.

import requests
from requests.exceptions import Timeout, ConnectionError

def lookup_bin_safe(bin_number: str) -> dict | None:
    headers = {
        "X-API-Key": API_KEY,
        "X-User-ID": USER_ID,
    }
    params = {"bin": bin_number}

    try:
        response = requests.get(
            BASE_URL, headers=headers, params=params, timeout=5
        )

        if response.status_code == 400:
            # Bad BIN format - your problem, not theirs
            print(f"Invalid BIN submitted: {bin_number}")
            return None

        if response.status_code == 401:
            raise RuntimeError("API credentials are invalid or missing")

        if response.status_code == 429:
            # Rate limited - handle with backoff (see next section)
            raise RateLimitError("Rate limit exceeded")

        response.raise_for_status()
        return response.json()

    except Timeout:
        print(f"BIN lookup timed out for {bin_number}")
        return None

    except ConnectionError:
        print("Could not reach BIN API")
        return None
Enter fullscreen mode Exit fullscreen mode

The None return on timeout is intentional. Whether a failed lookup should block the order or let it through is a business decision, not a technical one. Make that call explicitly rather than letting an exception bubble up unhandled.

Rate Limit Handling With Exponential Backoff

The free tier allows 20 requests per minute. The starter tier allows 60. If you hit the ceiling, you get a 429 with error code RATE_LIMIT_EXCEEDED.

Do not retry immediately. Use backoff:

import time

class RateLimitError(Exception):
    pass

def lookup_bin_with_retry(bin_number: str, max_retries: int = 3) -> dict | None:
    for attempt in range(max_retries):
        try:
            return lookup_bin_safe(bin_number)
        except RateLimitError:
            wait = 2 ** attempt  # 1s, 2s, 4s
            print(f"Rate limited. Retrying in {wait}s...")
            time.sleep(wait)

    print(f"Giving up on BIN lookup for {bin_number} after {max_retries} attempts")
    return None
Enter fullscreen mode Exit fullscreen mode

In a high-volume checkout flow, you probably want to queue lookups rather than retry inline. But for small-to-medium traffic, this gets you through the rough spots.


What Can You Do With BIN Data in Your Code?

This is the question that matters. The API is not the point. The point is what you build on top of it.

Extracting the Fields You Care About

def parse_bin_response(response: dict) -> dict | None:
    if not response or not response.get("success"):
        return None

    data = response.get("data", {})

    return {
        "brand": data.get("Brand", "").upper(),
        "card_type": data.get("Type", "").upper(),     # CREDIT, DEBIT
        "category": data.get("Category", "").upper(),  # STANDARD, PREMIUM, etc.
        "issuer": data.get("Issuer", ""),
        "country_code": data.get("isoCode2", ""),
        "country_name": data.get("CountryName", ""),
        "is_prepaid": data.get("Type", "").upper() == "PREPAID",
    }
Enter fullscreen mode Exit fullscreen mode

Notice the is_prepaid flag. Prepaid cards show up as Type: PREPAID in the response. They are not inherently fraudulent, but in certain product categories (high-value electronics, digital goods, gift cards), prepaid usage rates in chargebacks are disproportionately high.

Checking Country Mismatch

The most common signal you will want to build is a mismatch between the card-issuing country and the order's billing or shipping country.

def check_country_mismatch(bin_info: dict, billing_country: str) -> bool:
    card_country = bin_info.get("country_code", "")
    return card_country != billing_country.upper()
Enter fullscreen mode Exit fullscreen mode

If your customer says they are in Germany but the card was issued in Nigeria, that is worth flagging. Not blocking automatically, but flagging.


The Fraud Rule Playbook: 4 Rules You Can Ship Today

These are not theoretical. They are the patterns that catch the most common carding attacks and reseller abuse. Each one is a function that takes a parsed BIN response plus some order context and returns a risk signal.

Rule 1: Flag Prepaid Cards on Digital Goods Orders

def flag_prepaid_digital(bin_info: dict, product_type: str) -> dict:
    is_digital = product_type in ("gift_card", "software_license", "subscription")
    is_prepaid = bin_info.get("is_prepaid", False)

    if is_prepaid and is_digital:
        return {
            "flagged": True,
            "reason": "Prepaid card used for digital goods",
            "action": "manual_review",
        }
    return {"flagged": False}
Enter fullscreen mode Exit fullscreen mode

Rule 2: Block High-Risk Country and Card-Country Mismatch

HIGH_RISK_COUNTRIES = {"NG", "RO", "UA", "VN", "ID"}

def flag_high_risk_origin(bin_info: dict, billing_country: str) -> dict:
    card_country = bin_info.get("country_code", "")
    mismatch = card_country != billing_country.upper()
    high_risk_origin = card_country in HIGH_RISK_COUNTRIES

    if mismatch and high_risk_origin:
        return {
            "flagged": True,
            "reason": f"Card issued in {card_country}, billing address in {billing_country}",
            "action": "block",
        }
    return {"flagged": False}
Enter fullscreen mode Exit fullscreen mode

Adjust HIGH_RISK_COUNTRIES based on your actual chargeback data. This is a starting point, not a permanent list.

Rule 3: Elevated Review for First-Time Amex Users

Amex cards carry higher chargeback rights for cardholders, which makes them attractive for friendly fraud. It is worth adding friction for first-time Amex orders above a threshold.

def flag_new_amex_high_value(
    bin_info: dict, order_total: float, customer_order_count: int
) -> dict:
    is_amex = bin_info.get("brand") == "AMERICAN EXPRESS"
    is_new = customer_order_count == 0
    is_high_value = order_total > 300

    if is_amex and is_new and is_high_value:
        return {
            "flagged": True,
            "reason": "First-time customer, Amex card, high-value order",
            "action": "require_verification",
        }
    return {"flagged": False}
Enter fullscreen mode Exit fullscreen mode

Rule 4: Velocity Check on Same BIN Across Multiple Orders

If the same BIN appears across five or more orders in a short window, that is a carding pattern. Someone is testing card numbers that share the same issuer block.

from collections import defaultdict
from datetime import datetime, timedelta

order_log: dict[str, list] = defaultdict(list)

def flag_bin_velocity(bin_number: str, window_minutes: int = 60, threshold: int = 5) -> dict:
    now = datetime.utcnow()
    cutoff = now - timedelta(minutes=window_minutes)

    # Filter to recent hits
    order_log[bin_number] = [t for t in order_log[bin_number] if t > cutoff]
    order_log[bin_number].append(now)

    count = len(order_log[bin_number])

    if count >= threshold:
        return {
            "flagged": True,
            "reason": f"BIN {bin_number} used {count} times in {window_minutes} minutes",
            "action": "block_and_alert",
        }
    return {"flagged": False}
Enter fullscreen mode Exit fullscreen mode

In production, back order_log with Redis rather than an in-memory dict. But the logic transfers directly.


Putting It All Together in a Checkout Hook

Here is how all four rules look wired together in a single function you could drop into a pre-authorization hook:

def evaluate_card_risk(
    card_number: str,
    billing_country: str,
    product_type: str,
    order_total: float,
    customer_order_count: int,
) -> dict:
    bin_number = card_number[:8]  # Use 8 digits if available
    raw = lookup_bin_with_retry(bin_number)

    if raw is None:
        # Could not reach API - decide your fallback policy here
        return {"flagged": False, "reason": "BIN lookup unavailable", "action": "allow"}

    bin_info = parse_bin_response(raw)

    if bin_info is None:
        return {"flagged": False, "reason": "Unknown BIN", "action": "allow"}

    checks = [
        flag_prepaid_digital(bin_info, product_type),
        flag_high_risk_origin(bin_info, billing_country),
        flag_new_amex_high_value(bin_info, order_total, customer_order_count),
        flag_bin_velocity(bin_number),
    ]

    for check in checks:
        if check.get("flagged"):
            return check  # Return first hit

    return {"flagged": False, "reason": "Passed all checks", "action": "allow"}
Enter fullscreen mode Exit fullscreen mode

This runs synchronously and returns quickly. The most expensive part is the HTTP call, which you can cache by BIN. Most BINs do not change. Cache the result for 24 hours and you will cut your API usage by 80% or more on repeat customers.


Batch Lookups for Order Review Queues

If you are running a manual review queue or backfilling risk scores on historical orders, use the batch endpoint instead of looping single lookups. It accepts up to 50 BINs per request.

def batch_lookup(bin_list: list[str]) -> list[dict]:
    url = "https://api.binsearchlookup.com/lookup/batch"
    headers = {
        "X-API-Key": API_KEY,
        "X-User-ID": USER_ID,
        "Content-Type": "application/json",
    }
    payload = {"bins": bin_list[:50]}  # Hard cap at 50

    response = requests.post(url, json=payload, headers=headers, timeout=10)
    response.raise_for_status()

    data = response.json()
    return data.get("results", [])
Enter fullscreen mode Exit fullscreen mode

Run this in chunks if you have more than 50 BINs to process:

def chunked_batch_lookup(bin_list: list[str]) -> list[dict]:
    results = []
    for i in range(0, len(bin_list), 50):
        chunk = bin_list[i : i + 50]
        results.extend(batch_lookup(chunk))
    return results
Enter fullscreen mode Exit fullscreen mode

What About False Positives?

Every rule in the playbook above will occasionally flag a legitimate customer. A Canadian tourist buying software on a US site. An expat using a prepaid card because they do not have a local bank account yet.

The action field in each response is intentional. "Block" is for patterns so clear that the false positive rate is low enough to accept. "Manual review" is for signals that need a human eye. "Require verification" is for middle-ground cases where you ask the customer to confirm their identity before proceeding.

Build your actions around your chargeback rate and your customer tolerance. If you run a $20 SaaS product, a false block costs you a customer. If you run a $2,000 electronics store, a false allow costs you $2,000 and your merchant account standing.


Summary

BIN lookups are one of the cheapest and most underused tools in a payment developer's kit. The integration takes under an hour. The requests library handles everything. The response fields are clean and immediately usable in business logic.

The four rules in this tutorial are a starting point. Your actual fraud patterns will be specific to your product, your customer base, and your geography. Start with these, watch your flagged order queue for a few weeks, and adjust based on what you actually see.

The API work is the easy part. The judgment calls about what to block versus review versus allow are where the real work happens. Get the data in front of you first. Everything else follows from there.

Top comments (0)