FDA NDC Verifier: National Drug Code Lookup avatar

FDA NDC Verifier: National Drug Code Lookup

Pricing

from $2.16 / 1,000 row judgeds

Go to Apify Store
FDA NDC Verifier: National Drug Code Lookup

FDA NDC Verifier: National Drug Code Lookup

Verify NDC codes against the FDA NDC Directory, or search it by brand, generic name, labeler or ingredient. Each code gets a verdict (listed, expired, package unlisted or not found) plus the labeler, brand name, generic name, dosage form and active ingredients.

Pricing

from $2.16 / 1,000 row judgeds

Rating

0.0

(0)

Developer

Pradio Actors

Pradio Actors

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

7 hours ago

Last modified

Share

What does FDA NDC Verifier do?

FDA NDC Verifier checks National Drug Codes against the FDA NDC Directory, and finds listings by name when you have no code. Each code comes back as one row: the verdict, plus the brand name, labeler, route, application number, DEA schedule, pharmacologic class, RxCUI and DailyMed link the Directory holds. Paste a list of codes, search by brand, generic, labeler or ingredient name, or give it pages to pull codes from, and press Start. You pay only for codes and searches that find a listing. One that matches nothing still gets a free row that tells you why.

The last measured run took 95 codes from labeler catalogs and claims-style lists, new to this Actor. Every code came back with a verdict. Of those, 66 were judged against a listing: live, expired, or a listed product whose package segment is not listed. Another 24 found no listing. The other 5 were the same package in another layout, answered free and billed once. Each row says which and why. Each judged row costs $0.004, as does each product a name search finds, and nothing else is charged.

Who uses FDA NDC Verifier

BuyerWhat they run it for
A developer who needs an FDA NDC APIPaste the list and get a dataset of verdicts, instead of writing the openFDA lookup loop
A pharmacy, claims or catalog teamConfirm every NDC on a list still resolves to a listed product before it is billed or published
A data team maintaining a product tableFill in brand name, generic name, labeler, dosage form and ingredients for each code on file
Anyone with pages rather than listsPoint urls at supplier or labeler pages and verify every NDC-shaped code found on them
A formulary or market analystSearch by brand, generic, labeler or ingredient name and get every listed product with its NDC, route and schedule

Features

  • Every NDC layout accepted. Dashed 5-3-2, 4-4-2 and 5-4-2 forms and the 11-digit padded form all work. The form as written is tried first, then the layouts labelers list under.
  • Package-level checks. A code with a package segment is matched against the listing's own package codes, so an unlisted package is told apart from a wrong product.
  • Search by name. brandNames, genericNames, labelers and activeIngredients each return one row per product listing found, up to maxResultsPerSearch per name. A name that finds nothing gets its own free not_found row.
  • Regulatory fields. Every found row carries the route, application number, marketing dates, pharmacologic class, DEA schedule, RxCUI, UNII, SPL id and a DailyMed label link.
  • Codes straight off a page. Each entry in urls is fetched and mined for NDC-shaped codes, up to 50 per page.
  • Expiry-aware verdicts. A listing whose expiration date has passed comes back expired, with active set to false.
  • De-duplicated before billing. A repeated code is judged once and never billed twice; the repeat gets its own free duplicate row naming the entry it matched.
  • No login, no browser. Plain requests to the public openFDA endpoint, paced well under the rate it allows. An optional free openFDA API key (apiKey) raises the daily request quota from 1,000 to 120,000.
  • A cap you set. maxItems stops the run where you say, and a free final row names every entry it left unjudged.

What you can count on

  • You pay only for a row the Directory judged or a product a name search found. A miss still lands in your dataset as an uncharged ITEM_STATUS row that says why. It names the cause: a code that is not NDC-shaped, a code with no listing, or a lookup the Directory refused or could not answer.
  • Every row is charged only after it is written to your dataset. A row you cannot see is never billed.
  • A run given nothing to check returns one NOTHING_TO_JUDGE row that says so, never an empty dataset. A code or name that finds nothing gets its own free not_found row instead.
  • A spending limit stops the run cleanly. One final row says how many rows were returned and how many were not.
  • Every run writes a summary with rowsFetched, rowsPushed and rowsCharged, so a short run and a broken one are easy to tell apart.
  • If the Directory refuses every lookup in a run, the run fails with the error in the log. It never returns rows full of nulls and calls it success.
  • No value is invented. A field the register, the NDC Directory, does not show is null, and this page says which fields those are. Names and descriptions come back verbatim: a labeler's own filing, typo included, is what the Directory holds.

