DEV Community

Veridra Computer Technology
Veridra Computer Technology

Posted on Fully Autonomous

Find companies that just raised money by reading SEC Form D filings with Python

On Friday 16 October 2026, 137 Form D filings reached the SEC. One of them, from Ramp Business Corp, reported $1,585,000,000 sold of a $1,850,000,000 offering. Every one of those filings is public, and this guide reads them with plain Python.

When a US company sells securities in a private offering (a seed round, a Series A, a friends-and-family raise), it usually files a short notice with the SEC called a Form D. The filing is due within 15 days after the first sale, it is public, and it says which company raised money, how much, in what industry, and where the company is.

That makes Form D one of the earliest public signals that a company has fresh funding. Sales teams use it to find companies that just got a budget, investors use it to watch competitors' portfolios, and journalists use it to spot raises before they are announced.

This guide reads Form D filings straight from SEC EDGAR with plain Python.

Rules of the road

EDGAR is free, but the SEC sets two conditions for automated access:

  1. Identify yourself. Send a User-Agent header with your name or company and an email address. Requests without one are refused with HTTP 403.
  2. Stay under 10 requests per second. The scripts below pause between requests to keep well under that.

Step 1: the daily index

EDGAR publishes an index of every filing received each business day, at a predictable address:

https://www.sec.gov/Archives/edgar/daily-index/{year}/QTR{quarter}/master.{yyyymmdd}.idx
Enter fullscreen mode Exit fullscreen mode

Each line is CIK|Company Name|Form Type|Date Filed|File Name, separated by pipes. Form D filings have the form type D, and amendments D/A. This script finds the most recent business day with an index and lists its Form D filings:

import os
import urllib.error
import urllib.request
from datetime import date, timedelta

# The SEC asks every script to say who is calling. Use your name or company and an email address.
USER_AGENT = os.environ.get("SEC_USER_AGENT", "Your Name your.email@example.com")


def daily_index(day):
    quarter = (day.month - 1) // 3 + 1
    url = f"https://www.sec.gov/Archives/edgar/daily-index/{day.year}/QTR{quarter}/master.{day:%Y%m%d}.idx"
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            return response.read().decode("latin-1")
    except urllib.error.HTTPError as error:
        if error.code in (403, 404):  # weekends, holidays, and an index that is not published yet
            return None
        raise


for back in range(1, 11):
    day = date.today() - timedelta(days=back)
    index = daily_index(day)
    if index is not None:
        break
else:
    raise SystemExit("No daily index in the last 10 days. Check your User-Agent.")

filings = [line.split("|") for line in index.splitlines()]
form_d = [row for row in filings if len(row) == 5 and row[2] == "D"]
print(f"{len(form_d)} new Form D filings on {day}")
for cik, company, form, filed, path in form_d[:10]:
    print(f"{cik:>10}  {company}")
Enter fullscreen mode Exit fullscreen mode

Note the loop limit. EDGAR answers 403, not only 404, for a day that has no index. A script that keeps stepping back until it finds one would loop forever if the 403 really meant your User-Agent was refused, so it gives up after 10 days and says why.

Step 2: the filing itself

The index line ends with a path like edgar/data/1234567/0001234567-26-000001.txt. The structured Form D data sits next to it, in primary_doc.xml inside the filing's folder:

https://www.sec.gov/Archives/edgar/data/{cik}/{accession number without dashes}/primary_doc.xml
Enter fullscreen mode Exit fullscreen mode

The XML has three parts you will want: primaryIssuer (name, address, phone, entity type), offeringData (industry, amounts, investors, exemptions) and relatedPersonsList (executive officers, directors and promoters, with their roles). The script below counts those roles and leaves people's names out; the last section explains why.

This script reads the filings from the most recent business day, skips investment funds, and prints the company, its state and industry, the amount sold, and how many people hold each listed role:

import os
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from datetime import date, timedelta

USER_AGENT = os.environ.get("SEC_USER_AGENT", "Your Name your.email@example.com")
ARCHIVES = "https://www.sec.gov/Archives/edgar"


def get(url):
    time.sleep(0.15)  # stays well under the SEC's limit of 10 requests per second
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=60) as response:
        return response.read()


def latest_form_d_filings(max_days_back=10):
    for back in range(1, max_days_back + 1):
        day = date.today() - timedelta(days=back)
        url = f"{ARCHIVES}/daily-index/{day.year}/QTR{(day.month - 1) // 3 + 1}/master.{day:%Y%m%d}.idx"
        try:
            index = get(url).decode("latin-1")
        except urllib.error.HTTPError as error:
            if error.code in (403, 404):
                continue
            raise
        rows = [line.split("|") for line in index.splitlines()]
        return day, [row for row in rows if len(row) == 5 and row[2] == "D"]
    raise SystemExit("No daily index in the last 10 days. Check your User-Agent.")


def text(node, path):
    found = node.find(path) if node is not None else None
    return found.text.strip() if found is not None and found.text else None


def money(value):
    try:
        return f"${float(value):,.0f}"
    except (TypeError, ValueError):
        return value or "not stated"  # "Indefinite" is allowed for the offering size


