SEC EDGAR Full-Text Search API: Search Every Filing Since 2001
The free EFTS endpoint searches every SEC filing since 2001. The parts that break scripts: a required User-Agent, 100 hits per page, and a hard 10,000-result cap.
The actor referenced in this article. Pay only for results delivered.
Most people use SEC EDGAR to look up filings by company. The more useful endpoint, and the less documented one, searches the body text of every filing across every company at once. That turns EDGAR from a lookup tool into a research corpus: instead of asking what Apple filed, you can ask which 400 companies disclosed a material weakness in internal controls last quarter.
The endpoint is free, needs no key, and is called EFTS. It also has three behaviours that are not written down anywhere obvious and that break most first attempts at using it. This page covers how it works, what it does not cover, and a worked example that produces a real number.
TL;DR:
https://efts.sec.gov/LATEST/search-index?q="your phrase"is free and unauthenticated. It will 403 you without a declared User-Agent. It returns 100 hits per request, not 10. It refuses to page past a 10,000-result window, so any broad query has to be sliced by date. And it only covers 2001 onward, not the full EDGAR archive.
What EFTS covers, and what it does not
The index holds the body text of filings from 2001 onward. That is worth stating plainly, because the EDGAR archive itself goes back to the mid-1990s and plenty of write-ups claim full-text search does too. It does not. Query the index for 1996 and you get zero hits on phrases that return thousands in any modern year; 1999 and 2000 return single digits. The real index starts in 2001. Anything older has to be fetched from the archive and searched yourself.
Within that window it covers everything: 10-K annual reports, 10-Q quarterly reports, 8-K current reports, DEF 14A proxy statements, S-1 registrations, 13F holdings reports, 20-F and 6-K from foreign private issuers, and roughly 200 other form types, including their exhibits.
It indexes body text only. There is no way to restrict a search to the risk factors section, so a phrase that appears in both the risk factors and an attached press release will match twice.
The endpoint
GET https://efts.sec.gov/LATEST/search-index
| Parameter | Meaning |
|---|---|
q | The search term. Wrap in double quotes for an exact phrase. |
forms | Comma-separated form types, e.g. 10-K. Matching is on the root form, so 10-K also returns 10-K/A amendments. |
startdt / enddt | Filing date bounds, YYYY-MM-DD. They work on their own; the dateRange=custom parameter you will see in the browser’s network tab is not required. |
from | Offset for pagination. |
The User-Agent is mandatory. Send the request without one and the SEC returns 403 Forbidden. This is the single most common reason a working browser query fails as a script. The SEC’s EDGAR data access policy asks for a declared header naming you and giving a contact email, and caps automated access at 10 requests per second.
import requests
HEADERS = {"User-Agent": "Your Company Name you@yourdomain.com"}
r = requests.get(
"https://efts.sec.gov/LATEST/search-index",
params={
"q": '"material weakness"',
"forms": "10-K",
"startdt": "2025-01-01",
"enddt": "2025-03-31",
},
headers=HEADERS,
timeout=30,
)
data = r.json()
print(data["hits"]["total"]) # {'value': 3244, 'relation': 'eq'}
print(len(data["hits"]["hits"])) # 100
Reading the response
EFTS is Elasticsearch underneath and returns Elasticsearch’s shape. The fields inside _source are not named the way you would guess, and this is where most example code on the internet is simply wrong. There is no entity_name and no form_type.
for hit in data["hits"]["hits"]:
src = hit["_source"]
print(src["file_date"]) # '2025-03-12'
print(src["display_names"]) # ['Australian Oilseeds Holdings Ltd (COOT, COOTW) (CIK 0001959994)']
print(src["form"]) # '10-K/A'
print(src["ciks"]) # ['0001959994']
print(src["adsh"]) # '0001493152-25-009961'
print(src["sics"]) # ['2070']
print(src["biz_locations"]) # ['Cootamundra, C3']
The fields you will actually use:
| Field | Contents |
|---|---|
display_names | List. Company name with tickers and CIK baked into one string. Parse it if you want them separately. |
ciks | List of CIK numbers, zero-padded to 10 digits. |
form | The exact form, including amendments (10-K/A). |
root_forms | The base form the forms filter matched on (10-K). |
file_date | Filing date. |
period_ending | Period the filing reports on, which is usually the date you want for time series, not file_date. |
adsh | Accession number. |
sics | SIC industry code, for grouping by sector. |
biz_states / biz_locations | Business address, for grouping by geography. |
The document URL is not in the response. You build it from the accession number and the _id, which has the form {accession}:{filename}:
def filing_url(hit):
cik = hit["_source"]["ciks"][0].lstrip("0")
accession, filename = hit["_id"].split(":", 1)
return f"https://www.sec.gov/Archives/edgar/data/{cik}/{accession.replace('-', '')}/{filename}"
There is also an aggregations block on every response with pre-computed counts by entity_filter, sic_filter, biz_states_filter and form_filter. If all you want is “how many, grouped by industry”, read the aggregations and skip paging entirely.
The 10,000-result wall
This is the constraint that decides how you have to structure any real job.
EFTS returns 100 hits per request. You page with from. But from plus the page size must stay at or below 10,000, and past that the endpoint returns an Elasticsearch error rather than an empty page:
{"errorType":"ResponseError","errorMessage":"search_phase_execution_exception: [illegal_argument_exception] Reason: Result window is too large, from + size must be less than or equal to: [10000] but was [10090]."}
There is a matching tell in the response before you hit it. hits.total carries a relation field: "eq" means the count is exact, "gte" means the true count is at or above 10,000 and you are looking at a truncated result set.
total = data["hits"]["total"]
if total["relation"] == "gte":
print("truncated: narrow the query")
The fix is to slice. Filings are spread fairly evenly through the year, so date windows partition cleanly: run the same phrase quarter by quarter, month by month for common terms, and concatenate. Filtering by form first pulls most queries under the cap on its own.
import time
from datetime import date
def quarters(start_year, end_year):
for y in range(start_year, end_year + 1):
for (a, b) in [("01-01", "03-31"), ("04-01", "06-30"),
("07-01", "09-30"), ("10-01", "12-31")]:
yield f"{y}-{a}", f"{y}-{b}"
def search_all(phrase, forms, start_year, end_year):
out = []
for start, end in quarters(start_year, end_year):
offset = 0
while True:
r = requests.get(
"https://efts.sec.gov/LATEST/search-index",
params={"q": phrase, "forms": forms, "startdt": start,
"enddt": end, "from": offset},
headers=HEADERS, timeout=30,
)
hits = r.json()["hits"]["hits"]
if not hits:
break
out.extend(hits)
offset += len(hits)
if offset >= 9900: # stop before the window error
break
time.sleep(0.2) # stay inside 10 req/sec
return out
The time.sleep(0.2) is not decoration. The SEC’s fair-access limit is 10 requests per second across all your machines, and it blocks IPs that ignore it.
A worked example: has internal-control failure got worse?
A concrete question that full-text search can answer and no other EDGAR endpoint can: are more companies disclosing material weaknesses in internal controls than five years ago?
Two queries, both exact-phrase, both restricted to annual reports, both in a single calendar quarter so the counts come back exact rather than truncated:
def count(phrase, forms, start, end):
r = requests.get(
"https://efts.sec.gov/LATEST/search-index",
params={"q": phrase, "forms": forms, "startdt": start, "enddt": end},
headers=HEADERS, timeout=30,
)
return r.json()["hits"]["total"]
print(count('"material weakness"', "10-K", "2020-01-01", "2020-03-31"))
print(count('"material weakness"', "10-K", "2025-01-01", "2025-03-31"))
Run on 2026-07-30, that returns 3,221 for Q1 2020 and 3,244 for Q1 2025. Both are "relation": "eq", so they are real counts and not clipped at the ceiling. Essentially flat over five years, which is a more interesting answer than a rising line would have been, and it took two HTTP requests.
Then narrow it. Dropping the form filter for the same Q1 2025 window returns 7,880 hits across all form types, so roughly 40% of material-weakness language shows up in 10-Ks and the rest is spread across 10-Qs, 8-Ks and registration statements. Swapping the phrase to "going concern" in Q1 2025 10-Ks returns 1,704.
Every number here is reproducible with the code above. The counts move as filings are added and amended, so treat them as a snapshot rather than a citation.
High-value queries to start from
"going concern"finds companies whose auditors flagged doubt about survival."definitive agreement to acquire"in 8-Ks surfaces announced acquisitions across every filer, without waiting for a deal database to catch up."appointed as Chief Executive Officer"in 8-Ks gives you executive changes as they are filed."physical risks of climate change"across 10-Ks maps disclosure practice by SIC code, using thesic_filteraggregation."cybersecurity incident"in 8-Ks tracks Item 1.05 breach disclosures.- A supplier or customer name in quotes finds every filer who names them, which is the cheapest concentration-risk check available.
Skipping the plumbing
Everything above is about 60 lines of code, and it is 60 lines you have to maintain: the header the SEC will start rejecting if you get the format wrong, the window cap, the rate limit, the URL reconstruction, and the retries.
The SEC EDGAR Full-Text Search actor does that part. It takes a phrase, handles the pagination and the fair-access headers, and returns flat records with the fields already resolved:
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("themineworks/sec-edgar-fulltext-search").call(run_input={
"query": '"material weakness"',
"formTypes": ["10-K", "10-Q"],
"dateFrom": "2025-01-01",
"dateTo": "2025-03-31",
"maxResults": 500,
"contactEmail": "you@yourdomain.com",
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
print(item["company"], item["form"], item["file_date"])
print(item["filing_url"])
Input fields are query (required), formTypes, dateFrom, dateTo, maxResults (up to 10,000, default 200) and contactEmail. Each record comes back with company, ciks, form, file_date, period_ending, accession_number, file_type, sic, biz_location, inc_state, filing_url and scraped_at. Note form and file_date, matching EDGAR’s own naming rather than inventing new field names.
Pricing is $0.0035 per filing hit returned, which is $3.50 per 1,000 on Apify’s FREE tier and lower on the paid tiers. A search that returns nothing costs nothing, so exploratory queries are free in practice.
If you want a filing history for one company rather than a cross-company phrase search, that is the other actor: SEC EDGAR Filings Scraper takes tickers or CIKs and returns filings per issuer, optionally with XBRL financial facts and cleaned document text for RAG ingestion, at $5 per 1,000 filings.
Calling it from an AI agent
Full-text search is a good fit for an agent, because the useful queries are the ones you think of mid-investigation and would never have scripted in advance.
The Company Diligence MCP server exposes this as a search_filings_fulltext tool alongside nine others (SEC filings, GLEIF LEI records, EU VAT validation, US state business registries, court records, federal awards, and a composite diligence report). Point Claude Desktop, Cursor, or any MCP client at it and the model can run the phrase search itself, then follow up on whichever filers look interesting. That tool bills $0.10 per call on the FREE tier, with up to 100 hits per call.
The trade-off is straightforward: the raw endpoint is free and you write the plumbing, the actor is cheap per result and you write none, the MCP server costs more per call and gets you the search without writing anything at all.
Related reading
- SEC EDGAR filings for financial research: what the data supports covers the research questions each EDGAR endpoint can actually answer.
- Building a RAG pipeline on SEC EDGAR filings picks up where this page stops, once you have the filings and need the text chunked and embedded.
- Grounding company diligence in public registries puts EDGAR next to LEI, VAT, and state registry data for entity verification.
Endpoint behaviour, result counts, and the SEC fair-access limits on this page were verified against efts.sec.gov and sec.gov on 2026-07-30. Actor prices were read from the Apify API the same day. Counts change as filings are added; the code above reproduces them.
Explore the scraper referenced in this article — see inputs, outputs, and pricing, then run it on Apify.
Frequently asked questions
Is the SEC EDGAR full-text search API free? +
Yes. The EFTS endpoint at efts.sec.gov is free and needs no API key or registration. The SEC does require a declared User-Agent header containing a contact email, and caps automated access at 10 requests per second under its fair-access policy.
How far back does EDGAR full-text search go? +
To 2001. This is the single most common misconception about EFTS, because the EDGAR archive itself starts in 1993 to 1996 depending on form type. Query the full-text index for 1996 through 2000 and you get single-digit result counts; 2001 onward is where the index actually begins. For older filings you have to fetch documents from the EDGAR archive and search the text yourself.
Why does efts.sec.gov return a 403? +
You did not send a User-Agent header, or you sent a default library one. The SEC blocks undeclared automated traffic. Send a header in the format the SEC documents, a company or project name plus a contact email, and the same request returns 200.
How many results does EDGAR full-text search return per page? +
One hundred hits per request. Use the from parameter to page through more, but from plus page size must stay at or below 10,000, so roughly 9,900 results is the hard ceiling for any single query.
How do I get more than 10,000 results out of EDGAR full-text search? +
Split the query into narrower slices and page through each one separately. Date windows work best, because filings are evenly distributed through the year: run the same phrase quarter by quarter, or month by month for common terms, then concatenate. Filtering by form type first also cuts most queries under the cap.
Can I search inside 10-K risk factors? +
Yes. EFTS indexes the full body text of each filing, so Risk Factors, MD&A, footnotes, and exhibits are all searchable. There is no way to restrict a search to one section, so a phrase that appears in both the risk factors and a press-release exhibit will match on both.
What is the difference between the EDGAR full-text search API and the EDGAR submissions API? +
The submissions API answers "what has this company filed", starting from a CIK or ticker. Full-text search answers "which companies said this", starting from a phrase. Use the submissions API to build a filing history for one issuer; use full-text search for cross-company research where you do not know the companies in advance.
How to Scrape CutShort Jobs for India Tech Hiring Data (No API)
CutShort is where Indian startups post engineering and product roles, and it has no public jobs API. Learn how to extract titles, companies, salary ranges, skills, and experience bands as structured JSON for recruiting feeds and talent market research.
Airbnb Scraper: Listing Prices, Ratings, and Availability Without the API
Airbnb has no public listings API. Here's how to pull nightly price, rating, superhost status and coordinates from Airbnb search results using Python.
Maps Leads: Verified Email Extraction from Google Maps Business Listings
How to pull B2B leads from Google Maps with MX-verified emails, past the official API's 120-result cap, and pay only for contactable businesses.