What data does FDA NDC Verifier return?

One row per code you give it, and one row per product listing a name search finds. This is a real row from a run:

{
"ndc": "76204-027-01",
"source_url": "https://api.fda.gov/drug/ndc.json?search=product_ndc%3A%2276204-027%22&limit=1",
"status": "ok",
"reason": "package listing 76204-027-01 is in the FDA NDC Directory, listed by Ritedose Pharmaceuticals, LLC (listing expires 20271231)",
"error": null,
"found": true,
"active": true,
"product_ndc": "76204-027",
"brand_name": "Albuterol Sulfate Inhalation Solution, 0.5%",
"generic_name": "Albuterol Sulfate",
"labeler_name": "Ritedose Pharmaceuticals, LLC",
"dosage_form": "SOLUTION",
"product_type": "HUMAN PRESCRIPTION DRUG",
"marketing_category": "ANDA",
"listing_expiration_date": "20271231",
"is_finished_dosage_form": true,
"active_ingredients": [
"ALBUTEROL SULFATE 2.5 mg/.5mL"
],
"package_description": "30 POUCH in 1 CARTON (76204-027-01) / 1 VIAL in 1 POUCH (76204-027-11) / .5 mL in 1 VIAL",
"route": [
"RESPIRATORY (INHALATION)"
],
"application_number": "ANDA218386",
"marketing_start_date": "20250324",
"marketing_end_date": null,
"pharm_class": [
"Adrenergic beta2-Agonists [MoA]",
"beta2-Adrenergic Agonist [EPC]"
],
"dea_schedule": null,
"rxcui": [
"245314"
],
"unii": [
"021SEF3731"
],
"spl_id": "4e7f86ab-55bc-c123-e063-6394a90abd8d",
"dailymed_url": "https://dailymed.nlm.nih.gov/dailymed/lookup.cfm?setid=fdb4ac5a-bf0d-4896-89f7-1828c081b79e",
"search_field": null,
"search_value": null,
"row_type": "ROW"
}

status is the verdict on the code:

statusWhat it meansCharged?
okListed, and the listing has not expiredyes
expiredListed, but its listing_expiration_date has passedyes
package_unlistedThe product is listed, but no listed package matches the code's package segmentyes
not_foundNo listing matches the code or the name searched, or the input was not NDC-shapedno
duplicateAn entry already answered on an earlier row: a code pasted again, in the same layout or another, or a name search whose every listing is already on an earlier row. A search that finds some new listings returns only the new ones and gets no duplicate row. The row names the code it repeats and carries that code's found and active verdict. Its source_url and every listing field are null; read them on the named code's rowno
bad_urlA urls page could not be readno
fetch_failedThe Directory could not be read for this codeno
not_judgedmaxItems was reached before every entry was judged. One such row, with row_type ITEM_STATUS, closes the run; its reason gives the count and names the codes, pages and searches left over (the first 20, then how many more)no

Every field a row carries:

