Every number the API returns is a figure a company filed, returned with the filing it came from. Nothing is estimated, modelled, or filled in.
https://compsdesk.com/api/v1Authorization: Bearer <your token> — your token is the
#k= value in the access link emailed to you. It is in the URL fragment, so it never
reaches a server log.When a company did not disclose something, the API returns "value": null with a
reason. It does not return 0, and it does not substitute a
similar tag. A zero in a spreadsheet is a number someone will subtract; a null is a question. If
you ever see a figure from this API that you cannot trace to a filing, that is a bug worth
reporting — hello@compsdesk.com.
What the service is currently serving, including the newest filing date in the data. Check this before assuming a missing recent figure is a coverage gap — it may simply be newer than the last refresh.
curl https://compsdesk.com/api/v1/health
One figure, with its provenance. The Excel CD.GET function calls this.
| Parameter | Required | Notes |
|---|---|---|
ticker | yes | Ticker or 10-digit CIK |
metric | yes | An operating metric, a family metric (bank / REIT / insurer), a family disclosure field, or a disclosed XBRL tag — see /metrics, which lists all four |
period | no | latest (default), FY2024, 2024, or an exact period end 2024-12-31 |
basis | no | annual (default), quarterly, or any — see below. ltm is not served on this route and asking for it returns a 400 naming the two routes that do serve it, /ltm and /excel/series?basis=ltm; no annual or quarterly figure is substituted for it |
A retired tag is the trap to know about, and staleness_warning is how you catch
it. Ask for a specific XBRL tag and you get the newest value of that tag — which is the
only honest answer, but not always the answer you wanted. Apple stopped tagging
Revenues after FY2018 and reports
RevenueFromContractWithCustomerExcludingAssessedTax instead, so
CD.GET("AAPL","Revenues") correctly returns a 2018 figure while your other columns
return 2026. When the tag you asked for lags the filer's newest disclosure by more than a year, the
response carries staleness_warning naming what that filer reports now. We do not
silently substitute a different tag — you asked for a specific one, and swapping it would make the
citation a lie.
basis matters more than it looks. A filer
discloses the same tag over several windows that end on the same day. Apple's Q2 FY2026
filing reports NetIncomeLoss for the quarter ($29.6bn) and for the six months to that
same date ($71.7bn). Asking for a tag without saying which window you mean is an ambiguous
question, so the API answers the annual one by default and tells you what it gave you: every
response carries period_type (FY, Q, instant,
other) and period_start. Use basis=quarterly for the discrete
quarter. Balance sheet items are instants — they have no duration and are returned under any basis.
other is the largest duration bucket we hold, and it is where interim cash
flow lives. Companies file interim income-statement and cash-flow figures
year-to-date — six months, nine months — not as discrete quarters. Those facts are stored
with period_type=other, and there are more of them (15,192,660 rows) than there are
discrete quarters (14,459,975). basis=quarterly deliberately excludes them, so an
eight-quarter operating-cash-flow pull on basis=quarterly returns mostly nulls: the
quarters were never filed. Use basis=any to get them, and read
period_start and period_end on every row to see the exact window each
figure covers. CompsDesk will never subtract one filed period from another to manufacture
a discrete quarter — a derived quarter is not a filed fact and could not be cited to a filing.
Computed ratio columns exist only at annual grain. metrics_derived
is built from 10-K/20-F/40-F durations of 330–400 days plus the balance-sheet instants at those
period ends, so a derived name at basis=quarterly returns value:null with
that reason rather than the annual number wearing a quarterly label. Every response states
basis (what you asked for) and grain (the window the number covers)
separately, because they are different facts.
“latest” is not a shared period. period=latest resolves
per filer, so a row of latest quarterly cells across a cohort can hold different
quarters — Q2 for one filer and Q1 for the next, each individually correct. In Excel, use
CD.GETP rather than CD.GET when a row must be period-aligned: it spills
unit, period_end, period_start, period_type and
grain beside the value so the mix is visible in the sheet.
A figure filed in another currency is refused by CD.GET, not converted.
87 covered filers report in a currency other than the US
dollar (measured as the dominant filed unit across each filer's latest annual accession — CNY, SGD,
HKD, JPY, CAD and others), and this endpoint has always returned the filed unit beside the value.
CD.GET returns a bare number, which cannot carry a denomination — so when the filed unit
is a foreign currency it returns #N/A naming the currency rather than a number that
would sit in a dollar column looking like dollars. CD.GETP and CD.SERIES
serve those figures in full, each labelled with its own filed unit, and CD.SERIES
additionally discloses a reporting-currency break where a filer changed the currency it reports in.
Nothing is converted anywhere: see the FX ruling below.
curl -H "Authorization: Bearer $CD_TOKEN" \
"https://compsdesk.com/api/v1/excel/metric?ticker=AAPL&metric=Revenues&period=FY2025"
The same figure, plus the accession number and a direct link to the filing on EDGAR. This is the endpoint to use when someone asks you where a number came from.
Companies sharing the subject's 4-digit SIC, ordered by closeness in latest-year revenue. Deterministic: the same request returns the same cohort, which is what makes a comparison defensible three months later.
| Parameter | Required | Notes |
|---|---|---|
ticker | yes | Subject company |
count | no | Default 10 |
Read size_warning before you use the cohort. Closest available is not the same
as comparable. Some SIC codes contain very few large filers, so the nearest peers by revenue can
still be orders of magnitude away — CD.PEERS("AAPL") reaches down to a company with
$15.1m of revenue against Apple's $416.2bn. We do not drop those names (you asked for SIC peers, and
silently returning three where you asked for six would hide that no closer ones exist), so every
peer carries its own revenue and size_ratio in peer_detail,
and any gap of 10× or more is called out in size_warning.
The comp set: market cap, enterprise value and trading multiples for a subject and its peers — EV/Revenue, EV/EBITDA, P/E, P/B — with peer median and quartiles.
| Parameter | Required | Notes |
|---|---|---|
ticker | yes | Subject company |
peers | yes | Comma-separated tickers, up to 50 |
prices | yes | TICKER=PRICE pairs, e.g. AAPL=232.10,MSFT=418.55 |
You supply the prices, deliberately. We do not carry a price feed and will not resell one: redistributing quotes inside a paid product is a licensing liability, and a scraped quote is the one figure we could not trace to a filing — which would corrode the guarantee that makes the rest worth paying for. It is also the one number you already have on screen. What you do not have is period-aligned, tag-cited EBITDA and net debt for twelve peers, so we supply that: shares outstanding, revenue, EBITDA, net debt, book equity and diluted EPS all come from filings.
Two things the response is explicit about, because both are places a comp table normally
misleads. The share count is newer than the financials — it comes off the most recent filing's
cover page while revenue and EBITDA come from the last completed fiscal year, so
shares_as_of and fiscal_end differ; that is standard practice, stated
rather than hidden, and shares_basis names which tag it came from (a weighted-average
diluted count is an average over a year, not a count on a date). A multiple over a negative or
absent denominator is not a number — EV/EBITDA on negative EBITDA and P/E on a loss return null
with a reason in not_meaningful, never a figure, and are excluded from the peer
statistics rather than dragging a median.
Enterprise value is never assumed equal to market cap when net debt is unknown — it returns null and says so, because treating unknown leverage as zero understates EV for exactly the companies where it matters most.
The screen: one derived metric across every company, filtered. Rows where the metric is null are excluded — a screen is a ranking, and a company that did not disclose the input has no place in one.
| Parameter | Required | Notes |
|---|---|---|
metric | yes | A derived metric name from /metrics |
sic | no | One 4-digit SIC code, or a comma-separated set of them: sic=2836,8731. A set, not a range — SIC is a hierarchy of labels, not an ordered scale, so "2836 to 8731" would sweep 337 unrelated codes |
family | no | Narrow the screen to one metric family (operating, reit, bank, insurer, …). Nine SIC codes carry two families, so a SIC-only screen can return a cross-family set — see below |
min_revenue / max_revenue | no | Size band, USD. Tested on each company's latest held fiscal year — its current size, not any year it once was. Every row states the year the test used in screen_basis |
fiscal_year | no | e.g. 2025 |
limit | no | Default 200, capped by your plan — see Plan limits. On an Analyst membership that ceiling is 1,000 rows per request |
all_years | no | Return every fiscal year per company instead of one row each |
One row per company. Each company contributes its latest year that carries the
metric you asked for, so a screen ranks companies rather than company-years. (The size band is a
separate test, applied to the company's latest held year — see below.) Pin a year with
fiscal_year, or set all_years=true when you want the full history as
evidence rather than a ranking. Every row carries fiscal_end, so you can see how
current each company's latest filing actually is.
Rows also carry basis — how CompsDesk formed that figure
(filed means the company disclosed it directly; derived means it was
computed from disclosed inputs) — and listed, which is false for the
many real filers that have no ticker because they are unlisted, deregistered, or foreign private
issuers. Those rows are genuine data; they are labelled rather than dropped. Every row also carries
cik, which is the key those tickerless rows are followable by: it is what
/excel/metric and /excel/source accept, so any figure on a screen can be
taken back to the filing it came from.
Every row carries family, and the response says when the screen spans more
than one. Margins, capital structure and cash conversion do not mean the same thing for an
operating company, a REIT, a bank and an insurer, and nine SIC codes carry two families — so a
SIC-only screen can hand you a column that is two populations. CompsDesk does not refuse that (a
screen over two families is a legitimate thing to ask for) but it will never let you find out after
the median: the response carries family_warning naming the families returned, and
family= narrows the screen to one.
Row count is not population count, and the response now reports both. A screen
excludes companies that did not file the input, so membership moves with metric: the
same SIC set and revenue floor returns 35 companies on operating_margin_pct, 28 on
gross_margin_pct and 27 on rnd_pct_revenue. population gives
matching_filters_any_metric, screened,
returned_in_this_response, excluded_metric_not_filed and
cut_by_row_ceiling, so the difference is a number you can read rather than one you have
to infer. The two reasons a response is shorter than the population are reported
separately. A company can be missing because it did not file the input, or because your
limit (or your plan's row cap) stopped the response before it — those are different
facts about different things, and population_note states each one only when it applies
and names the row ceiling by number when it bit. screened is measured over the whole
screen, before any ceiling is applied.
A screen ranks each company's latest qualifying year, which is not the same as a
current year. A company that stopped filing in 2013 still has a latest qualifying year, and
it will sit in the ranking beside one from last quarter. Each row carries a lifecycle
block — what we hold, through when, and the form, filed date and accession it was last filed on —
and the response carries a recency summary and a lifecycle_warning when
rows are old. That status is derived from observed filing behaviour alone: CompsDesk reads the
filing record, not the transaction, so it never tells you a company was acquired, taken private or
deregistered. Where no lifecycle record exists, lifecycle.status is null
with the reason — an absent measurement is never presented as a current one.
The size band means current size, and every row names the year it was tested on.
min_revenue/max_revenue are applied to each company's latest held
fiscal year. They are not applied to whichever year happens to fall inside the band: until
14 August 2026 they were, which meant a company that had once been small enough reached back
through its own history until it found a year that fit. On one industrial-machinery screen
($500m–$5bn) that admitted a filer on its FY2014 revenue of $4.61bn whose current revenue is
$23.23bn, and tightening the ceiling to $4bn removed nobody at all. Every row now carries
screen_basis: the fiscal year the size test used, the revenue figure it used, the year
the metric value came from, the newest revenue we hold for that company, and whether that newest
figure is still inside the band you asked for. The value year can still be older than the size year
— a company whose newest year carries no value for your metric contributes its newest year that
does, rather than being dropped — and when a company has since grown out of your band,
lifecycle_warning says so in those terms rather than leaving you to infer it from a
date. Companies whose latest year discloses no revenue we can resolve are counted under
population.excluded_size_not_testable: we cannot state their current size, so a size
screen does not admit them, and that is a fact about our resolution rather than about their
disclosure.
curl -H "Authorization: Bearer $CD_TOKEN" \
"https://compsdesk.com/api/v1/query?metric=gross_margin_pct&sic=7372&min_revenue=250000000"
The client filter. Everything /query does, plus the filters a real
screen needs: tier, lifecycle, repeatable metric
thresholds, sort/order, offset paging and
format=csv. /screen and /query resolve through the
same selection function, so the two routes cannot return different populations for the same
filters — that is a property of the code, not a promise: three separate screen implementations once
answered one industrial-machinery screen with 26, 14 and 16 companies, and merging them into one
resolver is what stops that recurring.
| Parameter | Notes |
|---|---|
metric | Required. The metric the screen ranks on |
sic, family | As on /query. sic is a comma-separated set, never a range |
min_revenue / max_revenue | Tested on each company's latest held fiscal year |
tier | FULL, PARTIAL, THIN, or a family-qualified tier such as FULL-REIT. A bare class name matches every tier in that class. An unrecognised tier is a 400 that lists the tiers the artifacts actually publish, rather than an empty result you would read as "no companies match" |
lifecycle | current · dormant · dormant_long · unmeasured |
threshold | Repeatable, metric:op:value — e.g. threshold=gross_margin_pct:gte:40&threshold=revenue:gte:1000000000. Operators: gte gt lte lt eq ne |
sort / order | value · revenue · fiscal_end · ticker · name · sic, and desc/asc |
limit / offset | Paging over one ordering, and that ordering is total: ties on the sort column break on cik and then fiscal_end, so repeating a call reproduces the same sequence and two pages of one screen can neither repeat a company nor make one unreachable. limit is bounded by your plan's row cap; limit=0 is an honest empty page and a negative limit is a 400 rather than a guess at what you meant |
format | json (default) or csv |
A threshold that could not be tested is never reported as a threshold you failed.
gross_margin_pct:gte:60 removes two different kinds of company: the one that filed a
31% gross margin, and the one that filed no input from which a gross margin can be formed at all.
Those are a fact about a figure and a fact about a disclosure, and the response counts them as
excluded_threshold_not_met and excluded_threshold_input_not_filed rather
than merging them into one sentence.
What a tier screen can actually reach, measured rather than implied. Tier is read
from the family universe artifacts — the only place a filer's tier is published — and those four
files carry, between them, 3,291 FULL,
58 FULL-BANK,
170 FULL-REIT,
81 FULL-INSURER,
284 THIN-BANK,
31 THIN-REIT,
21 THIN-INSURER,
5 PARTIAL-BANK,
4 PARTIAL-REIT and
4 PARTIAL-INSURER filers. Read that list before
reading a count: tier=PARTIAL returns 13 companies
and every one of them is a bank, a REIT
or an insurer, because the operating artifact this route reads publishes its FULL rows
only. Operating filers below the full bar are tiered elsewhere and are not reachable through this
parameter today. That is a statement about which artifact the route reads, not about how many
partial-coverage operating companies exist.
Every row carries its tier and the reason for it. The family artifacts publish a
tierReason per filer — how many core years resolved, and which leg was binding — and
this route now returns it verbatim on the row as tier_reason, alongside
tier and tier_source (the artifact the tier came from). A THIN-BANK row
reads, for example, "no fiscal year with all 6 core metrics resolved across 8 fiscal year(s) on
file; the binding leg for this filer is check_revenue (0 of 8)" — that is the answer to "why is
this bank not FULL", per filer, on the same response as the row.
Two limits, both stated rather than left to be discovered. First, the reason is
verbatim or absent: nothing here authors one. The operating artifact
(universe.json) holds the admitted set alone and publishes no tierReason
field at all, so an operating row carries tier: "FULL" with
tier_reason: null — and tier_source names that file, so you can tell "our
artifact publishes no reason" from "this filer has no reason". Second, the response carries a
tier_coverage block on every screen that touched a family with tiers, filtered or not:
the tier vocabulary, how many filers sit at each tier on file, and how many of each are in the
response you are reading. A screen returning 58 rows under tier=FULL-BANK is 58 of 347,
and the other 289 are excluded by our admission bar — a fact about what we can resolve from
filings, not about the companies.
The tier and lifecycle screens tell you what the artifact behind them covers.
Lifecycle status is measured for tickerless filers — the population the listed universe
drops — so lifecycle=current excludes every listed company, not because those companies
are not current but because that measurement does not speak about them. The response says so on the
filter rather than leaving you to read the row count as "the current companies".
Every row says how far its value year has drifted from the company's current size.
Applying the band to the latest held year stopped companies entering a band through a year they no
longer resemble. It did not stop the value coming from an older year — a company whose
newest year carries no value for your metric contributes its newest year that does. So each row
carries screen_basis.value_year_revenue_vs_current_pct and a
stale_band boolean that fires past 20%. Nothing is dropped: an older year with an
accession behind it is evidence, and the flag says check the year before you quote it.
CSV carries the same disclosures as the JSON — the population counts and every
note as # header lines, and each row's lifecycle reason, size basis and stale-band note
as their own columns. In a CSV a blank is not a refusal; it is a zero waiting to happen.
curl -H "Authorization: Bearer $CD_TOKEN" \
"https://compsdesk.com/api/v1/screen?metric=operating_margin_pct&sic=7372\
&min_revenue=1000000000&threshold=gross_margin_pct:gte:60&sort=value&limit=50"
Trailing twelve months, with its component quarters cited. Returns, per filer and
per stitched tag, the LTM value, the fiscal period it anchors on, the exact twelve months it covers,
and the filed quarters it was built from — each with its form, filed date, accession and filing URL.
Five tags are stitched: Revenues,
RevenueFromContractWithCustomerExcludingAssessedTax, NetIncomeLoss,
OperatingIncomeLoss and
NetCashProvidedByUsedInOperatingActivities. CompsDesk computes no LTM EBITDA
— there is no D&A term in the LTM metric set — and no LTM ratio.
The stitch runs on the filer's own fiscal quarters, not the calendar's.
fiscal_period reads LTM_FY2026_Q3 because that is what the filer calls the
quarter, and ltm_window_start/as_of state the exact twelve months. Two
companies' LTM figures therefore end on different days unless their fiscal years coincide, so an LTM
comparison across filers is not period-aligned and CompsDesk computes no statistic over a set of
them.
The citation is checked, not asserted. The cited components are re-summed through
the identity and compared to the published value; components.reconciles says whether
they agree and components.delta gives the difference when they do not. A small number
of anchors do not reconcile, because the restatement layer can hold a later-filed version of a
component quarter than the view the stitch was built from. Those are published with the flag — the
published value stays the authority, and you can see exactly why the two differ.
A missing LTM is a null with a reason, never an absent row. Every anchor the
stitch could not complete carries ltm_null_reason naming the term that was missing.
Nothing is annualised and no partial sum is ever published.
curl -H "Authorization: Bearer $CD_TOKEN" \
"https://compsdesk.com/api/v1/ltm?ticker=AAPL"
In Excel this is basis="ltm" on CD.GET,
CD.GETP and CD.SOURCE (add-in 2.2 and later), and those formulas call
this route rather than /excel/metric — which accepts
annual|quarterly|any only, and would 400 on ltm. One authority for one
number: the figure in the cell and the citation behind it come from the same read.
CD.GETP(...,"ltm") spills the window, the filer's own LTM period label, whether the
cited components re-sum to the published value, and the fiscal-calendar caveat, because two filers'
LTM windows end on different days unless their fiscal years coincide and a row of LTM cells will not
show you that by itself. basis="ltm" takes no period argument — there is
one anchor per filer and tag — and a workbook talking to a deploy that does not serve this route
gets #N/A saying exactly that, never an annual figure standing in for a trailing one.
A whole history in one call. /excel/metric returns one scalar, so a
sixteen-year revenue history for four companies was 64 calls against your own rate limit. This
returns the series: ticker, metric (a derived metric, a family metric or a
raw XBRL tag), basis=annual|quarterly|instant|ltm, optional from_fiscal_year/
to_fiscal_year bounds, and order=newest|oldest. Points come back
oldest-first, one per period end and grain, deduplicated
to the latest-filed version of each — a restated year cannot appear twice and draw a spike in your
chart. Every filed point carries its form, period start and end, filed date, accession and filing
URL; every derived point carries the route that returns its input tags and accessions.
limit keeps the newest periods, and the response says which end it kept.
Until 2026-08-19 it kept the oldest: basis=quarterly&limit=8 on JPMorgan's
net interest income returned the eight quarters ending 2010-09-30 — eight real, fully cited
quarters, answering "the last eight quarters" with the first eight, and nothing on the response
said which end had survived. history_depth.kept_end now states it as data, and
order=oldest asks for the other end explicitly.
Balance-sheet histories are served, and an instant is admitted only where it belongs.
Deposits, regulatory capital, risk-weighted assets and the loan allowance are instants — a
stock at a date, with no duration — and this route used to read basis=annual as FY
durations only, so those histories returned nothing at all here while the refusal recommended
basis=instant, which the same route answered with a 400. instant is now a
basis, returning every filed instant. Under annual and quarterly, an
instant is admitted only at that basis's own period ends for that filer — measured from the
filer's fiscal year ends and its own discrete-quarter ends, never from a calendar — so an annual
balance-sheet series is annual, not a run of quarterly balance sheets under an annual label. Where a
series genuinely carries both a duration and an instant, grain_mix names what is there
and nothing is dropped: a flow measured over a year and a stock measured at a date are two
different measurements and one line through them is not a trend.
A gap is a point, not an absence — and "returned N of N" counts the periods you should
have had. Where this filer reported a period on this grain and tagged nothing under the
element you asked for, the point comes back with value: null,
provenance_kind: "gap" and a measured reason naming how many facts it did file
at that date under other element names. completeness then states the periods this filer
actually reported inside the span you received against the ones this element resolves for — the
number that says whether the series has holes in it, which periods_held cannot.
JPMorgan's quarterly net interest income is 55 points over eighteen years with no December quarter
anywhere in it, because a US registrant files 10-Qs for three fiscal quarters and reports the fourth
inside the 10-K as an annual duration; the response says so, names the annual basis that holds it,
and states plainly that CompsDesk never subtracts three filed quarters from an annual total to
manufacture the fourth.
It is also how you reach the years outside a pack's window. A pack renders a
window because that is a product design choice; the history behind it is larger, and
history_depth on every response states what we hold for that company and metric, what
this response returned, and which reason the difference is: a fiscal bound you set, a
limit you set, or your plan's per-request cap. Those are three different facts and none
is ever reported as another — row_ceiling_source says your_request or
plan in the payload, and plan_row_cap carries your plan's real ceiling
beside it, so you can tell at a glance whether raising limit would return more. None of
the three is ever reported as a coverage limit.
One column, one unit — and where it genuinely changes, we say so. A foreign
private issuer can file the same period twice in one 20-F, in its functional currency and again as a
convenience translation. Where that happens the point served is the one in the currency that filer
predominantly reports the tag in, chosen the same way here and on /excel/metric, so the
same request returns the same figure every time. Where a filer genuinely changed reporting
currency there is no single answer to give: the response then carries no single unit,
lists what is actually present in units, and adds unit_break naming the
periods each currency covers. Every value is the figure as filed and none is altered — CompsDesk
does not convert the odd periods into the majority currency, because a translated figure must carry
a measured dated rate and inventing one is the thing we will not do.
Asking for a derived ratio at basis=quarterly returns a refusal rather than the
annual figure: the derived layer is built from 10-K/20-F/40-F durations of 330–400 days, so there is
no quarterly grain of a ratio to serve — the annual number is a real figure that answers a
different question. The raw quarterly inputs do exist; ask for the filed tag itself.
curl -H "Authorization: Bearer $CD_TOKEN" \
"https://compsdesk.com/api/v1/excel/series?ticker=AAPL&metric=NetIncomeLoss\
&basis=annual&from_fiscal_year=2012&to_fiscal_year=2025"
In Excel this is CD.SERIES (add-in 2.2 and later):
=CD.SERIES("AAPL","gross_margin_pct","annual") spills a header row, one row per point
with its own citation, and two trailing rows — history_depth, carrying the sentence
above about held versus returned, and quality_ledger, which says checked or
NOT checked and never stays silent. The depth row is always present, including when nothing
was cut: a disclosure that only appears when it is bad news is one a reader learns to stop looking
for. A null year is spilled as a blank with its reason in the note column rather than dropped, so a
gap in your column stays visible as a gap. basis="any" is rejected there — a column
mixing year-to-date and discrete-quarter windows is not a series and the add-in will not assemble
one.
/excel/series accepts translate_to. Translation here is
presentation, never a comparable: a translated figure is not screened on, not ranked
on, and enters no statistic — /screen therefore refuses to translate the column it ranks
by, and would refuse even with a rate on file. Every translated number arrives beside the filed USD
figure, the rate, the rate's own observation date and the series it came from, together with the
statement that the two entities' fiscal periods differ.
Today this is a refusal, and the refusal names why. Rates come from an ingested
series or they do not exist — CompsDesk does not approximate, does not cross through a third
currency, and does not substitute the nearest currency it holds. The serving extract behind this API
carries no rate table yet, so a translate_to request is declined in full with that
reason rather than answered with USD numbers under another currency's label. Ask for the filed USD
figure instead; it is served complete and cited on the same route.
The Excel path is the same refusal. CD.SERIES's optional
currency argument sends translate_to, and when the answer is the complete
no the whole formula returns #N/A carrying that reason. It does not quietly return USD:
a cell keeps the number and discards everything around it, so a USD figure delivered under a EUR
heading would be unfalsifiable by the person holding the workbook.
translate_to acts on figures filed in US dollars, and refuses everything else
by naming the unit it found. Every rate series CompsDesk ingests is quoted against the
dollar, so a translation it can measure starts from a filed USD figure. A percentage or any other
dimensionless column is refused — a margin has no currency, and multiplying it through an exchange
rate produces a quantity the filer never reported. A series filed in another currency is refused —
running a CNY figure through a USD/EUR rate is arithmetic on the wrong operand, and CompsDesk does
not cross through a third currency. A series already filed in the currency you asked for is refused
— there is nothing to translate, and multiplying it anyway would return a different number for the
same filed figure. A series that is not in one unit is refused, with the reporting-currency break
named. This is what makes the promise above structural rather than aspirational: every translated
figure carries its filed USD beside it because only a USD-filed figure is ever translated.
No other route quietly ignores the parameter. translate_to is
answered by /excel/series (which translates, under the rule above) and by
/screen (which refuses on the merits, in the payload). Sent to any other endpoint it is
a 422 naming where translation is served — never an HTTP 200 whose numbers are dollars while the
caller asked for euros.
Everything callable: the operating metrics CompsDesk computes, the family metric sets (bank, REIT, insurer) with their own tables and their own size columns, the disclosure and evidence fields each family carries, the keys CompsDesk refuses to publish, and the disclosed XBRL tags available as raw values. Start here rather than guessing a name.
A metric key is family-scoped. A bank's measures are not a subset of an
operating company's with different numbers in them; they are a different set built from different
filed elements. metrics_bank, metrics_reit and
metrics_insurer each carry their own columns — net interest income, the efficiency
ratio, the Tier 1 and total capital ratios, deposits and the provision for a bank; the payout of
CFO, the D&A basis, the declared-versus-paid dividend guard for a REIT; the cession rate,
reserves and DAC for an insurer — and /metrics lists all of them under
family_metrics with the family-scoped spelling (BANK.net_interest_income)
beside the plain column name.
Call them exactly as you call an operating metric:
/excel/metric?ticker=JPM&metric=net_interest_income returns the figure, the family
table it came from, the element the chain resolved (us-gaap:InterestIncomeExpenseNet)
and the accession of the filing it is in. /excel/source returns the citation — one
filing where the figure is that filed fact, the input filings where it is computed.
/excel/series returns the history. /screen and /query rank the
family, and the family must be declared: ?family=bank&metric=efficiency_ratio_pct.
Asking for a family metric without a family, or asking a bank for an insurer's metric, is a 400 that
names the family it belongs to — never a null, because a null there would read as "this company did
not disclose it", which is a statement about the filing and a false one.
The size band on a family screen is not always called revenue, and the response says
which column it was. metrics_bank has no revenue column: a bank's top line is
net interest income plus noninterest income, published as net_revenue and
reconciliation-gated against the filer's own net-revenue element. screen_basis.size_column
names the exact column min_revenue/max_revenue were applied to on every
response, family or not.
Nulls carry the warehouse's own reason, not a sentence about disclosure.
M&T Bank's FY2025 efficiency ratio is null, and the response says why in the family build's own
words: the ratio is withheld because M&T tags neither us-gaap:Revenues nor
us-gaap:RevenuesNetOfInterestExpense at that period end, so the computed net revenue
cannot be checked against a figure the registrant filed. Every stamp and caveat that qualifies a
family figure — which element resolved, which arm of the chain, the scale classifier's verdict on a
capital ratio, the figure as filed before normalisation — rides on the response in
caveats, because a caveat that does not travel with its figure is not a caveat.
What we refuse, we refuse by name and with the ground measured at request time.
Ask for nim — or net_interest_margin, or NIM — and you get a
200 with value: null and a refusal block naming exactly what is refused and
on what ground: net interest margin is measured over average earning assets, which is not a
tagged fact in any taxonomy we ingest, so substituting total assets would produce a number that
disagrees with the margin the bank prints in its own MD&A. The block names the numerator we
do hold with its element census, and the metric served instead —
nii_to_period_end_assets_pct, which carries its denominator in its own name precisely
because it is not the denominator NIM uses. Every count comes from the extract that answered the
call — measured_by says which artifact measured it — rather than from a literal frozen
into the copy. On a screen a refused key is a 400: a screen ranks, and there is no column to order.
GET /metrics lists every refused key, so the vocabulary and the refusals come from one
place.
Corrected 2026-08-19. This paragraph used to say the CET1 ratio element
"is filed by zero companies in the extract answering you". That was measured over one element
spelling. us-gaap:CommonEquityTierOneCapitalRatio is filed by 233 companies with 2,591
facts in the same extract, and this API was serving it at the time the sentence was written. Only
…CapitalToRiskWeightedAssets is empty, and it is empty because nobody uses that
spelling — not because the ratio is unfiled.
Corrected again 2026-08-22 — the CET1 refusal is withdrawn, and this paragraph is where
it used to be published. The section above was written about CET1 and read: "Ask for
cet1_ratio — or CET1, or the standardised element name — and you get a 200
with value: null and a refusal block naming exactly what is refused: the
statistic. There is no CET1 family column, no median and no screen, so there is no set for a cohort
figure to describe." Every clause of that was true when it was written and the last one is what
made it true: the refusal rested on a fact about an artifact — that no family build had written the
column — rather than on anything about the data. The family build has now written it.
metrics_bank carries cet1_ratio_pct beside the Tier 1 and total
capital ratios, resolved from us-gaap:CommonEquityTierOneCapitalRatio, with
cet1_ratio_as_filed holding the figure before normalisation,
cet1_ratio_scale holding the scale classifier's verdict and
cet1_ratio_na_reason holding the sentence where a value could not be placed. So A6 §2.6
is retired: there is a CET1 family column, there is a median, and
/screen?family=bank&metric=cet1_ratio_pct ranks it — that call was a 400 until this
change and is a 200 now. GET /excel/metric?ticker=ZION&metric=cet1_ratio_pct returns
11.5% for FY2025, cited to accession 0000109380-26-000046, with cet1_ratio_scale: decimal
recording that the filer tagged 0.115. Nothing was reclassified and no ratio was assembled from
components — the ratio is taken as filed or not at all, and a bank that files neither spelling stays
null with its reason rather than being given a computed figure.
Builds a full working-capital workbook for a screened cohort and returns the .xlsx.
This is a real computation over the whole warehouse, so allow up to a couple of minutes.
| Parameter | Required | Notes |
|---|---|---|
anchor | yes | The subject company |
sic | yes | Comma-separated SIC codes defining the cohort |
rev_min / rev_max | yes | Size band, USD |
year_from / year_to | yes | Fiscal year range |
min_n | no | Default 8. Percentiles are suppressed below this cohort size rather than computed on too few companies |
Join on period_end, never on the fiscal-year label.
Two companies can both call a period "FY2025" and mean year-ends eleven months apart. Every response
carries period_end for exactly this reason. A comparison built on the FY label will look
right and be wrong, and nothing in the output will say so.
| Status | Meaning |
|---|---|
401 | No bearer token was sent |
403 | The token is invalid, expired, or the membership behind it is no longer active |
404 | The company is not in coverage |
422 | The company is covered, but discloses none of the inputs this metric needs for that period — the response names which inputs were missing |
400 | An unknown metric or an unparseable period |
429 | The plan's hourly request allowance is used up. The response names the plan and the allowance, and Retry-After gives the seconds until the window reopens |
Two ceilings, because either on its own is porous: a row cap alone is defeated by looping, an hourly cap alone by asking for everything at once.
| Plan | Rows per request | Requests per hour |
|---|---|---|
| Analyst | 1,000 | 600 (~10/min sustained) |
These bind on /query, the bulk surface. When a screen is cut short the response says
so rather than looking like a complete answer: truncated with truncated_by
distinguishing the plan ceiling from a limit you set yourself — so you are told to
raise your own parameter when that is the real constraint, rather than being pointed at an upgrade
you do not need.
A 200 with "value": null is not an error. It is the company not having
disclosed the figure, which is a fact about the filing rather than a fault in the request.
US XBRL filers, refreshed from EDGAR. /health reports the newest filing date
currently being served; if a company filed after that, its latest figures are not in yet. Restated
figures follow the same rule as everything else — the API returns what was filed, and
/excel/source tells you which filing that was.