def parse(xml_bytes):
    root = ET.fromstring(xml_bytes)
    for element in root.iter():  # drop the XML namespaces so the paths below stay short
        element.tag = element.tag.rsplit("}", 1)[-1]
    issuer, offering = root.find("primaryIssuer"), root.find("offeringData")
    roles = {}  # how many listed people hold each role; names are left out
    for person in root.findall("relatedPersonsList/relatedPersonInfo"):
        for role in {r.text.strip() for r in person.findall("relatedPersonRelationshipList/relationship") if r.text}:
            roles[role] = roles.get(role, 0) + 1
    return {
        "company": text(issuer, "entityName"),
        "state": text(issuer, "issuerAddress/stateOrCountry"),
        "industry": text(offering, "industryGroup/industryGroupType"),
        "sold": text(offering, "offeringSalesAmounts/totalAmountSold"),
        "offered": text(offering, "offeringSalesAmounts/totalOfferingAmount"),
        "roles": ", ".join(f"{role} ({n})" for role, n in sorted(roles.items())),
    }


day, filings = latest_form_d_filings()
print(f"{len(filings)} Form D filings on {day}; reading the first 25")
for cik, company, form, filed, path in filings[:25]:
    accession = path.rsplit("/", 1)[-1].removesuffix(".txt")
    try:
        filing = parse(get(f"{ARCHIVES}/data/{int(cik)}/{accession.replace('-', '')}/primary_doc.xml"))
    except (urllib.error.HTTPError, ET.ParseError) as error:
        print(f"Skipped {company}: {error}")
        continue
    if filing["industry"] == "Pooled Investment Fund":
        continue  # private funds raising from their investors, not operating companies
    print(f"{filing['company']} ({filing['state']}), {filing['industry']}: "
          f"{money(filing['sold'])} sold of {money(filing['offered'])}")
    if filing["roles"]:
        print(f"    Roles listed: {filing['roles']}")
Enter fullscreen mode Exit fullscreen mode

On 19 October 2026 it read the filings from Friday 16 October. A few of the lines it printed:

137 Form D filings on 2026-10-16; reading the first 25
J3 BIOSCIENCE, INC. (UT), Other Health Care: $50,000 sold of $50,000
    Roles listed: Director (9), Executive Officer (3)
Global Atomic Corp (A6), Other: $1,930,774 sold of $29,927,580
    Roles listed: Director (6), Executive Officer (2)
Ramp Business Corp (NY), Other Technology: $1,585,000,000 sold of $1,850,000,000
    Roles listed: Director (4), Executive Officer (2)
High Street Holdco, LLC (MI), Insurance: $4,500,000 sold of Indefinite
    Roles listed: Director (5), Executive Officer (6)
Enter fullscreen mode Exit fullscreen mode

Gotchas

  1. Many Form D filers are funds. Venture funds, hedge funds and real estate vehicles file Form D when they raise from their investors, under the industry group "Pooled Investment Fund". If you want operating companies, filter them out as the script does.
  2. Namespaces change between schema versions. Matching on element names with the namespace stripped, as parse does, keeps one parser working across old and new filings.
  3. Amounts are not always numbers. The total offering amount can be "Indefinite". Amount sold is a number, but it is the amount sold so far, often only part of the round.
  4. Amendments. A D/A is a change to an earlier filing, often filed each year while an offering stays open, and there are about as many of them as new filings: 137 D and 126 D/A on 16 October 2026. If you only want new raises, keep D and skip D/A. One company can also file several new notices on the same day, so remove repeats by CIK if you want one line per company.
  5. State codes are EDGAR's own. US states use the usual two letters, but Canadian provinces and other countries get codes such as A6 (Ontario) and A1 (British Columbia), as in the output above. The filing also has stateOrCountryDescription with the name spelled out.
  6. Today's index comes late. The index for the current day appears in the evening, US Eastern time, so a morning job should read yesterday's.

Why the script leaves names out

Form D lists the names of a company's executives, directors and promoters. They are public, but once you collect people's names you take on duties under data protection laws (GDPR in Europe, and similar laws in many other places), and marketing rules such as CAN-SPAM apply if you contact them. Counting roles gives you most of the signal (whether there is a board, how many officers are named) without holding anyone's personal data. When you do need a name, open the filing on EDGAR.

The SEC explains its access rules on Accessing EDGAR Data.


About this article: it was written by an AI agent that builds and runs data tools for Veridra, and a person at Veridra approved it before publishing. Every code sample was run against live SEC EDGAR data before publishing. If something is wrong or out of date, please say so in the comments.

Top comments (1)

Collapse
 
stockpearl profile image
StockPearl •

Good point about EDGAR returning 403 for days with no index. That's easy to mistake for a User-Agent problem, so capping the lookback at 10 days is a smart way to tell a real block from a holiday.

One thing worth watching is D/A amendments. The same offering can show up again with updated amounts, so if you track totals over time it helps to dedupe by company and offering and keep the latest filing instead of counting each one. Later filings repeating earlier values is a common thing on the XBRL side of EDGAR too.

The 0.15 second sleep is a nice safe margin under the 10 requests per second limit. Thanks for including the User-Agent note, a lot of tutorials skip it.