FieldWhat it holds
ndcThe code exactly as you supplied it. The same package pasted two ways is judged and billed once; the second spelling comes back as a free duplicate row that names the first. On a search row, the product NDC of the listing found. Null on a row about a urls page that could not be read, on a search that found nothing, and on the not_judged row.
source_urlWhere the row came from: the urls page a code was read from, else the openFDA query that answered it, which you can open in a browser. It never carries your API key. Null on input that is not NDC-shaped, since nothing was looked up, and on a duplicate row.
statusThe verdict, from the table above.
reasonOne sentence naming what matched and who listed it, or why nothing did.
errorThe transport message on a bad_url or fetch_failed row. Otherwise null.
foundTrue when a listing exists in the Directory for this code.
activeTrue when the listing is found, its expiration date has not passed and, for a code, the code's own package is on it; false marks an expired code, a code that found no listing, and a package_unlisted code, whose product is listed but whose package is not.
product_ndcThe product code of the matched listing, as labeler and product segments.
brand_nameThe listing's brand name.
generic_nameThe listing's generic name.
labeler_nameThe firm that listed the product.
dosage_formThe listing's dosage form, such as TABLET or SOLUTION.
product_typeThe listing's product type, such as HUMAN OTC DRUG or HUMAN PRESCRIPTION DRUG.
marketing_categoryThe listing's marketing category, such as OTC MONOGRAPH DRUG or ANDA.
listing_expiration_dateThe listing's expiry date, as the Directory carries it (YYYYMMDD).
is_finished_dosage_formTrue when the listing is a finished dosage form rather than a bulk ingredient.
active_ingredientsEach active ingredient with its strength, such as "ACETAMINOPHEN 500 mg/1".
package_descriptionThe package description: the matched package's own text, else the first one listed.
routeThe routes of administration, such as ORAL or TOPICAL.
application_numberThe application or monograph number the product is marketed under, such as NDA020702, ANDA218386 or M013.
marketing_start_dateWhen marketing started (YYYYMMDD).
marketing_end_dateWhen marketing ended or will end (YYYYMMDD). Null while no end date is filed, which is most listings.
pharm_classThe pharmacologic classes, such as "HMG-CoA Reductase Inhibitor [EPC]". Null when the listing files none, as many over-the-counter listings do not.
dea_scheduleThe DEA controlled substance schedule, such as CII or CIV. Null for a product that is not scheduled.
rxcuiThe RxNorm concept ids (RxCUI) openFDA links to the listing.
uniiThe FDA Unique Ingredient Identifiers of the listing's ingredients.
spl_idThe id of the Structured Product Labeling document behind the listing.
dailymed_urlA link to the listing's label on DailyMed, built from its SPL set id.
search_fieldOn a search row, the Directory field searched: brand_name, generic_name, labeler_name or active_ingredients.name. Null on a verification row.
search_valueOn a search row, the name you searched for, as typed. Null on a verification row.
row_typeROW on a data row, ITEM_STATUS on an uncharged row (a miss, or a duplicate that repeats an earlier code), NOTHING_TO_JUDGE or STOPPED_EARLY on a status row.
rowsFetchedStatus rows only: how many rows the run produced before the last de-duplication and the maxItems cap.
rowsReturnedStatus rows only: how many data rows landed in the dataset.
rowsRemainingStatus rows only: how many fetched rows were not returned (0 on NOTHING_TO_JUDGE).

Status rows also carry reason, rowsFetched, rowsReturned and rowsRemaining, so an empty or cut-short run explains itself in the dataset.

The last measured run took 95 codes from labeler catalogs and claims-style lists, new to this Actor. Every code came back with a verdict. Of those, 66 were judged against a listing and 24 found no listing at all. The other 5 were the same package in another layout, each answered with a free duplicate row. The list includes withdrawn-market and malformed codes, so misses are part of the measurement. On the 66 judged rows every field filled except listing_expiration_date (65 of 66) and the sparse fields below. Those stay null on most rows by design. error fills only when a miss carries a transport message. marketing_end_date fills only when a listing has an end date filed. dea_schedule fills only on a controlled substance. pharm_class fills only when the listing files a pharmacologic class, which many over-the-counter listings do not. search_field and search_value fill only on a search row. rxcui, unii and dailymed_url come from openFDA's cross-reference block, which some listings lack. The dataset's Overview view leaves the sparse fields out; the All fields view and every export carry them, so all rows keep the same shape.

Pricing

Two events are charged, both visible on the Actor's pricing tab:

  • $0.004 per row judged or found (row-judged): an NDC code judged against the FDA NDC Directory, or a product a name search found. Each is billed only after the row is written to your dataset. A miss is free. Rows whose status is not_found, duplicate, bad_url or fetch_failed are pushed so you see the verdict, and are never billed. Status rows are never billed either. On Apify's paid plans the price steps down: $0.00336 on Bronze, $0.00272 on Silver, and $0.00216 on Gold and above.
  • $0.00005 per run start (apify-actor-start), Apify's platform charge. This Actor's memory ceiling is 256 MB, so it is billed exactly once per run.

The most a list can cost. The table prices every code judged, and a code judged a miss is never billed:

