← research2026-10-17 · 7 min read

SEC EDGAR Companyfacts API Tutorial

The SEC EDGAR APIs provide public filing data. For developers building US equity research pipelines, two resources are especially useful:

The data is valuable, but it is not a ready-made point-in-time fundamentals panel. You still need to resolve tickers to CIKs, select tags and units, distinguish annual from quarterly facts, track filing dates, handle amendments and restatements, and prevent future information from leaking into historical tests.

This tutorial covers that plumbing directly. If you would rather inspect a finished example first, Tradevo Data publishes a no-signup proof pack at https://github.com/christianpichichero-max/pit-fundamentals.

The two SEC endpoints you need

SEC requests use a zero-padded, 10-digit Central Index Key rather than a ticker.

https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json
https://data.sec.gov/submissions/CIK0000320193.json

The first URL returns company facts grouped broadly as:

facts -> taxonomy -> concept -> units -> observations

A typical observation may contain:

The submissions response contains filing metadata, including accession numbers, forms, filing dates, report dates, and primary documents. It is useful for joining facts back to filings and inspecting amendments.

Respect SEC access rules

Consult the SEC's current developer and fair-access guidance before operating an automated client. Identify requests through a descriptive User-Agent, use a real application name and contact address, and apply conservative rate limiting.

A conservative serial Python session looks like this:

import time
import requests

HEADERS = {
    "User-Agent": "FundamentalsResearch/1.0 research@example.com",
    "Accept-Encoding": "gzip, deflate",
}

session = requests.Session()
session.headers.update(HEADERS)


def sec_get(url):
    response = session.get(url, timeout=30)
    response.raise_for_status()
    time.sleep(0.11)
    return response.json()

For production ingestion, add caching, exponential backoff, retry limits, logging, and a shared rate limiter. Do not let multiple workers independently assume they each have the full request allowance.

Resolve a ticker to a CIK

The SEC publishes a company-ticker mapping at:

https://www.sec.gov/files/company_tickers.json

Use it to build a case-insensitive lookup and pad the numeric CIK:

TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"


def ticker_to_cik(ticker):
    records = sec_get(TICKERS_URL)
    target = ticker.upper()

    for record in records.values():
        if record["ticker"].upper() == target:
            return str(record["cik_str"]).zfill(10)

    raise KeyError(f"Ticker not found: {ticker}")

Cache this mapping. Tickers are convenient identifiers, but they can change or be reused; keep the CIK in your internal security model.

Download companyfacts and submissions


def get_companyfacts(cik):
    url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
    return sec_get(url)


def get_submissions(cik):
    url = f"https://data.sec.gov/submissions/CIK{cik}.json"
    return sec_get(url)


cik = ticker_to_cik("AAPL")
companyfacts = get_companyfacts(cik)
submissions = get_submissions(cik)

Companyfacts may include standard us-gaap concepts and company-specific extension concepts. Do not assume every issuer reports the same economic item under the same tag throughout its history.

Extract a concept without losing provenance

Here is a minimal extractor for observations associated with a concept and preferred unit:

import pandas as pd


def extract_concept(data, concept, taxonomy="us-gaap", unit="USD"):
    concept_data = data["facts"][taxonomy][concept]
    observations = concept_data["units"].get(unit, [])

    rows = []
    for item in observations:
        rows.append({
            "entity": data.get("entityName"),
            "cik": data.get("cik"),
            "taxonomy": taxonomy,
            "concept": concept,
            "label": concept_data.get("label"),
            "unit": unit,
            "start": item.get("start"),
            "end": item.get("end"),
            "value": item.get("val"),
            "filed": item.get("filed"),
            "form": item.get("form"),
            "fiscal_year": item.get("fy"),
            "fiscal_period": item.get("fp"),
            "accession": item.get("accn"),
            "frame": item.get("frame"),
        })

    return pd.DataFrame(rows)


revenue = extract_concept(companyfacts, "RevenueFromContractWithCustomerExcludingAssessedTax")
revenue = revenue[revenue["form"].isin(["10-K", "10-Q", "10-K/A", "10-Q/A"])]

Keep the accession number, filing date, form, period boundaries, taxonomy, concept, and unit. Collapsing immediately to fiscal_year -> value destroys information needed to audit duplicates and revisions.

Gotcha 1: XBRL tag drift

Revenue is not always represented by one universal tag. An issuer may use a legacy standard tag, migrate to a newer tag, or report an extension concept. Similar problems occur with cash flow and balance-sheet items.

A durable concept mapping therefore needs:

  1. Candidate tags for each economic concept.
  2. Valid units and expected period type.
  3. Issuer- and date-aware precedence rules.
  4. Overlap checks when tags coexist.
  5. QA flags rather than silent guesses.

Do not add every revenue-like tag together. Two tags may be alternative representations of the same total, or one may be a component of another.

Gotcha 2: annual, quarterly, and Q4 values

A fact from a 10-Q may cover a quarter or a year-to-date interval. A 10-K annual fact does not automatically provide a separately tagged fourth quarter.

You can sometimes derive Q4 as annual minus the first three quarters, but only when periods, units, concepts, and accounting scope align. Label derived values explicitly. Per-share values and diluted share counts are especially unsafe to derive through simple subtraction because averaging and weighting matter.

Treat fp, form type, start, end, and duration together. The frame field can help, but it is not present or sufficient for every observation.

Gotcha 3: restatements and duplicate periods

The same fiscal period may appear in an original filing, a later comparative filing, and an amendment. Those values can differ.

