Free, public, read-only risk data for Stellar/Soroban DeFi lending protocols.
Two GET endpoints, no authentication, no API key, CORS open to any origin. If you are building a
wallet, an aggregator, or a dashboard and you want a live safety number for a protocol your users
are about to interact with, this is the whole surface area.
Everything below was captured from the live production API, not written from the type definitions. The example responses are verbatim bodies from a snapshot taken at 2026-08-19T13:20Z; the numbers move every ~5 minutes, the shapes do not.
https://stenion.vercel.app/api/v1
There is no separate API host and no sandbox. The production API is the only API, and it serves the same data the registry renders — the site's own pages and these routes read the same store, so what you get and what we show cannot drift apart.
Every public path carries a version segment. There are no unversioned paths — /api/protocols
and /api/protocol/:id existed briefly during the move to /v1 and now 404.
The policy, stated plainly because "will you break my integration" is the only versioning question that actually matters:
- Additive changes stay on
v1. A new field in a response — a sixth*Safetyfactor, another piece of protocol metadata, a new component inside a factor — ships onv1. It cannot break a client that ignores fields it does not recognise, so parse defensively and tolerate unknown fields. That is the one thing we ask of you in return. - Breaking changes get a
v2. Renaming a field, removing one, changing a type, changing what an existing value means, or restructuring the envelope — all of it goes to a new version path.v1keeps serving its existing contract until it is deliberately retired, which would be announced, not silent.
A methodology change is not an API change. If we change a formula, a threshold, or a weight,
safetyScore is still a 0–100 number meaning the same thing, so the contract holds and the version
does not move. What moves is methodologyVersion in the response body — see
The score. A change to the factor taxonomy, though — renaming or removing one of the
five factors — is breaking, and would be a v2.
The public registry data is free, and stays free. The score, the factor breakdown, the history,
and these endpoints are public and unmetered beyond the rate limit below. Stenion's paid tiers add
capability — private tooling, faster refresh, visibility placement — and they never gate access to
anything that is already public, and never change a score. The ranked registry is sorted purely on
safetyScore with no paid exceptions. This is a project rule enforced in code and review, not a
launch promise.
Nothing here is an endorsement. A safetyScore is analysis of on-chain state, not a
recommendation, a rating, an audit, or financial advice. Protocol names, logos, links, and contract
ids appear as the subject's own properties. Displaying a protocol does not imply endorsement,
partnership, or any relationship between Stenion and that protocol — in either direction — and
integrating this API does not create one either.
# every protocol, ranked
curl https://stenion.vercel.app/api/v1/protocols
# one protocol, with factors and run history
curl https://stenion.vercel.app/api/v1/protocol/blendThe leaderboard: every protocol Stenion tracks, with its latest score. Ranked by safetyScore
descending, with never-scored protocols last.
Request
curl https://stenion.vercel.app/api/v1/protocolsResponse 200 OK
{
"protocols": [
{
"id": "blend",
"name": "Blend",
"chain": "stellar",
"logo": "/assets/protocols/blend.svg",
"safetyScore": 53,
"computedAt": "2026-08-19T13:20:06.991Z",
"lastRunAt": "2026-08-19T13:20:04.336Z",
"lastRunStatus": "ok"
},
{
"id": "kinetic",
"name": "Kinetic",
"chain": "stellar",
"logo": "/assets/protocols/kinetic.png",
"safetyScore": 27,
"computedAt": "2026-08-19T13:20:12.614Z",
"lastRunAt": "2026-08-19T13:20:07.182Z",
"lastRunStatus": "ok"
}
]
}| Field | Type | Notes |
|---|---|---|
id |
string | Stable identifier, case-sensitive, used as the path segment on the detail endpoint. |
name |
string | Display name. |
chain |
string | Currently always "stellar". |
logo |
string or null | Root-relative path to a mark Stenion hosts — prefix with the base host. null is a normal state, not a broken image. |
safetyScore |
number or null | 0–100, higher = safer. From the latest ok run. null means never successfully scored — not "zero", not "unsafe". |
computedAt |
string or null | ISO 8601 UTC. When that score was computed. null if and only if safetyScore is null. |
lastRunAt |
string or null | ISO 8601 UTC. The most recent run of any status. See Staleness. |
lastRunStatus |
"ok", "failed", null |
Status of that most recent run. null means the protocol has never been run at all. |
The board deliberately carries no contractId, site, or docs — those are verification detail
nobody acts on from a list, and repeating them on every row of every fetch is waste. They live on
the detail response.
One protocol: metadata, the current score, the full factor breakdown, and recent run history.
Request
curl https://stenion.vercel.app/api/v1/protocol/blendResponse 200 OK
historyis truncated to 3 entries below for readability. The live response returns up to 50 rows, newest first. Everything else is verbatim.
{
"id": "blend",
"name": "Blend",
"chain": "stellar",
"adapter": "BlendAdapter",
"logo": "/assets/protocols/blend.svg",
"contractId": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD",
"site": "https://www.blend.capital",
"docs": "https://docs.blend.capital",
"safetyScore": 53,
"computedAt": "2026-08-19T13:20:06.991Z",
"factors": {
"oracleSafety": {
"value": 99,
"detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); all reserves have a deviation bound",
"weight": 0.25,
"components": [
{
"id": "priceFreshness",
"label": "Price freshness",
"value": 99,
"detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); anchored to the aggregator's own resolution and max_age (900s)"
},
{
"id": "deviationBound",
"label": "Deviation bound",
"value": 100,
"detail": "all 3 reserves score the same — CAS3J7… bounded at 60% per 300s step; CCW67T… bounded at 20% per 300s step; CDTKPW… bounded at 20% per 300s step"
},
{
"id": "priceAges",
"label": "Price age by feed (not scored)",
"value": null,
"detail": "Other:XLM 306s, Other:USDC 306s, Other:EURC 306s — all 3 within the protocol's own 900s staleness limit. Reported, not graded: priceFreshness already scores the worst of these."
},
{
"id": "deviationTightness",
"label": "Bound tightness (not scored)",
"value": null,
"detail": "per-reserve max_dev: CAS3J7… 60%, CCW67T… 20%, CDTKPW… 20%. Measured against the previous upstream record, so this bounds movement per publish interval. Reported, not graded — see METHODOLOGY.md §2."
}
]
},
"adminKeySafety": {
"value": 40,
"detail": "single-key admin (1 signer(s), high-threshold 0), 0 op(s) in 30d",
"weight": 0.2
},
"liquiditySafety": {
"value": 23,
"detail": "worst reserve (CCW67T…) has 23% of supply as free liquidity",
"weight": 0.15
},
"collateralSafety": {
"value": 71,
"detail": "top reserve holds 64% of supplied value across 3 reserves (HHI 0.53)",
"weight": 0.2
},
"utilizationSafety": {
"value": 14,
"detail": "worst reserve (CCW67T…) at 77% util vs 90% cap",
"weight": 0.2
}
},
"methodologyVersion": 1,
"lastRunAt": "2026-08-19T13:20:04.336Z",
"lastRunStatus": "ok",
"history": [
{
"status": "ok",
"safetyScore": 53,
"methodologyVersion": 1,
"computedAt": "2026-08-19T13:20:06.991Z",
"runAt": "2026-08-19T13:20:04.336Z"
},
{
"status": "ok",
"safetyScore": 53,
"methodologyVersion": 1,
"computedAt": "2026-08-19T13:15:07.978Z",
"runAt": "2026-08-19T13:15:05.118Z"
},
{
"status": "ok",
"safetyScore": 53,
"methodologyVersion": 1,
"computedAt": "2026-08-19T13:10:06.937Z",
"runAt": "2026-08-19T13:10:04.295Z"
}
]
}| Field | Type | Notes |
|---|---|---|
id, name, chain, logo |
Same as the leaderboard. | |
adapter |
string | Which Stenion adapter produced the score. Informational. |
contractId |
string or null | The Soroban contract the score was derived from. A raw C… address, deliberately not an explorer URL — pick your own. |
site, docs |
string or null | The protocol's own links. Listed as its properties, not as a recommendation. |
safetyScore, computedAt |
Latest ok run. Both null if never successfully scored. |
|
factors |
object or null | The five-factor breakdown, or null if never scored. See below. |
methodologyVersion |
number or null | Which rulebook version the current score was computed under. |
lastRunAt, lastRunStatus |
Newest run of any status. See Staleness. | |
history |
array | Up to 50 recent runs, newest first. A discriminated union — see below. |
Five keys, always all five present, defined once as a shared taxonomy so they mean the same thing
for every protocol: collateralSafety, oracleSafety, adminKeySafety, liquiditySafety,
utilizationSafety.
Each is either a factor object or null (the factor genuinely does not apply to that protocol —
render "N/A", do not treat it as zero).
| Field | Type | Notes |
|---|---|---|
value |
number | 0–100, higher = safer — the same direction as the overall score. |
weight |
number | This factor's share of the overall score. Weights of all non-null factors sum to 1. |
detail |
string | Human-readable, includes the raw on-chain figure it came from. Safe to surface directly. |
components |
array, optional | Sub-signals behind value. Absent on a factor computed from a single signal. |
A component with a non-null value is a scored sub-signal that fed the parent. A component with
value: null is a disclosure — a real on-chain quantity published deliberately ungraded,
because scoring it would invent comparability the underlying data does not support. Its detail
carries the figure. Treat components as additive: it may gain entries on v1.
Every factor name ends in *Safety, and every one is 0–100 higher-is-safer. There is no factor
anywhere in this API where a bigger number is worse.
This is the single most likely thing to get wrong, so it gets its own section.
history[] entries are not a uniform shape with nullable fields. They are a union discriminated
on status:
- an
okrow carriessafetyScore,methodologyVersion,computedAt, andrunAt. - a
failedrow carrieserrorandrunAt, and does not have asafetyScorekey at all — notnull, not0. The key is absent.
That absence is deliberate. A failed run is a gap in our data, never a score of zero, and giving
it a safetyScore: 0 would let a pipeline outage render as a protocol suddenly becoming maximally
dangerous.
type HistoryEntry =
| {
status: 'ok';
safetyScore: number;
methodologyVersion: number;
computedAt: string;
runAt: string;
}
| {
status: 'failed';
error: string;
runAt: string;
};A failed row looks like this. This example is constructed from the schema, not captured live —
no production run has ever failed, so there is no real one to show you. The shape is enforced by a
database CHECK constraint and pinned by tests, but we are not going to present a fabricated body
as a live capture:
{
"status": "failed",
"error": "Blend: simulation of lastprice failed",
"runAt": "2026-08-19T13:20:04.336Z"
}Do this — branch on status and let a failure be a gap:
for (const entry of detail.history) {
if (entry.status === 'ok') {
plot(entry.runAt, entry.safetyScore);
} else {
markGap(entry.runAt, entry.error);
}
}Not this — it silently plots a zero for every failed run, drawing a cliff that never happened:
// WRONG
for (const entry of detail.history) {
plot(entry.runAt, entry.safetyScore ?? 0);
}error is our own message, and it is meant to be readable. It describes our failure to read the
chain — an RPC timeout, a decode error — and says nothing about the protocol's safety. Do not
surface it as a risk signal.
Stenion re-scores every ~5 minutes. Runs can fail. The API is built to be honest about that rather than to paper over it, which means you get two independent pieces of information:
safetyScore/computedAt— the last score we computed successfully.lastRunAt/lastRunStatus— the most recent run attempt, of any outcome.
When lastRunStatus is "ok", these agree and there is nothing to think about.
When lastRunStatus is "failed", the score you are holding is still real, but our data is older
than it looks. It is the last one we successfully computed — at computedAt — and we have since
tried and failed to refresh it. The gap between computedAt and now is how stale the number
actually is, and lastRunAt tells you we were still trying.
const stale = detail.lastRunStatus === 'failed';
const scoreAgeMs = detail.computedAt ? Date.now() - Date.parse(detail.computedAt) : null;We think an integrator should surface that to their own users rather than absorb it silently — a safety number that quietly stopped updating is worse than one labelled as stale, because a user acts on it either way. Our own registry does this: a failed run gets a pill, a caption, and both timestamps on the protocol page.
One deliberate detail worth copying: never colour a staleness marker with the score bands. Green/amber/red mean risk level here. Painting a pipeline fault amber reports our outage as a verdict on the protocol.
safetyScore: null together with lastRunStatus: "failed" means we have never had a good score
for that protocol. Render it as unknown. It is not a zero.
safetyScore is 0 to 100, higher is safer. It is a weighted mean of the five factors, each of
which is also 0–100 higher-is-safer.
How each factor is computed — every formula, threshold, and weight — is in the Methodology, which is the public, challengeable rulebook and the source of truth. It is deliberately not restated here, so that this page cannot drift from it.
methodologyVersion is stamped onto every score at the moment it is computed, and history rows
carry their own. Scores computed under different methodology versions are not comparable. If you
chart history, treat a change in methodologyVersion between adjacent points as a discontinuity in
the rules, not a real move in risk. History is never backfilled — we label the break rather than
hide it. The current version is 1.
Both /v1 read routes are served through a CDN, with a TTL computed per response from the data in
the body rather than a fixed constant. The reason is directly relevant to you: a fixed TTL would
serve a body claiming "the last run succeeded at T" for some seconds after a later run had already
failed — the cache would be lying in exactly the field that exists to stop us lying about freshness.
The guarantee: a cached response can hide a newer indexer run by at most 10 seconds.
What you will actually observe on a 200:
Cache-Control: public, max-age=0
Age: 3
X-Vercel-Cache: HITThe s-maxage directive that drives the TTL is consumed by the CDN and does not reach you, so do
not look for it. Age is how long the copy you received has been sitting in the cache — subtract it
from Date if you want the true age of the response. max-age=0 is intentional: private browser
caches are deliberately kept out, so a copy's real age never exceeds what Age reports. There is no
stale-while-revalidate, also intentional — it works by serving a body past its deadline, which is
the exact masking described above.
Errors, 404s, and 429s are no-store.
Polling advice: the data changes every ~5 minutes, so polling faster than that buys you nothing
but cache hits. Once a minute is generous. Note that the cache key includes the query string, so
adding ?t=<random> to defeat the cache does not get you fresher data — it just guarantees a cache
miss and pushes you toward the rate limit.
60 requests per minute per client, with a burst of 60, as a token bucket.
The important and slightly unusual property: only cache misses count. The limiter runs inside the function, and the CDN only invokes the function on a miss. So the documented limit is not a cap on how many requests you may make — a client polling a cached endpoint can exceed it all day and never be refused, because we never see those requests. What it bites is the client that defeats the cache, where every request is a database query.
Clients are identified by IP. Behind a shared NAT you share a bucket with everyone else on that address — survivable in practice because NAT'd browser traffic overwhelmingly hits the CDN. We store a salted hash of the address, never the address itself; this is a limiter, not an access log.
The limiter fails open. If its own machinery breaks, requests are allowed rather than refused — a broken guard rail must not become a broken API.
A real refusal, captured live:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 2
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1787145533
Cache-Control: no-store
Access-Control-Allow-Origin: *{
"error": "Too many requests. This endpoint is rate limited per client; retry after the wait in the Retry-After header.",
"retryAfter": 2
}| Header | Meaning |
|---|---|
Retry-After |
Seconds to wait. This is the one to back off on. |
X-RateLimit-Limit |
The sustained per-minute allowance. |
X-RateLimit-Remaining |
Always 0 — this header only ships on a refusal. |
X-RateLimit-Reset |
Unix epoch seconds, the GitHub convention — an absolute time, not a delta. |
retryAfter in the body carries the same seconds value as the Retry-After header, for clients that
find it easier to read the body.
How to back off:
async function get(url: string): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url);
if (res.status !== 429) return res;
const wait = Number(res.headers.get('retry-after') ?? 1);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
throw new Error('stenion: still rate limited after 5 attempts');
}Honour Retry-After rather than retrying immediately or on a fixed schedule — the value is computed
from your actual token balance, so it is the shortest correct wait.
These headers are absent on a 200. That is deliberate, not an oversight: a 200 is
shared-cached and served to many clients, so an X-RateLimit-Remaining baked into one would be a
single client's balance, frozen and replayed to everybody — a number wrong for every reader,
including the one it came from. You learn your standing the one time it matters, which is when you
are refused.
Every error a consumer can hit, with the real body.
curl https://stenion.vercel.app/api/v1/protocol/does-not-exist{ "error": "Protocol not found", "id": "does-not-exist" }The id you asked for is echoed back. Ids are case-sensitive — /protocol/BLEND is a 404, and
that is the most common cause of an unexpected one.
A 404 is no-store and never cached, on purpose: a protocol added in the next cycle would
otherwise keep 404ing out of a shared cache after it went live.
See Rate limits above.
{ "error": "Internal server error" }Deliberately generic — the underlying error is logged server-side and never leaked. Never cached, so
a 500 cannot outlive the outage that caused it. Retry with backoff.
Both routes are GET (plus HEAD and OPTIONS) only. Any other method returns 405 with an empty
body.
Two rough edges, stated rather than hidden
GET /api/v1/protocolwith no id returns an HTML404, not JSON. No route matches, so the site's own not-found page is served. If you build the URL by concatenation, guard against an empty id — a JSON parse of that response will throw something unhelpful.- A
404from a path that matches no route at all (/api/v2/protocols, the removed/api/protocols) is likewise HTML. JSON error bodies come from paths that matched a route.
So: branch on res.status before parsing, not the other way round.
const res = await fetch('https://stenion.vercel.app/api/v1/protocol/blend');
if (!res.ok) {
// A 404/429/500 from a matched route is JSON; anything else may be HTML.
throw new Error(`stenion: ${res.status}`);
}
const detail = await res.json();Both read routes send Access-Control-Allow-Origin: * and answer the preflight, so browser clients
on any origin can call them directly — no proxy needed. Allowed methods are GET, OPTIONS; the only
allowed request header is content-type. Preflights are cached for a day.
The data is public, read-only, and payment-blind, so * is the correct policy here rather than a
shortcut.
Stated so you do not go looking:
- No pagination, filtering, or sort parameters. Two protocols are tracked today; the leaderboard
is one small response and returns everything, already ranked. If the set grows enough to need
paging, that is an additive change and would arrive on
v1with a documented default. - No historical range query.
historyis the most recent 50 runs, fixed. There is no?from=or?limit=. - No factor history. History rows carry the overall score, not the factor breakdown. The factors are stored, so this could be added additively — open an issue if you need it.
- No webhooks or streaming. Poll.
- No authentication. There is nothing to authenticate; it is all public.
Stenion is open source — the route handlers behind this document are
dashboard/app/api/v1/, and if the code and this page ever disagree, that
is a bug worth an issue.
If you are a protocol being scored and think a threshold is wrong,
METHODOLOGY.md is the rulebook and it tells you how to dispute it. Payment is not
a route to a better number, and never will be.