Article type: Tutorial
Prerequisites: Python 3 with
requests. No API key; the SEC asks you to send a User-Agent with your contact email.Tested on: 31 October 2026
When a US company raises money privately under Regulation D, it files a Form D with the SEC, usually within 15 days of the first sale. Every filing is public on EDGAR as structured XML, with the amount offered, the amount sold, the industry group and the issuer's state. EDGAR also publishes a daily index of everything filed that day. Put the two together and you get a daily list of new funding rounds, for free and with no API key.
Here's a script that does that and writes a CSV. Disclosure: at the end there's a hosted version that we built and charge for. The script works without it.
The two files you need
-
The daily form index:
https://www.sec.gov/Archives/edgar/daily-index/{year}/QTR{q}/form.{yyyymmdd}.idx. It's a fixed-width text file with form type, company name, CIK, date and file path. It's published after the SEC's business day (US Eastern), and there's none at weekends or on federal holidays. -
Each Form D's
primary_doc.xml:https://www.sec.gov/Archives/edgar/data/{cik}/{accession without dashes}/primary_doc.xml.
The SEC asks automated tools to declare a User-Agent with a contact email and to stay under 10 requests a second. The script sleeps 0.2 s between requests, which keeps it to 5 a second at most.
The script
import csv, re, sys, time
import xml.etree.ElementTree as ET
from datetime import date, timedelta
import requests
# The SEC asks automated tools to say who they are. Put your own name and email here.
UA = {"User-Agent": "Your Name you@example.com"}
SEC = "https://www.sec.gov"
def get(url):
time.sleep(0.2) # at most 5 requests a second; the SEC's limit is 10
r = requests.get(url, headers=UA, timeout=60)
r.raise_for_status()
return r
# 1. Find the latest daily form index (none at weekends or on federal holidays).
day = date.today()
for _ in range(10):
day -= timedelta(days=1)
q = (day.month - 1) // 3 + 1
url = f"{SEC}/Archives/edgar/daily-index/{day.year}/QTR{q}/form.{day:%Y%m%d}.idx"
r = requests.get(url, headers=UA, timeout=60)
if r.status_code == 200:
break
time.sleep(0.2)
print("index", day)
# 2. Keep new Form D filings (not D/A amendments).
line_re = re.compile(r"^(.*?)\s+(\d{1,10})\s+(\d{8})\s+edgar/data/\d+/(\d{10}-\d{2}-\d{6})\.txt\s*$")
filings = []
for line in r.text.splitlines():
if line[:17].strip() != "D":
continue
m = line_re.match(line[17:])
if m:
filings.append((m.group(1).strip(), int(m.group(2)), m.group(4)))
print(len(filings), "Form D filings")
def text(node, path):
el = node.find(path)
return el.text.strip() if el is not None and el.text else None
def amount(v):
try:
return float(v)
except (TypeError, ValueError):
return None # "Indefinite" or missing
# 3. Read each primary_doc.xml. Company fields only: we never read relatedPersonsList.
rows = []
for name, cik, acc in filings[: int(sys.argv[1]) if len(sys.argv) > 1 else 50]:
doc = ET.fromstring(get(f"{SEC}/Archives/edgar/data/{cik}/{acc.replace('-', '')}/primary_doc.xml").content)
industry = text(doc, "offeringData/industryGroup/industryGroupType")
if industry == "Pooled Investment Fund":
continue # keep operating companies
total = amount(text(doc, "offeringData/offeringSalesAmounts/totalOfferingAmount"))
sold = amount(text(doc, "offeringData/offeringSalesAmounts/totalAmountSold"))
if max(total or 0, sold or 0) < 1_000_000:
continue
rows.append({"company": name, "state": text(doc, "primaryIssuer/issuerAddress/stateOrCountry"),
"industry": industry, "offered": total, "sold": sold,
"url": f"{SEC}/Archives/edgar/data/{cik}/{acc.replace('-', '')}/primary_doc.xml"})
with open("form-d.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["company", "state", "industry", "offered", "sold", "url"])
w.writeheader(); w.writerows(rows)
print(len(rows), "offerings of $1M+ from operating companies")
Install requests (pip install requests), then run python3 formd.py 40 to read the first 40 filings. When I ran it on Monday 26 October 2026, it picked up Friday's index (23 October) and printed:
index 2026-10-23
188 Form D filings
12 offerings of $1M+ from operating companies
Gotchas
-
"Indefinite" is a valid amount. Funds often put
Indefiniteas the total offering amount. The script treats it as unknown, and the amount sold usually tells you more. -
Many filers are funds. If you want operating companies, drop industry group
Pooled Investment Fund, as the script does. -
D/A is an amendment. The index lists amendments as
D/A. The script skips them, so a raise that's updated isn't counted twice. -
Leave the people out. A Form D also lists executives, directors and promoters, with their addresses. The script never reads
relatedPersonsList. Unless you have a clear reason to process personal data, don't. - Monday's run sees Friday. There's no index for Saturday or Sunday, so the script steps back to the latest one that exists.
The hosted version (disclosure: we built it)
Our Apify Actor, SEC EDGAR Filings & Form D Funding Monitor, does the same thing in formD mode:
- Filters for industry group, issuer state, minimum offering size and securities type. Pooled funds are excluded by default.
- Company data only. Related persons, signers and sales-compensation names and addresses are dropped (you get a count), and so are issuer phone numbers and street addresses.
- Monitor mode remembers what it has returned and gives you only new filings on later runs.
- Pricing: $0.003 per Form D row, plus $0.01 for a monitor run that finds something (base prices; higher Apify plans pay less). Non-matching filings and status rows are free.
{ "mode": "formD", "filedWithinDays": 3, "minOfferingUsd": 1000000, "maxResults": 50 }
It also has company filings, latest filings by form type, full-text search and key financials modes.
Not affiliated with or endorsed by the U.S. Securities and Exchange Commission. Filings are as submitted by the filers. Nothing here is investment advice.
Top comments (0)