For each logical fact, preserve at least:

A latest-only table answers, “What does the record say now?” A point-in-time table answers, “What could have been known on this date?” Those are different datasets.

Gotcha 4: filing lag and lookahead bias

Fiscal period end is not the information-availability date. If a company's year ends in December and its filing arrives later, a backtest must not expose that filing in December.

For an as-of date, filter facts using filing availability:


def as_of_view(df, as_of):
    cutoff = pd.Timestamp(as_of)
    work = df.copy()
    work["filed"] = pd.to_datetime(work["filed"])
    work = work[work["filed"] <= cutoff]

    keys = ["cik", "concept", "unit", "start", "end"]
    return (
        work.sort_values([*keys, "filed", "accession"])
            .groupby(keys, as_index=False)
            .tail(1)
    )

This is only a starting point. Same-day treatment, amendments, tag equivalence, duplicate contexts, and filing acceptance timestamps may require an explicit policy. For the broader research issue, see https://tradevodata.com/blog/lookahead-bias-fundamental-backtests?utm_content=blog-sec-edgar-companyfacts-api-tutorial and https://tradevodata.com/blog/point-in-time-fundamentals-data?utm_content=blog-sec-edgar-companyfacts-api-tutorial.

Raw EDGAR versus a normalized API

Question Build directly on SEC EDGAR Use Tradevo Data
Source Public SEC filing data Point-in-time US equity fundamentals from SEC EDGAR
Access model Raw companyfacts and submissions JSON One normalized JSON endpoint plus Pro bulk downloads
Concept mapping You design and maintain it 16 annual concepts and 7 quarterly concepts
Point-in-time logic You implement filing-date and revision rules Server-side as_of, inclusive when first_filed <= as_of
Restatements You identify and retain revisions original_value, latest_value, restatement flag, and qa_status
Coverage Depends on the pipeline you build Annual: 5,168 US companies and 632,466 rows; quarterly: 5,382 companies and 1,089,635 rows; no non-US or delisted coverage
Formats SEC JSON and your own storage JSON API and bulk downloads; Parquet is not included
Cost Public source; engineering and operations remain Card-backed seven-day trial that renews at $29/month unless canceled

Tradevo Data is an honest budget tier of research-grade point-in-time data, not the only credible option. Sharadar, Tiingo, and QuantConnect may fit different workflows. See their pricing pages and documentation directly: https://data.nasdaq.com/databases/SF1, https://www.tiingo.com/pricing, and https://www.quantconnect.com/pricing.

When alternatives win

Raw EDGAR wins when you need custom XBRL concepts, filing-level text, bespoke amendment policies, extension-tag research, broader filing coverage, or complete control over lineage. It can also win when maintaining ingestion and normalization is part of your research advantage.

Sharadar, Tiingo, or QuantConnect may win when their coverage, delivery model, integrations, research environment, or supported workflows better match your needs. Verify current features and terms in each provider's documentation and see their pricing pages directly: https://data.nasdaq.com/databases/SF1, https://www.tiingo.com/pricing, and https://www.quantconnect.com/pricing.

Tradevo Data is not a fit if you need non-US or delisted coverage, Parquet delivery, derived TTM values, or concepts outside its supported annual and quarterly sets.

When to build it yourself

Build directly on EDGAR if you can commit to monitoring schema changes, updating mappings, rerunning QA, storing historical revisions, and documenting every point-in-time decision. The source is public; the practical cost is sustained engineering attention.

When a maintained layer is more practical

A normalized service can be useful when your objective is testing research ideas rather than operating XBRL infrastructure. Tradevo Data exposes:

/v1/fundamentals?ticker&as_of[&concept][&period=annual|quarterly]

Annual is the default. Pro provides complete available history, 5,000 requests per day, and bulk access through /v1/download and /v1/snapshot?as_of, each supporting period=annual|quarterly.

Its annual dataset contains 632,466 point-in-time rows across 5,168 US companies, with up to 12 fiscal years and 16 concepts: Revenue, NetIncome, Assets, StockholdersEquity, OperatingCashFlow, EPSDiluted, DilutedShares, GrossProfit, OperatingIncome, PretaxIncome, IncomeTaxExpense, CapitalExpenditures, CashAndCashEquivalents, CurrentAssets, CurrentLiabilities, and NetPPE. Its quarterly dataset contains 1,089,635 rows across 5,382 companies and 7 concepts. Q4 is reported where tagged or derived and labelled where supported; EPS and share counts are not derived for Q4. TTM values are not provided.

Each row includes first_filed, original_value, latest_value, a restatement flag for same-tag changes above 0.5%, including amendments, and qa_status. The dataset labels 37,804 restatements.

Start with the public proof pack: https://github.com/christianpichichero-max/pit-fundamentals. It contains 225 rows covering five companies and their latest three fiscal years, plus the methodology, without signup.

If the schema fits your work, the card-backed seven-day free trial at https://tradevodata.com/?utm_content=blog-sec-edgar-companyfacts-api-tutorial covers 10 companies, a rolling three-year history, and 100 requests per day without bulk. It renews at $29/month unless canceled. Documentation is at https://tradevodata.com/docs?utm_content=blog-sec-edgar-companyfacts-api-tutorial.

Not investment advice.

Check your own backtest against the public proof pack.

5 companies, latest 3 fiscal years, 225 point-in-time rows across 16 concepts, full methodology—no signup.

Or download the CSV straight from GitHub — CC0, no signup.

Related comparisons