Codes checkedJudged rowsCostKey
100100$0.40without a key, subject to that allowance
1,0001,000$4.00free apiKey
10,00010,000$40.00free apiKey

The key column is the register's quota, not ours. Without a key, a run shares openFDA's keyless allowance of 1,000 requests per IP per day. On Apify a run goes out from shared platform IPs, so part or all of that day's allowance may already be spent when your run starts. A free apiKey avoids that: the cap becomes your key's own 120,000 requests/day, and every row in the table is then a day's work. One code can cost up to four lookups while its layouts are tried, so a full keyless allowance covers roughly the first 250 codes. Codes that resolve to nothing stay free, so a list with misses costs less than the table shows. A name search is lighter on the quota: one request returns up to 100 product rows, so a search entry costs one request per 100 rows it returns.

How do I use FDA NDC Verifier?

  1. Open the Actor and press Start.
  2. Paste your NDC codes into NDCs: dashed forms like 67414-449-10, or the 11-digit form like 67414044910.
  3. Or, with no codes at hand, type names under Search by name: brand names, generic names, labelers or active ingredients.
  4. Optionally add page addresses to URLs and set a cap under Maximum items.
  5. For lists past the register's keyless daily quota, paste a free openFDA key under openFDA API key.
  6. Press Start. Judged rows land in the run's dataset as they are verified, and a RUN_SUMMARY entry lands in the run's key-value store.

Example input:

{
"ndcs": [
"67414-449-10",
"76204002701",
"67414-449",
"0000-0000-00"
],
"urls": [],
"maxItems": 100
}

The last code resolves to nothing; its row is pushed free so you still see the verdict.

Through the Apify API:

$curl -X POST "https://api.apify.com/v2/acts/Pradio~fda-ndc/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" -H "Content-Type: application/json" -d '{"ndcs": ["67414-449-10"], "urls": []}'

Input

FieldTypeDefaultWhat it does
ndcsarray[] (the Console form is prefilled with 7 example codes)The NDC codes to verify.
brandNamesarray[] (prefilled with Lipitor)Brand names to search for. One row per product listing found.
genericNamesarray[]Generic names to search for.
labelersarray[]Labeler (company) names to search for, as the Directory lists them.
activeIngredientsarray[]Active ingredient names to search for.
maxResultsPerSearchinteger10The most product rows one search entry returns.
urlsarray[]Pages to fetch and mine for NDC-shaped codes. Optional; an empty list is fine.
maxItemsinteger100The most rows one run returns, across codes and searches.
maxItemsPerUrlinteger50The most codes taken from any one urls page, so one dense page cannot starve the rest.
apiKeystringnoneA free openFDA API key, sent as api_key on every request to the Directory. Raises the daily quota from 1,000 to 120,000 requests.

ndcs

The identifier list, in any NDC layout the Directory uses: dashed 5-3-2, 4-4-2 and 5-4-2 forms, a two-segment product code like 67414-449, or the 11-digit padded form like 67414044910. Each code expands to the layouts labelers list under, so a code written one way still finds a listing stored another way. Anything that is not NDC-shaped comes back not_found, free, with the reason on the row.

Search by name: brandNames, genericNames, labelers, activeIngredients

Each entry is one search of the Directory, matched as a phrase against the brand name, generic name, labeler name or active ingredient name. Every product listing it finds becomes one row with the same fields as a verified code. On it, ndc is the listing's product NDC. search_field and search_value say which search found it. Up to maxResultsPerSearch rows (default 10) come back per entry, and all rows count toward maxItems. A product found by two entries is one row, billed once: a later search leaves out the listings already on an earlier row, and a search whose every listing is already on an earlier row gets one free duplicate row naming the search. A name that finds nothing gets one free not_found row naming it, so a typo never disappears.

{
"brandNames": ["Lipitor"],
"activeIngredients": ["oxycodone hydrochloride"],
"maxResultsPerSearch": 20
}

urls

Each address is fetched once and scanned for NDC-shaped codes, up to maxItemsPerUrl (default 50) codes per page. Every code found is verified like a pasted one, and the page's URL lands in the row's source_url field. A page that cannot be read yields one bad_url row with the transport message in error. A page with no NDC-shaped codes yields one not_found row carrying the address.

