--- name: openfda-adverse-events-scraper description: Query openFDA safety surveillance data and return one flat, uniform record per report via the Apify Actor arman-bd/openfda-adverse-events-scraper. Covers nine datasets across drugs, medical devices, food and tobacco: adverse event reports, enforcement actions, device recalls and drug labels, all normalised into the same key set with product names, manufacturer, reaction terms, seriousness, patient age and sex, recall classification and status, locations and free-text narrative. Use when a task needs pharmacovigilance sweeps, competitor recall monitoring, drug and reaction co-occurrence counts, food safety tracking or label change detection. Not for clinical trials, drug approvals, prescribing data, or any claim that a product caused an effect. --- # openFDA Adverse Events Scraper Apify Actor `arman-bd/openfda-adverse-events-scraper`. Pick a domain and an endpoint, narrow with product names, a date window or a raw search expression, and get one dataset record per report. Every one of the nine datasets normalises into the same key set, so a drug adverse event and a food recall land in the same columns. It runs without credentials; an openFDA key only raises the daily ceiling. ## When to use it - Pharmacovigilance: sweep adverse event reports naming your molecules and alert on a change in `seriousness`. - Recall monitoring across a list of competitor firms, on drugs, devices or food. - Counting drug and reaction co-occurrence by year for clinical risk research. - Food safety work: Class I recalls by state, with the distribution pattern. - Regulatory intelligence: diffing drug label records to spot a label change. ## When not to use it - Causality. These are voluntary reports; a report does not establish that the product caused the effect, and the FDA says so. Treat counts as signals, never as evidence. - Clinical trials, approvals, prescribing volumes or pricing. None are in these datasets. - Non-US regulators. This is FDA data only. - Patient-level follow-up. Reports are de-identified and duplicates of the same case are common. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/openfda-adverse-events-scraper').call({ domain: 'drug', endpoint: 'event', productNames: ['metformin', 'atorvastatin'], searchQuery: 'patient.reaction.reactionmeddrapt:"nausea"', fromDate: '2024-01-01', toDate: '2025-12-31', maxResults: 2000, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~openfda-adverse-events-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"domain":"drug","endpoint":"event","productNames":["ibuprofen"],"fromDate":"2025-01-01","maxResults":500}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/openfda-adverse-events-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `domain` | string | no | `drug` | One of `drug`, `device`, `food`, `tobacco`. | | `endpoint` | string | no | `event` | One of `event`, `enforcement`, `label`, `recall`, `problem`. Only nine pairings exist, see the table below; anything else stops the run immediately. | | `productNames` | string[] | no | `[]` | Brand or generic names. **Each name is a separate query** against that dataset's own product field, and each is echoed on its rows as `productQuery`. Empty runs one query over the whole dataset. | | `searchQuery` | string | no | `""` | A raw openFDA search expression, ANDed with the product and date filters. Field names are dataset-specific. | | `fromDate` | string | no | `""` | Lower bound, `YYYY-MM-DD` or `YYYYMMDD`. Applied to that dataset's own date field. Anything else throws before the first request. | | `toDate` | string | no | `""` | Upper bound, same formats. A `fromDate` later than `toDate` throws. | | `maxResults` | integer | no | `100` | Cap **per product name**, not per run. Minimum 1, maximum 25100. | | `apiKey` | string | no | `""` | Optional openFDA key. Raises the daily request ceiling from 1000 to 120000. Secret field. | The nine datasets, and which fields your date and product filters land on: | Dataset | Date field | Product field | |---|---|---| | `drug/event` | `receivedate` | `patient.drug.medicinalproduct` | | `drug/enforcement` | `report_date` | `product_description` | | `drug/label` | `effective_time` | `openfda.brand_name` | | `device/event` | `date_received` | `device.brand_name` | | `device/enforcement` | `report_date` | `product_description` | | `device/recall` | `event_date_initiated` | `product_description` | | `food/event` | `date_created` | `products.name_brand` | | `food/enforcement` | `report_date` | `product_description` | | `tobacco/problem` | `date_submitted` | `tobacco_products` | **`maxResults` is per product name, and one query can never reach past 25000 records.** Those two facts set the shape of every large pull. Three names at `maxResults: 5000` is 15000 records and about 150 requests, since results page a hundred at a time. A single query needing more than 25000 records cannot be widened: split it into date windows with `fromDate` and `toDate`, or into separate product names, and run the pieces. Results always come back newest first on the dataset's own date field, so a cap always gives you the most recent N rather than an arbitrary slice. ## Output One record per report. Every dataset produces the same key set; fields that do not apply to that dataset are `null` or an empty array. | Field | Type | Notes | |---|---|---| | `domain` | string | Echo of the input. | | `endpoint` | string | Echo of the input. Pair with `domain` to know which shape you are reading. | | `recordId` | string \| null | The dataset's own identifier, always coerced to a string: safety report number, recall number, device report number or label id. | | `safetyReportId` | string \| null | Populated on `drug/event` only. `null` everywhere else. | | `productName` | string \| null | Brand name, or the product description where the dataset has no brand field. | | `genericName` | string \| null | Generic name or active ingredient. | | `manufacturer` | string \| null | Manufacturer as openFDA resolves it, falling back to the reporting or recalling firm. | | `reactions` | string[] | Reaction terms for drug and food events, reported health problems for tobacco, report-text type codes for device events. Empty for recalls and labels. | | `seriousness` | string \| null | Which seriousness criteria the reporter ticked, joined with `; ` (`Hospitalization; Other serious`), or `Serious` / `Non-serious`. For device events this holds the event type instead. | | `patientAge` | string \| null | Decoded to text with its unit, `27 years`. Not a number. | | `patientSex` | string \| null | `Male` or `Female`, decoded from the numeric code. | | `receiveDate` | string \| null | ISO date. Meaning is dataset-specific: report received, recall initiated, device event received. | | `reportDate` | string \| null | ISO date. The second date the dataset carries: transmission, report, posting or label effective date. | | `outcome` | string[] | Reaction outcomes decoded to text, deduplicated. Empty when the dataset has none. | | `recallReason` | string \| null | Free text reason for the recall. | | `classification` | string \| null | `Class I`, `Class II` or `Class III`. Enforcement datasets only. | | `recallNumber` | string \| null | Recall identifier. | | `status` | string \| null | `Ongoing`, `Terminated` and similar. On device events this is the report source code instead. | | `recallingFirm` | string \| null | Firm that issued the recall. | | `productDescription` | string \| null | Full product description where the dataset has one. | | `country` | string \| null | Firm or reporter country. Format varies by dataset: `US` on drug events, `United States` on enforcement. | | `state` | string \| null | Two-letter state, recall and device datasets. | | `city` | string \| null | City, recall and device datasets. | | `narrative` | string \| null | The longest free text the dataset offers: device narrative, drug indication, distribution pattern, root cause or indications and usage. | | `productQuery` | string \| null | The `productNames` entry that produced this row. `null` when no product filter was set. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A `drug/event` record, `reactions` trimmed: ```json { "domain": "drug", "endpoint": "event", "recordId": "26940509", "safetyReportId": "26940509", "productName": "METFORMIN", "genericName": "METFORMIN", "manufacturer": "Mylan Pharmaceuticals Inc.", "reactions": ["Adrenal insufficiency", "Hepatocellular injury", "Tachycardia", "Nausea"], "seriousness": "Hospitalization; Other serious", "patientAge": "27 years", "patientSex": "Male", "receiveDate": "2026-06-30", "reportDate": "2026-07-09", "outcome": ["Recovered/resolved", "Not recovered/not resolved"], "recallReason": null, "classification": null, "recallNumber": null, "status": null, "recallingFirm": null, "productDescription": null, "country": "US", "state": null, "city": null, "narrative": "Diabetes mellitus inadequate control", "productQuery": "metformin", "scrapedAt": "2026-08-06T11:49:45.177Z" } ``` A `drug/enforcement` record fills the recall side of the same shape and leaves the patient side `null`: ```json { "domain": "drug", "endpoint": "enforcement", "recordId": "D-0390-2026", "productName": "IBUPROFEN", "manufacturer": "Sun Pharmaceutical Industries, Inc.", "receiveDate": "2026-03-02", "reportDate": "2026-03-25", "recallReason": "Presence of foreign substance: the firm received complaints for a gel-like mass…", "classification": "Class II", "recallNumber": "D-0390-2026", "status": "Ongoing", "recallingFirm": "STRIDES PHARMA INC", "productDescription": "Children's Ibuprofen Oral Suspension, USP, 100 mg per 5mL, 4 FL OZ (120 mL) bottle .", "country": "United States", "state": "NJ", "city": "Bridgewater", "narrative": "U.S.A. Nationwide" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** A product name that matched nothing and a product name that failed look identical in the dataset. ```json { "dataset": "drug/event", "targetsRequested": 2, "targetsFailed": 0, "failures": [], "recordsSaved": 1400, "filters": { "searchQuery": "patient.reaction.reactionmeddrapt:\"nausea\"", "productNames": ["metformin", "atorvastatin"], "fromDate": "20240101", "toDate": "20251231", "maxResults": 2000, "authenticated": false }, "finishedAt": "2026-08-06T11:50:12.884Z" } ``` `dataset` confirms which of the nine pairings actually ran. `failures` carries one `{ product, error }` per failed target, with the label `(no product filter)` when `productNames` was empty. An error mentioning a rejected expression means `searchQuery` used a field that does not exist in that dataset, which is the single most common mistake. `filters.fromDate` and `filters.toDate` are echoed in the compact `YYYYMMDD` form, not as you passed them. `recordsSaved` well below `targetsRequested` multiplied by `filters.maxResults` simply means the query matched less than the cap. ## Behaviour to plan around - **A query matching nothing is not an error.** The source answers a no-match with a not-found response, which the Actor reads as zero results. That target saves nothing, records no failure, and the run continues. Silence means "no matches", so check `failures` before concluding the filter was wrong. - **The record shape never changes, so nulls carry no information on their own.** Every dataset emits all 26 keys. `classification: null` on a drug event means the dataset has no such field, not that the classification is unknown. - **The same key means different things per dataset.** `receiveDate` is the report receipt date on drug events and the recall initiation date on enforcement. `status` is the recall status on enforcement and the report source code on device events. Always branch on `domain` plus `endpoint` before interpreting a field. - **Numeric codes are decoded, including the trap.** Sex, age unit, reaction outcome and seriousness arrive as codes upstream. Seriousness flags use `1` for yes and `2` for no, so the presence of a flag says nothing; the Actor decodes the value, and `seriousness: "Non-serious"` is a real answer rather than a missing one. - **`productName` need not equal `productQuery`.** A report lists every drug the patient was taking. The Actor picks the drug matching your search term, then the reporter's designated suspect drug, then whatever came first. Join on `productQuery` for your own grouping. - **Duplicates of the same case are common.** Dedupe on `safetyReportId` (drug events) or `recordId` before counting anything. - **`maxResults` is per product name.** A run with five names and a cap of 1000 can return 5000 rows. Budget the timeout on the product count multiplied by the cap. - **Some endpoints are slow.** Device requests can take several seconds each. Each request gets 60 seconds and three attempts with linear backoff. - **One failing product name never aborts the run.** The Actor throws only when every target failed. ## Recipes **Nightly pharmacovigilance sweep.** Two molecules, last week only, newest first. ```json { "domain": "drug", "endpoint": "event", "productNames": ["metformin", "atorvastatin"], "fromDate": "2026-07-30", "maxResults": 2000 } ``` Dedupe on `safetyReportId`, then alert on any row whose `seriousness` contains `Death` or `Life-threatening`. **Class I recall watch.** No product filter, one sweep across the whole dataset. ```json { "domain": "food", "endpoint": "enforcement", "searchQuery": "classification:\"Class I\"", "fromDate": "2026-01-01", "maxResults": 5000, "apiKey": "YOUR_OPENFDA_KEY" } ``` Group by `recallingFirm` and `state`; `narrative` holds the distribution pattern. **Drug and reaction co-occurrence.** One reaction term, one molecule, a wide window split into years to stay under the per-query ceiling. ```json { "domain": "drug", "endpoint": "event", "productNames": ["ibuprofen"], "searchQuery": "patient.reaction.reactionmeddrapt:(\"nausea\" OR \"vomiting\")", "fromDate": "2024-01-01", "toDate": "2024-12-31", "maxResults": 25000 } ``` Re-run per year and count distinct `safetyReportId` per year. Do not present the counts as incidence rates. **Label change detection.** Brand names against the label dataset, run on a schedule. ```json { "domain": "drug", "endpoint": "label", "productNames": ["Ozempic"], "maxResults": 50 } ``` Diff `recordId` and `reportDate` against the previous run; `narrative` holds the indications and usage text to compare.