One read-only JSON API over the data behind this site: Cardano governance actions and DReps, NFT collection floors, and token markets decoded from the chain. Ten endpoints under https://ourolens.com/api/v1, no writes, and no key needed to try it.
It is a deliberately small surface with written-down terms: published limits from the first day, 180 days' notice before any of them tightens, and nothing that launched free getting metered later. Governance and NFT floors lead the list because no other Cardano market API exposes either.
Try it now
This works as written, from any terminal, with no key and no account. A key raises your daily allowance; it does not unlock anything.
Create a key in your account settings — free, up to 10 at a time. The machine-readable version of this page is at /v1/openapi.json.
Published limits
Caller
Requests per UTC day
Worst case actually reachable
With a free key (Authorization: Bearer olens_…)
5,000
15,000
With no key at all
100
300
A key is free and takes one click. The anonymous allowance is enough to read this page and try a few calls from a terminal, and not enough to build on — that is what it is for, and what lets an abusive client be told from a popular one.
RateLimit-Remaining is what is left after the response you are reading, and RateLimit-Reset is the unix second of the next UTC midnight: that is the only reset there is, and there is no rolling window. A 429 adds Retry-After. Successful responses are cacheable, and one served from a shared cache carries the headers of whoever filled that entry, so read the headers as a signal and treat 429 as the authoritative answer — 429s themselves are never cached.
The limits are approximate, and this is the worst case
Requests are counted in memory and flushed every 30 seconds, not written to the database one by one: one write per request would cost more than the rest of this infrastructure combined. The consequence is that the limit is enforced per server instance, and the API runs at most 3 of them.
What that means in figures
A burst deliberately spread across instances can therefore reach 15,000 requests against a stated 5,000 before it is stopped (3 × 5,000), and 300 against a stated 100. Ordinary traffic never approaches it — an instance reads the flushed total on its first request of the day, so the counters agree within one flush interval — but the number is published because it is real.
The third column of the table above is that multiplier applied, not a separate promise. It is published rather than left out because the number is real: raising the instance count would loosen every limit on this page, which is why it is pinned in the contract file with that sentence beside it. This is the honest version of a rate limit that has not been paid for in one database write per request — a precision we would rather not claim than fake.
180 days' notice, and nothing that launched free gets metered
180 days' notice before a breaking change to any endpoint on this page, and before any limit on one gets tighter. Six months is chosen to be longer than a hobby project is expected to honour, because the whole reason to build on this rather than on a DEX's free API is that it is the one that cannot be switched off underneath you. A number we would quietly break is worse than no number.
Everything on this page stays free. Not “free during the beta”, not “free for now”: if it is documented here today, it will not go behind a payment later. A new endpoint that is paid from the day it appears is fair game; moving an existing one behind a price is not.
What counts as breaking, so the promise is checkable
Breaking — 180 days' notice
Not breaking — may happen any time
Removing an endpoint, or a field marked always
Adding an endpoint, or a field
Renaming a field, or changing its type
Adding a value to an existing enum (status, market, dex, source)
Making an always field nullable or optional
Making a nullable field non-null, or an optional one always present
Lowering a limit, or shrinking limit's maximum of 100
Changing a Cache-Control max-age, or the ranking within a list
Changing what a null means
Adding an error code for a case that used to be a different code
Calling it
·Auth — Authorization: Bearer olens_…, and no header at all is also valid, at the anonymous rate. A header that is present and is not a bearer token is a 401 invalid_authorization rather than a quiet fall-back: a client that sent Basic … has made a mistake, and serving it the anonymous allowance would let it discover that mistake as a mysterious 429 a hundred calls later.
·Methods — GET and HEAD. Anything else is a 405 with Allow: GET, HEAD, OPTIONS. The method is checked after the rate limit, so a flood of POSTs is metered like anything else.
·CORS — Open for GET, HEAD and OPTIONS from any origin, with the rate-limit headers listed in Access-Control-Expose-Headers — without that a browser could read the body but not the headers promised above.
·Pagination — Cursor-based: ?limit= (1–100, default 25) and ?cursor=. Every list answers { "data": [...], "nextCursor": "…" }, and nextCursor is null on the last page. A cursor is opaque: pass back what you were given, unchanged. A page may legitimately come back shorter than limit, so page until nextCursor is null rather than until a page is short.
·Nothing silently accepted — ?limit=1000 is a 400, not a clamp to 100; an unknown parameter is a 400, not the default page. A request that cannot be answered as asked is refused, because the alternative is a 200 that looks like an answer to a different question.
Successful responses are public, max-age=N, with N set from the schedule that actually rewrites the document being served; errors are no-store, and Vary: Authorization keeps a keyed caller out of another caller's cache entry.
/v1/tokens, /v1/tokens/{unit}
20 s
rewritten inside the block that produced the trade — about one block
the governance sync runs hourly; polling faster gains nothing
/v1/status
60 s
a tenth of the health check's interval — a status endpoint has to stay responsive
/v1/openapi.json
3600 s
fixed at deploy, and not immutable: a day-long cache would hide a new endpoint
Absent is absent
A field we have not recorded is omitted from the JSON — not 0, not "", not null. A field that can legitimately have no value is null, and every null has a stated meaning. Nothing is ever filled in with a plausible value, so a 0 you receive is a reading rather than a gap: change24h: 0 means the price is flat, and floorAda: null means there is no priced listing rather than a floor of zero.
This is the rule that makes every field predictable, and it is a promise like the three above. A field marked always in the OpenAPI document is guaranteed: removing it, or making it nullable or optional, is a breaking change with 180 days' notice.
Endpoints
Ten of them, all GET, all under https://ourolens.com/api/v1. Every example below is a real call; the responses are trimmed to the fields worth seeing.
GET /v1/governance/proposals
cached 900 s
Governance actions, newest first by the block time of the submitting transaction.
tally: null means no voting summary has been fetched for that action yet — it is not a tally of zeroes. yesPct: null inside one means that lane does not vote on this action type.
metadataVerified has four states and they are not two: true matched the anchor hash, false did not match, null was not checked, and absent means the document predates the check.
DReps that set CIP-119 doNotList, and DReps outside the top set we sync, are left out of this list and still resolve by id. A page may come back shorter than limit, so page until nextCursor is null rather than until a page is short.
GET /v1/governance/dreps/{id}
cached 900 s
One DRep, with profile, power history and votes — including one that set doNotList.
rank: null means outside the top set we sync — not ranked last. An absent votes means the vote list has never been synced, which is not an empty list; an empty list would read as “this DRep has never voted”.
GET /v1/nft/collections
cached 300 s
NFT collections ranked by 24-hour ADA volume, highest first — what is trading, not what is cheapest.
floorAda: null means there is no priced listing. It is not a floor of zero, and it can sit beside a real volume30dAda — the collection traded, and nothing readable is listed right now. listedUnpriced absent is not the same as none.
A policy id that is not 56 hex characters is a 400 invalid_policy_id, not a 404: “no such collection” is a different fact and would send you looking for the collection instead of at your own string.
GET /v1/tokens
cached 20 s
ADA-paired tokens above the liquidity threshold, ranked by ADA liquidity, highest first.
An absent change24h means no 24-hour figure has been written for that token, and an absent volume24hAda is not zero volume. A 0 you do receive is a reading: change24h: 0 is a flat price.
GET /v1/tokens/{unit}
cached 20 s
One token, by unit — the 56-character policy id followed by the hex asset name.
supply absent or null means the supply is unknown, so mcapAda is absent or null too — never 0. holders.fetchedAt is part of the figure, not decoration: it is refreshed at most once a day.
GET /v1/status
cached 60 s
Every data pipeline, how long since it last wrote, and the limit it is judged against — the same rows as the status page.
ageSec: null means that pipeline has never written — not that it wrote a moment ago. If the health check has never published at all this is a 503 unavailable, not an all-green ok: true: we do not know that everything is fine, we know we have nothing to show.
GET /v1/openapi.json
cached 3600 s
This reference, machine-readable. Generated from the same object the router serves, so there is no second copy to keep in step.
One shape, at every status, always JSON — including 404s and 500s. Nothing on this API returns HTML: a client that has to guess whether a 404 is JSON or HTML writes a parser instead of a feature.
{
"error": {
"code": "invalid_limit",
"message": "limit must be between 1 and 100; got 1000.",
"docs": "https://ourolens.com/docs/api"
}
}
code is stable, lower-case and underscored — it is the thing to branch on. message is for a human and may be reworded without notice. docs is this page.
Status
code
When
400
invalid_limit
limit is not a whole number between 1 and 100. Not clamped: a client that asked for a thousand, got a hundred and saw no nextCursor would believe it had the whole list.
400
invalid_status
status is not one of the five proposal statuses. The accepted values are in the message.
400
invalid_cursor
A cursor that is malformed, or whose document has since been deleted. Not a silent restart at page one.
400
unknown_parameter
A query parameter this endpoint does not accept — ?limt=10, ?sort=mcap. A typo that returned the default page would be the same class of bug as a silent zero.
400
invalid_policy_id
A policy id that is not 56 hex characters.
400
invalid_unit
A unit that is not a 56-character policy id followed by a hex asset name.
401
invalid_authorization
An Authorization header that is present and is not a bearer token. Sending no header at all is valid and is served at the anonymous rate; falling back quietly would let a mistake surface as a mysterious 429 a hundred calls later.
401
invalid_key
A bearer token we did not issue. A key looks like olens_….
401
revoked_key
A key that was revoked. Create another in your account settings; the old one never comes back.
404
not_found
No such endpoint, or no such document. One trailing slash is forgiven; an empty path segment (/v1//tokens) is not.
405
method_not_allowed
Anything but GET, HEAD or OPTIONS, with Allow on the response. The method is checked after the rate limit, so a flood of POSTs is metered like anything else.
429
rate_limited
The daily limit for this key — or for an unkeyed caller — is used up. Retry-After is the seconds until the next UTC midnight, which is the only reset there is. This is the authoritative answer about your quota; the headers are a signal.
500
internal
Our fault, and not otherwise described. Quote the time if it persists.
503
quota_unavailable
We could not establish what you had used, so the request was declined rather than served on a guess. Not a rate limit: nothing is exhausted, and Retry-After is seconds rather than hours. Retry shortly rather than backing off for the day.
503
unavailable
A request could not be authorised at all just now, or the endpoint's underlying document has never been written (/v1/status).
429 and 503 are different facts
When the usage counter cannot be read, the request is refused rather than served on a guess — and it is refused as 503 quota_unavailable, never as 429. “You have used your allowance” when the real answer is “we could not establish what you have used” is a plausible-looking wrong answer, and a client that believes it is over quota stops for the day instead of retrying in a minute.
Keys
Keys live in your Ourolens account, beside the rest of your sign-in settings: Portfolio → Settings → API keys. Create one with a label, up to 10 live at a time, and revoke any of them at any time.
A key is stored only as its SHA-256, so a leaked database dump cannot be replayed and nobody — including us — can read a key back to you. That is why the plaintext is shown once, when it is created, and why there is no screen anywhere that can show it again. If you lose one, revoke it and make another.
Revoking takes effect on the next request that uses the key, which then gets 401 revoked_key. The revoked key's usage history is kept, because it is the evidence for why it was revoked.
Found a wrong figure, a field this page does not explain, or an endpoint you need? GitHub issues or @ourolens. The same reference, machine-readable, is at /v1/openapi.json.