Data API
MarketStage publishes the measurements behind the dashboard as plain JSON documents, so your own software can read them without a browser and without scraping a page. Derived output only — stages, scores, relative strength, momentum and percentage moves.
/api/ sits behind a gate that answers before the request reaches our server, so an
unauthenticated call is refused rather than served. There is no sign-up page and no button that
mints a credential: you write to us and we issue one. Start at
Getting access.What it serves
The same numbers the dashboard shows, from the same run, computed once and written out as static files. There is no query language, no server-side computation and no per-request database: the pipeline writes the documents when it publishes, and the API hands them over. That is why it is fast, and why it can only answer with what the last run produced.
No prices, by design. Absolute closes, opens, highs, lows, volume and the sparkline series are deliberately absent, and a test in the build fails if one ever appears. Every level is expressed as a derived figure or a percentage. This is a licensing boundary, not an oversight — publishing price values is redistribution, which is not what our data agreements allow us to do.
End of day. Nothing here is intraday or a real-time quote feed, whatever cadence the
dashboard itself runs at. The data_through field in every document tells you the last
session it covers, and it is the field to trust.
Base URL and versioning
Everything lives under https://marketstage.eu/api/v1/. The version is part
of the path, so a breaking change arrives as /api/v2/ and never as a surprise in a
response you already parse. Only v1 exists today; a request to any other version is
refused with an error that says so.
Only GET and HEAD are accepted (plus OPTIONS for CORS
preflight), and only paths ending in .json resolve to anything.
Endpoints
| Endpoint | What it returns |
|---|---|
GET /api/v1/meta.json | Versions, how many instruments are covered, when the data runs through, the horizon and update cadence, the producer, the MAR disclosure and the source attribution. Read this first; it is small and it dates everything else. |
GET /api/v1/universe.json | Every instrument covered — ticker, name, kind, the page and group it belongs to, and the doc path of its own document. No measurements. This is where you look a document up before fetching it. |
GET /api/v1/signals.json | Every instrument with its measurements, in one document. About 1 MB. One request for the whole board is cheaper for both of us than a thousand small ones. |
GET /api/v1/signals/<doc>.json | One instrument. Take <doc> from the doc field in universe.json — do not build it from the ticker. |
# open a session against the base URL BASE=https://marketstage.eu/api/v1 # what is covered, and the disclosure that comes with it curl -s "$BASE/meta.json" # what is covered: tickers, names, kinds and doc paths, without any measurement curl -s "$BASE/universe.json" # every instrument with its measurements, in one document (about 1 MB) curl -s "$BASE/signals.json" # one instrument curl -s "$BASE/signals/MSFT.json"
Never build a document path from a ticker
Most tickers are their own filename, so signals/MSFT.json works and tempts you into
a rule. It is not a rule. Tickers that cannot be a filename as they stand — ^GSPC,
GC=F — are rewritten with a short hash suffix that you cannot derive from
anything you know. Every entry in universe.json, and every instrument document, carries a
doc field with the exact path. Read it.
# right: ask universe.json where the document lives curl -s "$BASE/universe.json" \ | jq -r '.instruments[] | select(.ticker=="^GSPC") | .doc' # -> signals/_GSPC-<hash>.json # wrong: guessing the filename from the ticker curl -s "$BASE/signals/^GSPC.json" # 404, and always will be
Authentication
Two gates exist for this API and they present different credentials, so the first question to ask support is which one you have been issued. What follows describes both honestly, because a page that documents only the one we would like to be running would be no better than no page at all.
Cloudflare Access service token — what is in front of the API today
Access checks a service token before the request reaches us, and it does so without ever redirecting to a login page, which is exactly why a script can use it. The credential is a pair, and both halves go in headers on every request:
export CF_ACCESS_CLIENT_ID=... # from the credential you were sent
export CF_ACCESS_CLIENT_SECRET=... # keep both out of your repository
curl -s -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
"$BASE/signals/MSFT.json"
Under this gate every path is closed, meta.json and universe.json
included, and a request without the pair is refused by Cloudflare with a 403 that is
its own page, not the JSON error body described below. No per-key quota is counted. The secret is
shown to us once when it is created and to you once when we send it; it belongs in the environment
of whatever calls the API and never in a repository, a browser or a shared folder.
An API key — the key-and-quota gate, not yet in service
A key-based gate is written and reviewed but not deployed, and it is what a paid key will use when it is. It is documented here because its contract — the public endpoints, the error bodies and the quota headers in the rest of this page — is what you will be coding against, not the one you meet today. Ask us which gate is answering before you rely on either.
The key goes in one of two headers, whichever suits your client:
export MS_API_KEY=... # the key we sent you; never paste it into a command line # either header works; send one, not both curl -s -H "Authorization: Bearer $MS_API_KEY" "$BASE/signals.json" curl -s -H "X-API-Key: $MS_API_KEY" "$BASE/signals.json"
Under this gate meta.json and universe.json are open without a key
— you must be able to see what is covered, and what the disclosure says, before deciding
whether to pay for the measurements. Everything else needs one. Keys are stored only as a hash, so
nobody, us included, can read a working credential out of the store; if you lose one it is
replaced, not recovered.
Errors
Under the key gate every refusal is a JSON document with the same two members, so one parser
handles all of them. The HTTP status carries the category and error.code carries the
reason; error.message is written for a human reading a log and may be reworded, so
branch on the code and never on the text.
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"error": {
"code": "missing_key",
"message": "Send your key as 'Authorization: Bearer <key>' or 'X-API-Key: <key>'."
},
"api_version": 1
}
| Status | code | What happened |
|---|---|---|
| 401 | missing_key | No key in either header. |
| 403 | invalid_key | The key is not recognised. A revoked key looks the same as one that never existed. |
| 403 | key_disabled | The key exists but has been switched off. |
| 404 | unknown_version | The path is under /api/ but not under /api/v1/. Only v1 exists. |
| 404 | not_found | No document at that path, or the path is not a .json document. |
| 405 | method_not_allowed | Only GET, HEAD and OPTIONS are accepted. |
| 429 | quota_exceeded | The daily quota for your key is spent. See Quotas. |
| 500 | misconfigured | Our fault, not yours. Tell us. |
| 502 | origin_error | The gate is up but the data origin is not answering. Retry with a backoff. |
Error responses are sent with Cache-Control: no-store, so nothing between us caches
a refusal and keeps serving it after the cause is fixed.
Quotas and what to do with a 429
A key carries a daily allowance, counted per key per day and reset at 00:00 UTC. Every authenticated response carries where you stand:
X-RateLimit-Limit: 5000 # your daily allowance X-RateLimit-Remaining: 4873 # what is left, best-effort (see below) X-RateLimit-Reset: 34190 # SECONDS until 00:00 UTC, not a timestamp X-Plan: pro # the plan recorded against your key
X-RateLimit-Reset is a number of seconds until the reset, not a clock time
and not a Unix timestamp. When the allowance is spent the answer is 429 with
code: "quota_exceeded", the same quota headers, and a Retry-After in
seconds. The correct response is to stop until it elapses. Retrying immediately spends nothing and
achieves nothing — the counter only moves at midnight UTC.
X-RateLimit-Remaining can be a little
optimistic. It is a good guide and a bad invariant: treat the 429 as the authority. In
the other direction, if the counter cannot be read at all the request is allowed through rather
than refused — we would rather miscount than lock a paying caller out of their own data.The cheapest way to stay well inside any quota is to stop asking for things you already have.
One signals.json is one request for the entire board; a thousand per-instrument
documents are a thousand. And nothing changes between publications, so polling faster than we
publish returns the same bytes at the cost of your allowance.
Caching
Successful responses carry Cache-Control: public, max-age=300 and are cached at the
edge for the same five minutes. The documents themselves change only when the pipeline republishes
— once a day after the US close for the free set, and every 30 minutes between 08:00 and
22:00 Lisbon time on weekdays for Pro. Compare generated and data_through
in meta.json against what you already hold and skip the download when they match;
that is the whole of a sensible polling strategy. The status page shows
whether the last run was healthy, which usually answers "why has this not moved" before you have to
ask us.
CORS
The key gate answers preflight requests and sends
Access-Control-Allow-Origin: * with GET, OPTIONS and the
Authorization, X-API-Key and Content-Type headers allowed;
preflight results may be cached for a day. Responses also carry
X-Content-Type-Options: nosniff.
That the headers permit a browser call does not make one a good idea. Any credential you put in front-end JavaScript is a credential you have published, and it is your quota and your account that pay for it. Call the API from your server.
Document shapes
meta.json
Small, and the only document that dates the rest. disclosure_complete is worth
checking in code: when it is false the producer identity behind the numbers is
incomplete and disclosure_missing names what is absent.
{
"api_version": 1,
"app_version": "1.2.0",
"generated": "2026-08-18T14:36:10",
"data_through": "2026-08-18",
"instruments": 1028,
"kinds": ["commodity", "equity", "etf", "eu_sector", "fund",
"industry", "macro", "sector", "stock"],
"horizon": "26 weeks (medium term). ...",
"update_frequency": "Recalculated on every run, on end-of-day data. ...",
"producer": "...",
"disclosure_complete": true,
"disclosure_missing": [],
"disclaimer": "These are investment recommendations within the meaning of ...",
"attribution": ["Euro foreign exchange reference rates: European Central Bank ...", "..."],
"notes": ["Derived signals only. ...", "End-of-day. ..."]
}
universe.json
{"api_version", "data_through", "instruments"}, where each instrument carries
id, doc, ticker, name, kind,
page and group — and nothing else. It is coverage, not measurement,
which is what makes it safe to leave open.
signals/<doc>.json and signals.json
One instrument under an instrument key, or every instrument in an
instruments array. The instrument object is identical in both, so the same parser
reads either.
{
"api_version": 1,
"data_through": "2026-08-18",
"instrument": {
"id": "stk:IGV:MSFT",
"doc": "signals/MSFT.json",
"ticker": "MSFT",
"name": "Microsoft",
"kind": "stock",
"page": "stocks",
"group": null,
"industry": "Software",
"stage": {
"d": {"n": 2, "sub": "A", "since": "2026-08-06", "bars": 8},
"w": {"n": 4, "sub": "B", "since": "2026-02-06", "bars": 29},
"m": {"n": 2, "sub": "B", "since": "2023-04-30", "bars": 41}
},
"score": 31,
"rs_rating": 37,
"mansfield": -3.411132444244902,
"mansfield_rising": true,
"minervini": 5,
"faber": true,
"momentum_12_1": -22.42218995598129,
"short_term": "wait",
"golden_cross": false,
"leader": false,
"tags": [],
"pos_52w_pct": 67.49503098011259,
"chg_1d_pct": -3.4608375504450173,
"chg_1m_pct": 21.439996949947048,
"chg_1y_pct": -7.733279701552331,
"stage_ribbon": "2222222222222222222222222222334411444444...",
"asof": "2026-08-18",
"stale": false
}
}
Instrument fields
id- Our internal identity for the row. Opaque, and scoped to the page that ranked it — match on
ticker, never on this. doc- Where this instrument's own document lives, relative to
/api/v1/. ticker,name,kind- The identity you should key on.
kindis one of the values listed inmeta.json → kinds. page,group,industry- Where the instrument sits on the dashboard. Any of them may be
null; they are presentation, not classification you should depend on. stage- The classification, per timeframe:
ddaily,wweekly (the primary read),mmonthly. Each is{"n", "sub", "since", "bars"}ornullwhen there is not enough history.nis 1 accumulation, 2 advance, 3 distribution, 4 decline — see how to read the stages.subis"A"for a young stage and"B"for a mature one,sinceis the date it was entered andbarshow many bars of that timeframe it has lasted. score- The weighted composite, 0–100, higher being stronger. Comparable between instruments on the same run; not comparable across runs as an absolute level.
rs_rating- Percentile relative strength, 1–99, against the rest of the stock universe.
nullfor anything that is not a universe stock with a year of history. mansfield,mansfield_rising- Mansfield relative strength against the benchmark, and whether it is turning up.
minervini- How many of the eight trend-template conditions are met, 0–8.
faber,golden_cross,leader- Booleans: above the 10-month average, 50-day above 200-day, and whether the instrument is flagged as a leader in its group.
momentum_12_1- Twelve-month momentum excluding the most recent month, as a percentage.
short_term"go","wait"or"no"— a state, not an instruction."wait"means the trend is not against you but there is no clean entry.pos_52w_pct,chg_1d_pct,chg_1m_pct,chg_1y_pct- Position within the 52-week range and the moves over a day, a month and a year, all as percentages. Percentages, never levels — see no prices.
stage_ribbon- Up to 260 characters, one per weekly bar, oldest first: the digit is the stage that week and
"."is a week with no classification. tags- Free-form labels attached by the model. Informational.
asof,stale- The date this instrument's own data runs through, and whether that is behind the rest of the run. A
truehere is the single most useful field on the document: it says this row is older than it looks.
A field may be null whenever the model could not compute it — too little
history, no benchmark, an instrument the measure does not apply to. Treat null as
"not known", never as zero, and expect new fields to appear within v1: additions are
not breaking changes, so parse leniently and ignore what you do not recognise.
Getting access
There is no self-service page, and that is deliberate rather than unfinished: credentials are issued one at a time, by hand, against a name we can contact. Write to [email protected] from the address on your subscription and tell us what you intend to build, roughly how many requests a day it needs, and whether it runs on a server or on someone's machine. We answer within 2 business days.
You will get back the credential itself, which is shown once and is not recoverable afterwards, and the allowance recorded against it. To rotate one, ask for a replacement and switch over before we retire the old one; to revoke one, tell us and it stops working immediately. If you think a credential has leaked, say so first and apologise later — disabling one takes a minute.
API access is part of the Pro plan; see pricing for what that includes. We are not publishing a separate API price list, because there is not an honest one to publish yet.
Terms of use
This API is the supported way to read MarketStage with software. Scraping the HTML instead is a breach of our Terms and Conditions, which also cover what you may do with what you read: use it in your own tools freely, but do not resell or redistribute the output, and do not build a competing product out of it. Credentials are per customer, not per team-that-shares-one.
Attribution travels with the data. meta.json carries an attribution
array identifying the official institutions and the applicable market-data source class behind
the underlying series; if you show these numbers to anyone else, carry it with them.
meta.json carries the full disclaimer in the response itself, which is where
it belongs.