Funding announcements are old news by the time you read them. There's usually a
gap of weeks between a company closing a round and telling anyone about it. In
that gap, the round is already a matter of public record.
That's because of Form D. When a US company sells securities in a private
placement, it has to file one with the SEC, generally within 15 days of the first
sale. It's a legal requirement, it's public the moment it's accepted, and almost
nobody reads it.
So the question is not whether you can see funding rounds early. It's how to
filter thousands of Form Ds a month down to the ones you care about.
This post is the working version of that, end to end: ~30 lines of Python, real
output, and the two traps that cost me a couple of hours.
Why you can't just list Form Ds
The naive approach is "give me every Form D from the last 90 days." That's
thousands of filings: real estate syndicates, hedge funds, family offices, a
long tail of LLCs. Most of it is not what anyone means by "a startup raised."
You also can't filter by company name, because you don't know the company
names yet. That's the entire point of the exercise.
What you want is a full-text search over the body of the filings, scoped to
Form D and to a recent window. EDGAR supports exactly that, over every filing
since 2001.
The code
I'm using an Apify Actor I built for EDGAR,
SEC EDGAR Scraper, because
it handles the parts of EDGAR that are tedious: CIK resolution, the fair-access
rate limit, XBRL, and the date-range bug I'll get to below. One HTTP call runs it
and returns rows.
import datetime as dt
import json
import os
import urllib.request
ACTOR = "glitchbound~sec-filings-scraper"
API = "https://api.apify.com/v2"
def form_d_by_sector(sector: str, days: int = 90) -> list[dict]:
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
payload = {
# Quote the phrase or EDGAR matches the words separately.
"searchQuery": f'"{sector}"',
"formTypes": ["D"],
"filedSince": since,
"maxSearchResults": 50,
}
url = (f"{API}/acts/{ACTOR}/run-sync-get-dataset-items"
f"?token={os.environ['APIFY_TOKEN']}&maxTotalChargeUsd=0.50")
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
rows = json.load(r)
# A target the Actor couldn't resolve comes back as a row with an `error`
# field instead of failing the whole run, and those aren't charged. Count
# them separately or a run that fetched nothing still looks successful.
return [r for r in rows if not r.get("error")]
for f in sorted(form_d_by_sector("artificial intelligence"),
key=lambda r: r["filingDate"], reverse=True):
print(f"{f['filingDate']} {f['companyName']:55} {f.get('state') or '--'}")
print(f" {f['filingUrl']}")
Set APIFY_TOKEN from
console.apify.com/settings/integrations
and run it.
Real output
This is an actual run, not a mockup:
2026-07-06 Flo Artificial Intelligence, Inc. (CIK 0001798621) CA
.../data/1798621/000179862126000003/primary_doc.xml
2026-06-29 Artificial Intelligence Financial Services Market Inc. DE
.../data/2142624/000214262426000001/primary_doc.xml
2026-06-29 Artificial Intelligence Financial Services Market Inc. DE
.../data/2142624/000214262426000002/primary_doc.xml
2026-06-18 Panorama Artificial Intelligence Corp. WA
.../data/2001638/000200163826000001/primary_doc.xml
2026-05-26 Artificial Intelligence Progress Research, LP --
.../data/2133240/000213324026000002/primary_doc.xml
2026-05-15 Allied Innovation Artificial Intelligence Fund, LLC NY
.../data/2021379/000202137926000001/primary_doc.xml
2026-05-05 Artificial Intelligence Progress Research, LP --
.../data/2133240/000213324026000001/primary_doc.xml
(URLs truncated for width; the real ones are absolute sec.gov links.)
Seven filings, each linking straight to the primary document on sec.gov. Every one
is a real entity that filed a real Form D in that window.
Note that the same company can appear twice, with two separate accession numbers
on 2026-06-29. Those are genuinely two filings, not a duplicate row: an amended Form
D gets its own accession. Dedupe on companyName only if you actually want to.
You'll notice something honest about this list: it is heavy on entities with
"Artificial Intelligence" in the company name, plus a couple of funds. That's
what a full-text phrase search gives you. It matches the text of the filing, and
Form D is a short form without a business description. It's a strong signal,
not a magic sector classifier. More on that below.
Trap 1: EDGAR silently ignores a half-open date range
This one cost me real money before I caught it.
EDGAR's full-text endpoint takes startdt and enddt alongside
dateRange=custom. Send only startdt and it does not narrow anything, and it
does not tell you either. Measured against the live endpoint, same query, forms=D:
| Parameters | Hits | Oldest result |
|---|---|---|
startdt only |
53 | 2015-07-28 |
| no date params at all | 53 | 2015-07-28 |
startdt + enddt
|
7 | 2026-05-05 |
startdt alone is byte-for-byte identical to sending no filter. If you're paying
per result, you're paying for eleven years of filings outside the window you
asked for, and the response looks perfectly normal.
If you're calling EDGAR directly: always send both bounds. Default the
missing one: 2001-01-01 is the floor for full-text search, and today for the
upper bound. (This is fixed in the Actor as of build 0.1.4; that's why the run
above returns 7 and not 53.)
Trap 2: "recent" isn't the sort you want for anything else
For funding discovery, newest-first is right. For almost every other EDGAR
question it isn't. If you're looking for the filing that matters on a topic,
recency is noise. Worth knowing before you build the wrong dashboard.
What this costs
The Actor charges per result: $3.00 per 1,000 on the free tier, so seven filings
is 2.1 cents, plus a one-time $0.02 Actor-start event, so about 4 cents for
the run. Paid Apify tiers pay less per result, down to half that on Gold.
The maxTotalChargeUsd=0.50 in the code above is a hard
ceiling the platform enforces. The run stops cleanly at the limit rather than
being killed mid-write, so you can't accidentally spend more than you meant to.
Rows that come back as errors, such as a ticker that doesn't resolve or a query
EDGAR rejects, aren't charged.
Going further
A few directions that work well from here:
Watch a sector on a schedule. Run it daily with days=2 and diff against
what you've already seen. New Form Ds show up the day they're accepted.
Pivot to the company. Once a name appears, feed its CIK back in with
includeFinancials: true to get XBRL figures: revenue, net income, total assets,
straight from what the company tagged, not scraped off a rendered page. A full
20-year history is one request, because it comes from companyfacts.
Search for other things entirely. formTypes: ["8-K"] with a phrase like
"material weakness" or "going concern" finds every company that disclosed it.
formTypes: ["4"] is insider trades. formTypes: ["13F-HR"] is what the big
funds hold.
Caveats worth stating plainly
- Form D is a filing, not a validated funding announcement. It tells you securities were sold under a Reg D exemption. It does not confirm a venture-style round, and the amounts on the form are frequently the maximum offering rather than what closed.
- Full-text phrase search matches text, not sectors. As the output shows, it finds companies whose filings contain your phrase, which correlates with the sector but isn't the same thing. For real classification you'd want to pull the filing and read it.
- This is public disclosure, and that's the whole appeal. No login, no key, no scraping of rendered pages. The SEC asks callers to identify themselves and stay under 10 requests/second, which the Actor does.
- Not investment advice, obviously.
Code
Full runnable scripts, including this one and a few others, are here:
github.com/danielhagever/apify-data-actors
Every example input in that repo is generated from the Actor's own input schema,
so they're valid by construction rather than by my memory of the field names.
That's a habit I picked up after the reviewedAt-vs-date mistake in a
different script cost me an afternoon of blank output.
If you find a case where EDGAR behaves differently than described here, I'd
genuinely like to know. That date-range trap only surfaced because I ran the
example instead of just writing it.
Top comments (0)