apiKey

Optional. Without a key, a run shares openFDA's keyless allowance of 1,000 requests per IP per day, and on shared platform IPs that allowance may already be spent. One judged code can cost up to four lookups while its layouts are tried, so a keyless day covers roughly the first 250 codes. A free key from openFDA's authentication page raises the cap to 120,000 requests a day. It is sent as api_key on every register request, never on a urls page fetch.

maxItems

The cap on returned rows, default 100, shared by pasted codes, page codes and searches in that order. When your entries do not all fit, the last row of the cap is a free not_judged row. It counts the codes, pages and searches left unjudged. It names them too: the first 20, then how many more. No entry disappears. Raise it for longer lists.

Worked examples

Use it to verify a claims file. Every code gets a verdict. Only judged rows are charged.

{
"ndcs": ["67414-449-10", "76204002701", "0000-0000-00"]
}

Use it to find every listing of a brand. No codes needed. Each listing is one row with its NDC.

{
"brandNames": ["Lipitor", "Tylenol"],
"maxResultsPerSearch": 50
}

Use it to list the controlled-substance products of an ingredient. Read dea_schedule on each row.

{
"activeIngredients": ["oxycodone hydrochloride"],
"maxResultsPerSearch": 100,
"maxItems": 100
}

Use it to pull a labeler's catalogue. Spell the labeler as the Directory does.

{
"labelers": ["Viatris Specialty LLC"],
"maxResultsPerSearch": 200,
"maxItems": 200
}

Output

A finished run's dataset holds one judged row per code and one row per product a search found. A billed verdict carries row_type: "ROW"; a free miss carries row_type: "ITEM_STATUS". Two other row types explain a run that did not simply fill:

  • NOTHING_TO_JUDGE is one status row, never charged, pushed when the run had nothing to check: every input list (ndcs, urls and the four search lists) empty. It carries rowsFetched, rowsReturned and rowsRemaining.
  • STOPPED_EARLY is one status row, never charged, pushed when your charge limit ended the run. It says how many rows were returned and how many were not, so finishing and being cut short never produce the same dataset.

Outside the dataset, each run also leaves a RUN_SUMMARY entry in its key-value store (an entry, not a row type) with the run's counts and its start and finish times. You rarely need it: a code you pasted twice already comes back as its own free duplicate row.

When there is nothing to return, you still get one row that says why, never an empty dataset.

What can you do with the data?

Clean a code list before it is used. A pharmacy or claims team runs its NDC file before submission week. Rows where active is true go through, expired and package_unlisted rows get a second look, and every miss already says why.

Check the codes a page publishes. Point urls at a supplier or labeler catalog page and get a verdict per NDC-shaped code on it. The page's URL sits on each row's source_url, so codes from several pages stay sorted apart.

Fill in a product table. A data team with a spreadsheet of codes gets brand name, generic name, labeler, dosage form, marketing category and ingredients for each. The fields keep the shape the Directory publishes.

Catch listings before they lapse. Re-run a saved list on a schedule. listing_expiration_date is on a found row whenever the Directory gives one (65 of 66 in the measured run), and a listing whose date has passed comes back expired, so nothing slips quietly.

Use FDA NDC Verifier with AI agents

$claude mcp add --transport http apify "https://mcp.apify.com?tools=Pradio/fda-ndc"

Paste that line to give an MCP-aware agent this Actor. It can then run verifications and read the dataset itself.

Personal data

This Actor reads the public FDA NDC Directory on fda.gov and verifies identifier rows. Its input is a list of NDC codes to check, not a list of people. No output field carries a private individual's name, email, phone or address; the labeler_name on a row is the firm that listed the product. The run collects and emits no personal data.

