How to Find and Extract a Company's EIN from the Web
Where US Employer Identification Numbers actually live, how to tell a real one from a phone number, and how to sweep thousands of company domains without drowning in false positives.
What an EIN is, and why it is harder to find than you expect
An Employer Identification Number is the nine-digit federal tax ID the IRS assigns to a US business — written NN-NNNNNNN, sometimes labelled FEIN, Federal Tax ID, or on SEC filings, 'I.R.S. Employer Identification No.'. If you do vendor onboarding, KYB checks, nonprofit research or B2B data enrichment, it is the closest thing the US has to a national company number.
The trouble is that the US has no companies register in the European sense. There is no single endpoint that maps a domain to an EIN. Public companies disclose theirs to the SEC. Nonprofits disclose theirs to donors and on Form 990. Everyone else discloses theirs when they feel like it — usually buried in a footer, an invoice template, a careers page or the terms of service, in whatever formatting the person who typed it chose that day.
So an EIN pipeline is two problems stacked: getting the pages, and deciding which nine-digit number on a page is actually a tax ID. The second one is where most projects quietly produce garbage.
Start with the authoritative sources, not the crawler
Before writing any extraction code, check whether the company is one whose EIN is already published as structured data. It costs nothing and the answer is definitive.
For SEC registrants, EDGAR exposes the EIN on the submissions endpoint keyed by CIK. No HTML parsing, no anti-bot, just JSON — the SEC only asks that you send a descriptive User-Agent with a contact address.
- SEC EDGAR — every public registrant, EIN included, as JSON. Free.
- IRS Tax Exempt Organization Search and the annual Exempt Organization Business Master File — every registered nonprofit, downloadable in bulk.
- Form 990 filings — the EIN sits on the cover page of every one.
- State secretary-of-state registers — company registration numbers, but usually not the EIN.
- The company itself — a W-9 is the only truly authoritative answer for a private business.
curl -s -H "User-Agent: data-team you@example.com" \
"https://data.sec.gov/submissions/CIK0000320193.json" \
| jq '{name: .name, ein: .ein, sic: .sicDescription}'
# {
# "name": "Apple Inc.",
# "ein": "942404110",
# "sic": "Electronic Computers"
# }If your target list is public companies or nonprofits, you probably do not need a crawler at all. Crawl only for the private long tail, where no register covers you.
Where EINs hide on a company website
For the long tail you are looking at a small, fairly predictable set of pages. In practice a handful of paths account for the large majority of published EINs, which matters because it turns 'crawl the site' into 'fetch six URLs and stop early'.
Nonprofits are the easy case: they put the EIN next to the donate button because donors need it for their own returns. Private for-profit companies are the hard case — many never publish it, and no amount of crawling will invent one.
- /terms, /terms-of-service, /legal — company identity blocks.
- /privacy — the data-controller identity section.
- Footers on every page — often the fastest single hit.
- /about, /contact, /imprint — the closest US analogue to a European imprint page.
- /donate, /support-us, /financials — nonprofits, almost always labelled.
- PDF invoices, W-9s and annual reports linked from those pages.
The matching problem: shape is not enough
Here is the part people get wrong. An EIN has no check digit. There is no checksum you can run, no algorithm that tells you 84-3719205 is real and 84-3719206 is not. A regex for `\d{2}-\d{7}` will happily return phone numbers, DUNS numbers, order references, zip+4 codes and part numbers, and on a busy e-commerce page it will return dozens.
Two filters get you most of the way. First, the prefix: the IRS only ever issued two-digit prefixes in a known set of ranges, so anything starting 07, 17, 28 or 69 is not an EIN. Second, proximity to a label — 'EIN', 'FEIN', 'Federal Tax ID', 'Employer Identification'. A number that satisfies both is nearly always the real thing; a number that satisfies only the first is a candidate you should not ship to a downstream system unreviewed.
import re
VALID_PREFIXES = set()
for lo, hi in [(1, 6), (10, 16), (20, 27), (30, 35), (37, 39), (41, 48),
(50, 68), (71, 77), (80, 88), (90, 95), (98, 99)]:
VALID_PREFIXES.update(f"{n:02d}" for n in range(lo, hi + 1))
EIN = re.compile(r"\b(\d{2})[-\s]?(\d{7})\b")
LABEL = re.compile(
r"(ein|fein|employer\s+identification|federal\s+tax\s*id|i\.?r\.?s\.?\s+no)", re.I
)
def extract_eins(text: str, require_label: bool = True):
text = " ".join(text.split())
hits = {}
for m in EIN.finditer(text):
prefix, rest = m.group(1), m.group(2)
if prefix not in VALID_PREFIXES:
continue
window = text[max(0, m.start() - 60): m.end() + 20]
labelled = bool(LABEL.search(window))
if require_label and not labelled:
continue
hits.setdefault(f"{prefix}-{rest}", {"labelled": labelled, "context": window.strip()})
return hits
print(extract_eins("Acme Robotics · Federal Tax ID (EIN): 84-3719205 · Order 12-3456789"))
# {'84-3719205': {'labelled': True, 'context': '...Federal Tax ID (EIN): 84-3719205 · Order...'}}Rule of thumb: labelled + valid prefix goes straight into the database. Valid prefix but unlabelled goes into a review queue. Everything else is discarded.
Fetching the pages
Terms and privacy pages are usually plain server-rendered HTML, so a plain HTTP fetch works for many domains. It stops working at scale: run a few thousand domains from one IP and you will collect connection resets, WAF interstitials and JavaScript-only shells from sites that render their footer client-side.
That is the part worth outsourcing. Irmu's crawl endpoint takes the URL, handles rendering, proxying and retries, and hands back the HTML. You keep the regex — the piece that encodes your judgement — in your own code.
- Order the paths by hit rate, not alphabetically, and break out of the loop on the first labelled match.
- Treat a 404 on /imprint as expected, not as an error worth retrying.
- Store the source URL and the surrounding context with every EIN — when someone disputes a number six months later, that context is the whole audit trail.
- A response Irmu serves is billed even when the target returns 404 or 500; only Irmu-side failures and uncovered domains such as .gov are free. So the early break is a real cost saver.
const PATHS = ["/", "/terms", "/privacy", "/about", "/contact", "/legal"];
async function findEin(domain) {
for (const path of PATHS) {
const target = new URL(path, `https://${domain}`).href;
const res = await fetch(
`https://app.irmu.com/api/crawl?url=${encodeURIComponent(target)}&js=true`,
{ headers: { Authorization: `Bearer ${process.env.IRMU_API_KEY}` } },
);
if (!res.ok) continue; // 404 on /imprint is normal
const text = (await res.text()).replace(/<[^>]+>/g, " ");
const hit = extractEins(text, { requireLabel: true })[0];
if (hit) return { domain, ein: hit.ein, source: target, found_at: new Date().toISOString() };
}
return { domain, ein: null, source: null };
}
console.log(await findEin("example.org"));Handling the awkward formats
Real pages do not respect your regex. EINs turn up as nine bare digits with no hyphen, split across table cells, rendered inside an image on a scanned W-9, or written with an en dash instead of a hyphen because a CMS autocorrected it. Normalise the text before matching: collapse whitespace, replace the Unicode dash range with an ASCII hyphen, and strip non-breaking spaces.
Bare nine-digit runs are the risky case. Match them only when a label sits immediately before them, otherwise you will pick up product IDs and tracking numbers indiscriminately. And if the number only exists inside a PDF or an image, accept it: run the PDF through a text layer if it has one, and put the rest in a manual queue rather than pretending OCR at scale is free.
import re, unicodedata
DASHES = dict.fromkeys(map(ord, "\u2010\u2011\u2012\u2013\u2014\u2015\u2212"), "-")
def normalise(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
text = text.translate(DASHES)
text = text.replace("\u00a0", " ")
return " ".join(text.split())
# Only accept bare nine-digit runs when a label is immediately in front of them.
BARE = re.compile(
r"(?:ein|fein|federal\s+tax\s*id|employer\s+identification(?:\s+number)?)\D{0,12}(\d{9})\b",
re.I,
)Validating what you collected
Prefix rules and labels give you confidence, not proof. For anything that will drive a payment, a contract or a compliance decision, cross-check against a second source: EDGAR for public companies, the IRS exempt-organization file for nonprofits, or the IRS TIN matching programme if you are eligible for it. Two independent sources agreeing is the practical standard.
Also record when you found it. EINs do not change often, but company structures do — an acquisition can move a brand onto a different entity's EIN, and the old number keeps sitting in an unmaintained footer for years. Timestamp every record and re-check the ones that matter on a schedule.
- Store ein, source_url, context, labelled, first_seen, last_verified.
- Never overwrite a verified EIN with an unlabelled candidate scraped later.
- Flag any domain where two different labelled EINs appear — usually a parent company and a subsidiary, and you need a human to say which one you want.
The legal and ethical line
An EIN identifies a business, not a person, and public companies and nonprofits are required to disclose theirs, which is why EIN collection is routine in KYB and vendor onboarding. That is not a blanket permission slip. Sole proprietors are allowed to use their Social Security Number where an EIN would go, so a nine-digit number scraped from a one-person consultancy's invoice may be personal data with a very different risk profile.
Collect from published pages, respect each site's terms, keep the records secure, and if a number looks like it came from an individual rather than an entity, treat it as sensitive and get your counsel's view before storing it. None of this is legal advice — it is the shape of the caution the topic deserves.
Try it on one page first
Before building the pipeline, paste a single company footer or filing into the free EIN extractor and see what the prefix and label filters do to it. It runs entirely in your browser, nothing is uploaded, and it uses exactly the logic described above — which makes it a fast way to calibrate how strict you want the label filter before you spend credits on ten thousand domains.
Keep reading
How to Scrape Google Maps (2026 Guide)
Collect business listings, ratings and reviews from Google Maps reliably — without maintaining a browser farm.
GuidesScrape Amazon Product Data with Python
A practical walkthrough for pulling prices, buy box, stock and reviews from Amazon at scale.
EngineeringCloudflare Bypass: What Actually Works in 2026
A technical look at Turnstile, TLS fingerprinting and why most open-source bypasses stopped working.
Start building with Irmu today
200 free credits every month, no card required. Every API, every integration, one key.