I built this actor; it's a paid tool on Apify with a free trial credit.
Every site that makes, sterilizes, relabels or exports a medical device for the US market has to register with the FDA and list its devices. That makes the FDA's registration file one of the few supplier directories where every entry has been filed with a regulator. In a 2,500-listing test for catheter, it held 817 different registered sites: 1,457 listings in the US, 203 in Mexico, 143 in China, 76 in Canada and 75 in Costa Rica.
I'm an 18-year-old engineering student, and I built FDA Medical Device Suppliers & Manufacturers Finder to turn "catheter" plus a country and a role into one clean JSON row per device listing. This post covers how it works, calling it from Python and Node, what a row looks like, and a small project: a shortlist of contract manufacturers.
One limit up front: the FDA file has no prices, no minimum order quantities, and no company e-mails or phones. It does name people (US agents, official correspondents) with their phone and e-mail; the actor leaves all of that out and keeps only company data. If you need price tiers, this is the wrong source.
How it works
The data comes from the public openFDA API (api.fda.gov/device/registrationlisting), which is CC0. The actor builds one search per device word, matching the FDA generic device name or the brand name, and adds your filters: country, US state, role, product code, device class, medical specialty or company name.
Three things I found while building it:
- One request returns up to 1,000 listings, but plain
skippaging stops at 25,000. The API sends aLink: <...>; rel="next"header instead, and the actor follows that. The 2,500-listing test took 5 requests and 8.7 seconds. - A search with no match returns a 404, not an empty list. The actor reports it as "no match" and charges nothing.
- Empty fields are often the string
"--"rather than null. The actor turns those into null so your filters work.
If a filter has no valid value at all (a role the FDA does not use, say), the run stops with an input error instead of quietly searching everything.
Calling it from Python
from apify_client import ApifyClient client = ApifyClient("<YOUR_APIFY_TOKEN>") run = client.actor("rel8ble/fda-device-suppliers-scraper").call(run_input={ "searchTerms": ["catheter"], "countries": ["DE"], "establishmentTypes": ["Manufacture Medical Device"], "maxResultsPerSearch": 20, }) for r in client.dataset(run["defaultDatasetId"]).iterate_items(): print(r["firmName"][:40], "|", r["city"], "|", r["productCodes"], "|", r["premarketNumber510k"]) The same from Node:
import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' }); const run = await client.actor('rel8ble/fda-device-suppliers-scraper').call({ searchTerms: ['surgical mask'], countries: ['CN'], uniqueSuppliers: true, maxResultsPerSearch: 50, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); console.log(items.length, 'registered mask sites in China'); What a row looks like
From the prefilled run (catheter, Germany, manufacturers, 5 November 2026), trimmed:
{ "firmName": "AESCULAP AG", "registrationNumber": "9610612", "establishmentTypes": ["Manufacture Medical Device"], "city": "TUTTLINGEN, DONAU Baden-Wurttemberg", "countryCode": "DE", "ownerOperatorName": "Aesculap AG", "usAgentCompany": "Aesculap, Inc.", "deviceBrandNames": ["Aesculap"], "productCodes": ["GBX"], "deviceNames": ["Catheter, Irrigation"], "deviceClasses": ["1"], "medicalSpecialties": ["General, Plastic Surgery"], "premarketNumber510k": null } Other rows from the same run: BIOTRONIK SE & CO. KG in Berlin (product code DQY, class 2, K193474), B. BRAUN MELSUNGEN AG (BSO, K090884) and VYGON Germany GmbH in Aachen (FOZ, K061250).
In the 2,500-listing test, firm, registration number, address, owner, role, product codes, device names and class were filled in 100% of rows. Brand names were 78%, the 510(k) number 50%, the US agent company 32% (foreign sites only) and a PMA number 1%.
Project: a contract-manufacturer shortlist
The FDA role "Manufacture Medical Device for Another Party (Contract Manufacturer)" marks sites that make devices for other brands. Filter on it, keep one row per site, and group by country:
import csv from collections import Counter run = client.actor("rel8ble/fda-device-suppliers-scraper").call(run_input={ "searchTerms": ["catheter"], "establishmentTypes": ["Manufacture Medical Device for Another Party (Contract Manufacturer)"], "uniqueSuppliers": True, "maxResultsPerSearch": 200, }) rows = list(client.dataset(run["defaultDatasetId"]).iterate_items()) print(Counter(r["countryCode"] for r in rows).most_common(5)) with open("contract-makers.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["firm", "country", "city", "owner", "product codes", "510k"]) for r in rows: w.writerow([r["firmName"], r["countryCode"], r["city"], r["ownerOperatorName"], "; ".join(r["productCodes"]), r["premarketNumber510k"]]) A listing says a firm is registered to make or handle a device type. It is not proof of clearance, quality or capacity. Check the 510(k) number in the FDA's own database and audit the supplier before you order.
Pricing and limits
$0.75 per 1,000 rows, platform usage included, no start fee. Searches that find nothing, duplicates, rows skipped by the one-row-per-supplier switch and failed requests are never charged. openFDA allows 1,000 requests a day per IP without a key; you can add your free key in the input, and it goes in a header, not the URL. A big firm appears once per listing, so use the supplier switch for company lists. Runs above about 25,000 rows per search were not tested.
The data is free from the FDA; you pay for the ready rows, the filters and the company-only output. Calling openFDA yourself is a fine option too.
Store page: https://apify.com/rel8ble/fda-device-suppliers-scraper
This article was drafted with AI and published by me.
Need a custom scraper or a ready dataset? Hire me on Fiverr: https://www.fiverr.com/rel8ble