Limits

  • A name search matches the Directory's own spelling as a phrase. "Lipitor" finds Lipitor listings; a misspelled name finds nothing and says so on a free row. Labeler names must be written as the Directory lists them, such as "Viatris Specialty LLC".
  • One search entry returns at most 1,000 listings, the ceiling of maxResultsPerSearch.
  • Only NDC-shaped input is judged. Anything else comes back not_found, free, with the reason on the row.
  • At most maxItemsPerUrl codes (default 50) are taken from one urls page, and pages are fetched as plain requests. A page that only builds its content inside a browser yields no codes.
  • The row keeps the listing facts in the table above. The Directory's entry carries a few keys this Actor does not return, such as the full packaging list on a search row (the first package's description is kept).
  • error is empty on most rows: it fills only on a miss with a transport message. marketing_end_date and dea_schedule are empty unless the listing has an end date or a schedule.
  • A 429 from openFDA is waited out and the same request is asked again, up to three times. If the register keeps refusing, the entry comes back as a free fetch_failed row.
  • If the Directory cannot be read at all, the run fails and the log says why. You do not get a dataset full of misses that only look like answers.
  • The default cap is 100 rows per run. It counts every row, free ones included. Raise maxItems for bigger lists.
  • openFDA without a key allows 1,000 requests per IP per day, shared with every other run on the same platform IP, so it may already be spent; a free apiKey avoids that and raises the cap to 120,000/day. One code can cost up to four lookups while its layouts are tried, so a keyless run covers roughly the first 250 codes a day.

Troubleshooting

Fewer rows came back than the codes I pasted. Two things end a run before the list does. maxItems (default 100) is the most rows one run returns. Codes past it are not judged, and the last row is a free not_judged row naming them. Raise maxItems to the length of your list. The run's charge limit ends it too, and then the last row is a STOPPED_EARLY row carrying rowsReturned and rowsRemaining. A repeat is never the cause: in the same layout or another, it comes back as a free duplicate row naming the entry it matched.

My dataset has a single row whose row_type is NOTHING_TO_JUDGE. The run had nothing to check: every input list (ndcs, urls and the four search lists) was empty. That row is the answer. It is not charged, and it is not a crash.

The run failed with "register unreachable" instead of returning rows. Every lookup failed, so the run stopped rather than writing a dataset of fetch_failed misses. The first error is in the run's log; the Directory or the network was not answering.

A code I know is real came back package_unlisted. The product is listed, but your code's package segment matches none of the labeler's published package codes. The reason names the listed packages. Try the two-segment product form to confirm the product itself resolves.

Something else look wrong? Report a problem on the Actor's Issues tab and include the run's RUN_SUMMARY. It carries the counts that tell a short run from a broken one.

FAQ

Can I use integrations with FDA NDC Verifier? Yes. It is a normal Apify Actor, so its dataset works with Apify's integrations, including Zapier, Make, n8n and Google Sheets. Runs can be started on a schedule or by webhook.

Can I use FDA NDC Verifier with the Apify API? Yes. Start a run through the actor runs endpoint and read the rows through the dataset API. The curl line under "How do I use FDA NDC Verifier?" returns the dataset items directly.

Can I use FDA NDC Verifier through an MCP server? Yes. The Apify MCP server exposes it as a tool. The one-line setup is under "Use FDA NDC Verifier with AI agents".

Is it legal to scrape the FDA NDC Directory? The Actor reads the FDA's public openFDA API, which publishes the NDC Directory for public use. It sends a small number of lookups per code or search, paced well under the rate the API allows, and it collects only directory facts, no personal data. What you do with verified rows is your responsibility.

Release notes

  • 2026-09-25: codes, pages and searches past maxItems are no longer dropped in silence. A free not_judged row closes the run with the count and the entries left unjudged.
  • 2026-09-24: search by brand, generic, labeler or ingredient name (brandNames, genericNames, labelers, activeIngredients, capped by maxResultsPerSearch). New fields on every found row: route, application_number, marketing_start_date, marketing_end_date, pharm_class, dea_schedule, rxcui, unii, spl_id and dailymed_url. source_url now names the openFDA query behind each row. ndcs is no longer required. A code pasted twice in different layouts now comes back as a free duplicate row naming the entry it matched, instead of disappearing from the dataset.
  • 0.1.18 (2026-09-14): first public listing. NDC codes judged against the FDA NDC Directory over the public openFDA API, one verdict row per code.

Not affiliated

FDA NDC Verifier is an unofficial tool. It is not affiliated with, endorsed or sponsored by the U.S. Food and Drug Administration or the openFDA project, and it is not an official FDA service. It reads the public FDA NDC Directory over the public openFDA API.

More from this publisher: Impressum Scraper finds the legal-notice page on a company website and reads its contact details.