Annual revenue for public companies is free from the SEC and needs no API key. Getting it into a spreadsheet is still fiddly. Here is what the work involves, a Python script that does it for one company, and a way to do 50 at once.

Disclosure: I maintain the Actor mentioned in the second half. The script in the first half is free and stands on its own.

The problem

data.sec.gov has everything you need, but a few things get in the way:

  • Ticker to CIK. The API is keyed by a 10-digit, zero-padded CIK, not a ticker. The SEC publishes a mapping file you have to fetch and search.
  • Nested XBRL. The companyfacts endpoint returns one JSON document per company, with every reported fact nested under taxonomy, concept and unit. For big filers that is tens of MB.
  • Concept names change. Apple reports revenue as RevenueFromContractWithCustomerExcludingAssessedTax. Other companies, or the same company in older years, use Revenues or SalesRevenueNet. There is no single "revenue" field.
  • Repeated facts. A 10-K includes prior-year comparatives, so the same period shows up in several filings.
  • Fair access. The SEC asks for at most 10 requests per second per address, and wants a User-Agent that identifies you (name and email).

The DIY way (free)

This uses only the standard library. It maps the ticker to a CIK, downloads the company facts, tries revenue concepts in order, keeps only full-year values reported on a 10-K, and de-duplicates by period (earliest filing wins). Put your own contact in UA.

import json import urllib.request UA = "YourName your@email.com" # the SEC requires an identifying contact REVENUE_CONCEPTS = [ "RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues", "SalesRevenueNet", ] def get(url): req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r) def cik_for(ticker): data = get("https://www.sec.gov/files/company_tickers.json") for row in data.values(): if row["ticker"].upper() == ticker.upper(): return str(row["cik_str"]).zfill(10) raise ValueError(f"unknown ticker {ticker}") def annual_revenue(ticker): cik = cik_for(ticker) facts = get(f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json") gaap = facts["facts"]["us-gaap"] for concept in REVENUE_CONCEPTS: # first concept the company reports  if concept not in gaap: continue rows, seen = [], set() for p in gaap[concept]["units"]["USD"]: if p["form"] != "10-K" or p["fp"] != "FY": continue if not p.get("start") or not p["end"]: continue # keep ~12-month periods only (10-Ks also repeat prior years)  days = (_d(p["end"]) - _d(p["start"])).days if not 350 <= days <= 380: continue if p["accn"] in seen and any(r["end"] == p["end"] for r in rows): continue seen.add(p["accn"]) rows.append({"ticker": ticker, "concept": concept, "end": p["end"], "value": p["val"], "accn": p["accn"], "filed": p["filed"]}) # same period appears in later filings; keep the earliest accession per period  best = {} for r in sorted(rows, key=lambda r: r["filed"]): best.setdefault(r["end"], r) if best: return sorted(best.values(), key=lambda r: r["end"]) return [] def _d(s): import datetime return datetime.date.fromisoformat(s) if __name__ == "__main__": for r in annual_revenue("AAPL")[-5:]: print(r) 

I ran it against AAPL on 2026-11-08. Two requests, and the last five fiscal years:

Fiscal year end Revenue (USD)
2022-09-24 394.3B
2023-09-30 383.3B
2024-09-28 391.0B
2025-09-27 416.2B
2026-09-26 478.8B

For 50 tickers, loop over it with a short time.sleep between companies (stay well under 10 requests per second) and cache the ticker map so you fetch it once, not 50 times. Write the rows with csv.DictWriter and you have your spreadsheet. If that is all you need, stop here.

The scaled way: the Datawharf SEC EDGAR Actor

If you would rather not maintain the script, handle retries, or schedule it yourself, I publish an Apify Actor that reads the same official data.sec.gov APIs. No SEC key. It does the ticker-to-CIK lookup, takes up to 500 companies per run, paces itself at 5 requests per second per run (slowing down if the SEC pushes back) and retries.

Input for annual revenue (list up to 500 tickers in companies):

{ "mode": "companyFacts", "companies": ["AAPL", "MSFT", "GOOGL"], "concepts": ["RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues", "SalesRevenueNet"], "forms": ["10-K"], "fiscalPeriod": "FY", "userAgent": "YourName your@email.com" } 

Download the results as CSV or Excel, push them to Google Sheets with Apify's integration, or put the run on a quarterly Schedule.

From your own Python code (pip install "apify-client>=3"):

import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("datawharf-tools/sec-edgar-api").call(run_input={ "mode": "companyFacts", "companies": ["AAPL", "MSFT", "GOOGL"], "concepts": ["RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues"], "forms": ["10-K"], "fiscalPeriod": "FY", "userAgent": "YourName your@email.com", "maxResultsPerCompany": 6, }) for r in client.dataset(run.default_dataset_id).iterate_items(): print(r["ticker"], r["periodEnd"], r["value"]) 

A JavaScript version and more examples are in the examples repo.

Gotchas

  • Use the period fields. fiscalYear labels the filing, not the period. Use periodStart and periodEnd.
  • Duplicates are normal. Comparatives repeat across filings. Dedupe on period end (keep the latest filing if you want restated figures).
  • Amendments and foreign filers. 10-K does not match 10-K/A; foreign issuers file 20-F/40-F, often under ifrs-full.
  • Not investment advice. Values are as published by the SEC.

Cost

The script is free. The Actor charges about $0.34 per 1,000 rows plus standard Apify usage, so a 50-company, six-year revenue table (about 300 rows) is roughly $0.10 in result charges. You can cap the charge per run.

If something looks wrong in either route, tell me in the comments.

Disclosure: I maintain this Actor. It is not affiliated with or endorsed by the SEC.