Skip to main content
Stenion
For integrators

Public API

Two read-only endpoints, no key, open CORS. Every example below is a verbatim capture from the live production API — not written from the types — so the shapes are checkable rather than merely plausible. The data is free and stays free.

View source on GitHub

Stenion Public API

Free, public, read-only risk data for Stellar/Soroban DeFi lending protocols.

Five 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.

The scored examples below were captured from the live production API, not written from the type definitions. They are verbatim bodies: /v1/protocols and /v1/protocol/aquarius-xlm-usdc from 2026-08-30T08:35Z, /v1/protocol/blend from 2026-08-28T12:25Z. The numbers move every ~5 minutes, the shapes do not.

Both categories appear in the examples. /v1/protocols returns "lending" and "dex" entries side by side, and a dex protocol's factors object has two keys rather than lending's five — adminKeySafety and assetControlSafety, at their own weights. The factors section explains how to read a factor map whose keys you do not recognise, and carries a full dex response beside the lending one. Scores are comparable only within one category, and so are the keys and weights of factors.

The coverage example was curled from production on 2026-08-25T18:20Z.

One consequence of "verbatim" worth knowing before you diff these against the field tables: the keys inside operationalState come back in a different order from the tables below, because the value is stored as Postgres jsonb, which does not preserve key order. JSON objects are unordered and no consumer should care — but the examples are copied from the wire rather than tidied, so the difference is real and is left alone.

The /v1/health examples are mixed, and marked individually at the endpoint. The healthy body was curled from production on 2026-08-25T18:20Z. The degraded body is constructed, not captured: risk_scores has never held a failed run, so that state has never occurred in production and cannot be observed until it does.

The /v1/protocols/export contract is documented without a body example until a deployed response can be captured live.


Base URL

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.

Versioning, and whether we will break you

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 *Safety factor, another piece of protocol metadata, a new component inside a factor — ships on v1. 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. v1 keeps 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 a factor an existing category publishes — is breaking, and would be a v2. Admitting a new category, with its own factor keys, is additive and stays on v1: no existing entry's shape changes, and a client that iterates factors by its own keys reads the new one correctly. That is why the factors section tells you to iterate rather than index.


Two commitments

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.


Quick start

# every protocol, ranked
curl https://stenion.vercel.app/api/v1/protocols

# current scored registry snapshot, as JSON or CSV
curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=json"
curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=csv"

# protocols Stenion assessed and deliberately does not score
curl https://stenion.vercel.app/api/v1/coverage

# one protocol, with factors and run history
curl https://stenion.vercel.app/api/v1/protocol/blend

# is the data fresh? (answers 503 when it is not)
curl -i https://stenion.vercel.app/api/v1/health

GET /api/v1/protocols

The leaderboard: every protocol Stenion tracks, with its latest score. Ranked by safetyScore descending, with never-scored protocols last.

The array's order never implies a rank across categories, and two scores are comparable only when their category matches. Each category is scored on its own factors under its own weights — a safetyScore of 70 in one says nothing at all about a 65 in another, and the gap between them is not a quantity. This response is a flat data feed sorted by a single column; it is not a leaderboard across categories, and building one from it by reading positions off the array would assert a comparison the number cannot support. Rank within one category and present the categories separately. That stopped being hypothetical on 2026-08-29: "dex" entries are now published alongside "lending" ones, so the flat order of this array is no longer a valid ranking and never will be again. Sorting by safetyScore across the whole array would put a dex market above or below a lending one on a comparison neither rulebook supports.

Request

curl https://stenion.vercel.app/api/v1/protocols

Response 200 OK

{
  "protocols": [
    {
      "id": "etherfuse",
      "name": "Etherfuse",
      "chain": "stellar",
      "category": "lending",
      "logo": null,
      "deployedOn": {
        "host": "Blend",
        "label": "Blend V2 pool"
      },
      "safetyScore": 65,
      "computedAt": "2026-08-30T08:30:10.636Z",
      "operationalState": {
        "asOf": "2026-08-30T08:30:10.000Z",
        "level": "entryDisabled",
        "detail": "pool status 4 (Admin Frozen) — borrowing and supplying are disabled; withdrawals and repayments still work.",
        "origin": "admin",
        "source": "PoolConfig.status = 4",
        "blocked": ["supply", "borrow"]
      },
      "lastRunAt": "2026-08-30T08:30:07.381Z",
      "lastRunStatus": "ok"
    },
    {
      "id": "blend",
      "name": "Blend",
      "chain": "stellar",
      "category": "lending",
      "logo": "/assets/protocols/blend.svg",
      "deployedOn": null,
      "safetyScore": 49,
      "computedAt": "2026-08-30T07:45:25.080Z",
      "operationalState": {
        "asOf": "2026-08-30T07:45:25.000Z",
        "level": "active",
        "detail": "pool status 1 (Active) — all operations available.",
        "origin": "protocol",
        "source": "PoolConfig.status = 1",
        "blocked": []
      },
      "lastRunAt": "2026-08-30T08:30:23.669Z",
      "lastRunStatus": "failed"
    },
    {
      "id": "kinetic",
      "name": "Kinetic",
      "chain": "stellar",
      "category": "lending",
      "logo": "/assets/protocols/kinetic.png",
      "deployedOn": null,
      "safetyScore": 27,
      "computedAt": "2026-08-30T08:30:18.555Z",
      "operationalState": {
        "asOf": "2026-08-30T08:30:16.000Z",
        "level": "active",
        "detail": "the router is not paused",
        "origin": "indeterminate",
        "source": "router.is_paused() = false",
        "blocked": []
      },
      "lastRunAt": "2026-08-30T08:30:12.987Z",
      "lastRunStatus": "ok"
    },
    {
      "id": "yieldblox",
      "name": "YieldBlox",
      "chain": "stellar",
      "category": "lending",
      "logo": "/assets/protocols/yieldblox.png",
      "deployedOn": {
        "host": "Blend",
        "label": "Blend V2 pool"
      },
      "safetyScore": 27,
      "computedAt": "2026-08-30T07:45:21.408Z",
      "operationalState": {
        "asOf": "2026-08-30T07:45:21.000Z",
        "level": "active",
        "detail": "pool status 0 (Admin Active) — all operations available.",
        "origin": "admin",
        "source": "PoolConfig.status = 0",
        "blocked": []
      },
      "lastRunAt": "2026-08-30T08:30:18.749Z",
      "lastRunStatus": "failed"
    },
    {
      "id": "aquarius-xlm-usdc",
      "name": "Aquarius XLM/USDC",
      "chain": "stellar",
      "category": "dex",
      "logo": null,
      "deployedOn": null,
      "safetyScore": 24,
      "computedAt": "2026-08-30T08:30:12.798Z",
      "operationalState": {
        "asOf": "2026-08-30T08:30:12.000Z",
        "level": "active",
        "detail": "the AMM router CBQDHN… is not in emergency mode",
        "origin": "indeterminate",
        "source": "router.get_emergency_mode() = false",
        "blocked": []
      },
      "lastRunAt": "2026-08-30T08:30:10.864Z",
      "lastRunStatus": "ok"
    }
  ]
}
FieldTypeNotes
idstringStable identifier, case-sensitive, used as the path segment on the detail endpoint.
namestringDisplay name.
chainstringCurrently always "stellar".
categorystringWhich rulebook produced safetyScore — "lending" or "dex" today. Scores are comparable only within one category, and so are the keys of factors. Tolerate an unrecognised value: new categories are additive and stay on v1.
logostring or nullRoot-relative path to a mark Stenion hosts — prefix with the base host. null is a normal state, not a broken image.
deployedOnobject or nullPresent when this entry is not an independent protocol — see Not every entry is a protocol. null means it runs on its own contracts.
safetyScorenumber or null0–100, higher = safer. From the latest ok run. null means never successfully scored — not "zero", not "unsafe".
computedAtstring or nullISO 8601 UTC. When that score was computed. null if and only if safetyScore is null.
operationalStateobject or nullWhat the market's own contracts are currently refusing — see Operational state. Never folded into safetyScore. null means not read, never "unrestricted".
lastRunAtstring or nullISO 8601 UTC. The most recent run of any status. See Staleness.
lastRunStatus"ok", "failed", nullStatus 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. deployedOn and operationalState are the exceptions, and for the opposite reason: neither is detail you look up after deciding to care, both are part of what the row is, and a reader who scans the board and leaves has to have seen them.


GET /api/v1/protocols/export

A current snapshot export of the scored registry state, downloadable as JSON or CSV.

This endpoint is intentionally non-historical. It returns one row or object per currently scored protocol/market, using the same persisted latest-successful score semantics as GET /api/v1/protocols: safetyScore, computedAt, factors, methodologyVersion, and operationalState come from the latest ok run, while lastRunAt and lastRunStatus describe the newest run of any status. If the latest attempted run failed, the last successful score remains the exported current score and lastRunStatus exposes the failure. A protocol that has never produced a successful score is excluded from this scored snapshot rather than exported with nulls or zeros.

Scores are comparable only within one category. Export consumers should not rank lending and dex rows against each other, and should tolerate new categories and factor keys.

This endpoint was added after the captured production examples at the top of this document. No live response body is included here until it can be captured from a deployed API response; the contract below documents the shape without fabricating sample data.

Request

curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=json"
curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=csv"
Query parameterValuesNotes
formatjson or csvOptional. Defaults to json. Other values 400.

Successful responses

FormatContent-TypeContent-Disposition filename
JSONapplication/json; charset=utf-8stenion-registry-current.json
CSVtext/csv; charset=utf-8stenion-registry-current.csv

Successful responses use the same freshness-aware public cache policy as /api/v1/protocols: the shared-cache TTL is derived from exported rows' lastRunAt values, so caching does not hide a newer run for longer than the documented floor.

JSON shape

JSON preserves each factor map as structured JSON:

{
  protocols: Array<{
    id: string;
    name: string;
    chain: string;
    category: string;
    logo: string | null;
    deployedOn: { host: string; label: string } | null;
    safetyScore: number;
    computedAt: string;
    methodologyVersion: number;
    factors: {
      [factorKey: string]: {
        value: number;
        weight: number;
        detail: string;
        components?: Array<{ id: string; label: string; value: number | null; detail: string }>;
      } | null;
    };
    operationalState: {
      level: string;
      asOf: string;
      origin: 'admin' | 'protocol' | 'indeterminate';
      source: string;
      detail: string;
      blocked: string[];
    } | null;
    lastRunAt: string | null;
    lastRunStatus: 'ok' | 'failed' | null;
  }>;
}

CSV shape

CSV has exactly one data row per exported current scored protocol. The stable top-level columns come first:

id,name,chain,category,logo,deployedOn.host,deployedOn.label,safetyScore,computedAt,methodologyVersion,lastRunAt,lastRunStatus,operationalState.level,operationalState.asOf,operationalState.origin,operationalState.source,operationalState.detail,operationalState.blocked

After those, the export builds the deterministic union of factor keys present in that response, sorts them alphabetically, and emits three columns for each:

<factor>.value,<factor>.weight,<factor>.detail

Rows whose category does not use a factor leave that factor's cells empty. operationalState is flattened into its public fields; operationalState.blocked is joined as a semicolon-separated text cell. CSV cells are RFC-4180 escaped for commas, quotes, and CR/LF. Empty, null, or undefined values serialize as empty cells. Text cells beginning with =, +, -, or @ after leading whitespace are prefixed with an apostrophe to avoid spreadsheet formula execution; numeric score, value, and weight cells are not modified by that mitigation.

Errors and preflight

Unsupported formats return 400 with:

{ "error": "Unsupported format", "supportedFormats": ["json", "csv"] }

Database failures return the same generic, uncached 500 shape as the other public routes:

{ "error": "Internal server error" }

The endpoint is public, read-only, CORS-enabled, and rate limited like the other /api/v1 data routes. OPTIONS returns the standard public API preflight response.


GET /api/v1/coverage

Protocols and markets Stenion has assessed and deliberately does not score. This is a separate, unranked contract: an entry here is a coverage decision, never a failed run or a low score.

Request

curl https://stenion.vercel.app/api/v1/coverage

Response 200 OK

The first entry is shown below for readability. The live response returned 4 entries in the coverage array; this object is verbatim from a production curl.

{
  "coverage": [
    {
      "id": "templar",
      "name": "Templar",
      "status": "off-chain-state",
      "logo": null,
      "links": {
        "site": null,
        "docs": null
      },
      "contractId": null,
      "summary": "A NEAR-based protocol whose reserves, balances and positions live on NEAR — the only contract it runs on Soroban is a price oracle.",
      "reason": [
        "Templar is a NEAR-based chain-abstraction protocol — it calls its product “Cypher Lending” — and its lending market state lives on NEAR, not on Stellar. Reserves, supply and borrow balances, utilization and collateral positions are all read through NEAR RPC. Stellar’s role is as a wallet and collateral entry point via NEAR’s MPC signing, not as the ledger the lending market runs on.",
        "The only native-Soroban contract Templar ships is a price oracle. That is one of the five factors Stenion scores; the other four are on another chain. An adapter faithful to what Templar actually is would have to read NEAR, and Stenion’s adapters read trustless Stellar infrastructure and nothing else — that rule is the pitch rather than an implementation detail, so bending it for one protocol would quietly change what every other score means.",
        "This is a decision about where the data lives, not a judgment about Templar. It could be represented only if Stenion’s model expanded to read another chain, which ROADMAP.md keeps explicitly out of scope."
      ],
      "verify": "Follow Templar’s own documentation for where lending state is held, then confirm it against the chain: the Soroban contract it publishes on Stellar exposes an oracle interface (price reads), with no reserve, supply/borrow or position storage. There is no Soroban contract to call get_reserves_list, or any equivalent, against.",
      "asOf": null
    }
  ]
}
FieldTypeNotes
idstringStable, case-sensitive coverage identifier; also the path segment on /coverage/<id>.
namestringDisplay name.
statusstringMachine-readable coverage category. New categories may be added on v1; existing values are not renamed on v1.
logostring or nullRoot-relative self-hosted mark, or null.
linksobjectThe protocol's verified site and docs, each string or null.
contractIdstring or nullFull Soroban address only when one was recorded; otherwise null.
summarystringOne-sentence coverage summary.
reasonstring[]Protocol-specific evidence and reasoning. Quoted measurements remain text, not a numeric score.
verifystringHow an integrator or reader can independently check the decision.
asOfYYYY-MM-DD or nullDate of a measurement-backed reason. null means the decision is structural or no dated check is claimed.

There is deliberately no safetyScore key and no JSON numeric value anywhere in this response. Identifiers, dates, and evidence strings can contain digits; none is a value a client could mistake for a score. The full evidence ships in the list, so there is no separate GET /api/v1/coverage/:id endpoint.

The route reads the live leaderboard only to apply the same self-healing dedupe as the registry. A protocol that has become scorable cannot appear in both responses. GET /api/v1/protocols is unchanged byte-for-byte.


Not every entry is a protocol

Some entries are individual markets running another protocol's contracts, not protocols in their own right. The YieldBlox entry (yieldblox) is one: it is a DAO-managed pool on Blend V2, running Blend's pool contract byte-for-byte, and Stenion scores it with the same adapter it uses for Blend's own pool.

Such an entry carries a non-null deployedOn on both endpoints — verbatim from the yieldblox entry in the leaderboard capture above:

"deployedOn": {
  "host": "Blend",
  "label": "Blend V2 pool"
}
FieldTypeNotes
hoststringThe host protocol's display name, e.g. "Blend". Not an id, and not a link.
labelstringShort label naming the deployment, e.g. "Blend V2 pool". Safe to render verbatim.

null means the entry runs on its own contracts. It never means "unknown" — we do not register an entry without knowing which.

If you display protocol names, display this beside them. Not a style preference: without it your users read a list of markets as a list of protocols, which is a claim about the ecosystem that isn't true. Rendering label verbatim next to the name is enough.

host is deliberately not a protocol id and links to nothing. Stenion's blend entry is itself one Blend market, so pointing at it would say this pool runs on that entry rather than on Blend's contract. If you want the host's own entry, you are looking for a relationship this API does not assert.

Each such entry is scored independently, on its own on-chain state. Sharing contract code is not sharing a score: deployedOn markets are ranked on their own reserves, oracle configuration and admin like any other entry, and the two live Blend pools currently differ by 30 points. Do not infer one entry's risk from its host's.


GET /api/v1/protocol/:id

One protocol: metadata, the current score, the full factor breakdown, and recent run history.

Request

curl https://stenion.vercel.app/api/v1/protocol/blend

Response 200 OK

history is truncated to one entry below. The live response returns up to 50 rows, newest first, and every ok one carries a factors object of exactly the shape shown here — which is why one is shown in full rather than three with it cut out. Everything else is verbatim.

{
  "id": "blend",
  "name": "Blend",
  "chain": "stellar",
  "category": "lending",
  "adapter": "BlendAdapter",
  "logo": "/assets/protocols/blend.svg",
  "contractId": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD",
  "site": "https://www.blend.capital",
  "docs": "https://docs.blend.capital",
  "deployedOn": null,
  "safetyScore": 49,
  "computedAt": "2026-08-28T12:25:19.635Z",
  "factors": {
    "oracleSafety": {
      "value": 97,
      "detail": "all 3 reserves score the same — 319s old (fresh<300s, dead>900s); all reserves have a deviation bound",
      "weight": 0.25,
      "components": [
        {
          "id": "priceFreshness",
          "label": "Price freshness",
          "value": 97,
          "detail": "all 3 reserves score the same — 319s 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 319s, Other:USDC 319s, Other:EURC 319s — 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": 20,
      "detail": "worst reserve (CCW67T…) has 20% of supply as free liquidity",
      "weight": 0.15
    },
    "collateralSafety": {
      "value": 58,
      "detail": "top reserve holds 74% of supplied value across 3 reserves (HHI 0.61)",
      "weight": 0.2
    },
    "utilizationSafety": {
      "value": 11,
      "detail": "worst reserve (CCW67T…) at 80% util vs 90% cap",
      "weight": 0.2
    }
  },
  "operationalState": {
    "asOf": "2026-08-28T12:25:19.000Z",
    "level": "active",
    "detail": "pool status 1 (Active) — all operations available.",
    "origin": "protocol",
    "source": "PoolConfig.status = 1",
    "blocked": []
  },
  "methodologyVersion": 1,
  "lastRunAt": "2026-08-28T12:25:17.171Z",
  "lastRunStatus": "ok",
  "history": [
    {
      "status": "ok",
      "safetyScore": 49,
      "methodologyVersion": 1,
      "factors": {
        "oracleSafety": {
          "value": 97,
          "detail": "all 3 reserves score the same — 319s old (fresh<300s, dead>900s); all reserves have a deviation bound",
          "weight": 0.25,
          "components": [
            {
              "id": "priceFreshness",
              "label": "Price freshness",
              "value": 97,
              "detail": "all 3 reserves score the same — 319s 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 319s, Other:USDC 319s, Other:EURC 319s — 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": 20,
          "detail": "worst reserve (CCW67T…) has 20% of supply as free liquidity",
          "weight": 0.15
        },
        "collateralSafety": {
          "value": 58,
          "detail": "top reserve holds 74% of supplied value across 3 reserves (HHI 0.61)",
          "weight": 0.2
        },
        "utilizationSafety": {
          "value": 11,
          "detail": "worst reserve (CCW67T…) at 80% util vs 90% cap",
          "weight": 0.2
        }
      },
      "computedAt": "2026-08-28T12:25:19.635Z",
      "runAt": "2026-08-28T12:25:17.171Z"
    }
  ]
}
FieldTypeNotes
id, name, chain, logoSame as the leaderboard.
categorystringSame as the leaderboard. Pairs with methodologyVersion — see that row.
adapterstringWhich Stenion adapter produced the score. Informational.
contractIdstring or nullThe Soroban contract the score was derived from. A raw C… address, deliberately not an explorer URL — pick your own.
site, docsstring or nullThe protocol's own links. Listed as its properties, not as a recommendation.
deployedOnobject or nullSame as the leaderboard. See Not every entry is a protocol.
operationalStateobject or nullSame as the leaderboard. See Operational state.
safetyScore, computedAtLatest ok run. Both null if never successfully scored.
factorsobject or nullThe factor breakdown for this protocol's category, or null if never scored. Not always five keys — see below.
methodologyVersionnumber or nullWhich rulebook version the current score was computed under. Read it with category, not alone — every category's version counter starts at 1, so the pair identifies a rulebook and the number by itself does not.
lastRunAt, lastRunStatusNewest run of any status. See Staleness.
historyarrayUp to 50 recent runs, newest first, each ok one carrying its own factors. A discriminated union — see below.

The factors object

Its keys are the factors this protocol's category is scored on, and they differ per category. Read factors by iterating its own keys — never by indexing a fixed list. A lending protocol carries exactly collateralSafety, oracleSafety, adminKeySafety, liquiditySafety and utilizationSafety; a dex protocol carries exactly adminKeySafety and assetControlSafety.

This is a clarification of v1, not a break in it. Until 2026-08-29 every scored protocol was lending, so every factors object had the same five keys and this section said so. Registering the first dex market made that description wrong rather than making the contract change: the field was always "this protocol's factors", category was always published beside it, and a client that indexed factors.oracleSafety unconditionally will now read undefined on a dex entry. Which factors a category scores is published per category in methodology/, and adding a category is additive: treat an unrecognised factor key as data, not as an error.

adminKeySafety appears in both, deliberately — it answers the same question ("who can change the rules") from entirely different on-chain data on each side. That is not a comparability claim. A dex adminKeySafety of 60 and a lending one of 60 were produced by different rules from different quantities, exactly as the two overall scores were.

A dex response in full

The same endpoint, on the one dex market: category is "dex", factors has two keys, and their weights sum to 1 exactly as lending's five do. history is truncated to one entry as above; everything else is verbatim.

curl https://stenion.vercel.app/api/v1/protocol/aquarius-xlm-usdc
{
  "id": "aquarius-xlm-usdc",
  "name": "Aquarius XLM/USDC",
  "chain": "stellar",
  "category": "dex",
  "adapter": "AquariusAdapter",
  "logo": null,
  "contractId": "CA6PUJLBYKZKUEKLZJMKBZLEKP2OTHANDEOWSFF44FTSYLKQPIICCJBE",
  "site": "https://aqua.network",
  "docs": "https://docs.aqua.network",
  "deployedOn": null,
  "safetyScore": 24,
  "computedAt": "2026-08-30T08:30:12.798Z",
  "factors": {
    "adminKeySafety": {
      "value": 10,
      "detail": "role posture binds at 10 — pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d",
      "weight": 0.55,
      "components": [
        {
          "id": "rolePosture",
          "label": "Privileged role posture",
          "value": 10,
          "detail": "pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d"
        },
        {
          "id": "upgradeWindow",
          "label": "Upgrade reaction window",
          "value": 100,
          "detail": "pool UpgradeDeadline = 0 — no code change scheduled (FutureWASM ae0da5a8… matches the running code) | router UpgradeDeadline = 0 — no code change scheduled (FutureWASM 06f4207b… matches the running code)"
        },
        {
          "id": "timelockDuration",
          "label": "Timelock duration",
          "value": null,
          "detail": "ADMIN_ACTIONS_DELAY is a compile-time constant with no getter, confirmed absent from all four deployed wasms, so the LENGTH of the upgrade window is not readable from the contract. The deadline and the current time are, which is why the window is graded as a state and never as a fraction remaining."
        },
        {
          "id": "emergencyBypass",
          "label": "Emergency Admin bypass",
          "value": null,
          "detail": "Aquarius's own response to Certora H-01: \"In the case of system vulnerability fixes, delay may be bypassed by the Emergency Admin role.\" So the reaction window is conditional on one single-signer key choosing not to skip it. Disclosed rather than folded in, because \"a window exists\" and \"the window is unconditional\" are different claims and only the first is true."
        }
      ]
    },
    "assetControlSafety": {
      "value": 40,
      "detail": "worst of 2 gradable reserve(s): USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance",
      "weight": 0.45,
      "components": [
        {
          "id": "reserve:CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
          "label": "XLM",
          "value": 100,
          "detail": "XLM CAS3J7…: native XLM — a SAC with no issuer account to act from"
        },
        {
          "id": "reserve:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
          "label": "USDC",
          "value": 40,
          "detail": "USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance"
        }
      ]
    }
  },
  "operationalState": {
    "asOf": "2026-08-30T08:30:12.000Z",
    "level": "active",
    "detail": "the AMM router CBQDHN… is not in emergency mode",
    "origin": "indeterminate",
    "source": "router.get_emergency_mode() = false",
    "blocked": []
  },
  "methodologyVersion": 1,
  "lastRunAt": "2026-08-30T08:30:10.864Z",
  "lastRunStatus": "ok",
  "history": [
    {
      "status": "ok",
      "safetyScore": 24,
      "methodologyVersion": 1,
      "factors": {
        "adminKeySafety": {
          "value": 10,
          "detail": "role posture binds at 10 — pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d",
          "weight": 0.55,
          "components": [
            {
              "id": "rolePosture",
              "label": "Privileged role posture",
              "value": 10,
              "detail": "pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d"
            },
            {
              "id": "upgradeWindow",
              "label": "Upgrade reaction window",
              "value": 100,
              "detail": "pool UpgradeDeadline = 0 — no code change scheduled (FutureWASM ae0da5a8… matches the running code) | router UpgradeDeadline = 0 — no code change scheduled (FutureWASM 06f4207b… matches the running code)"
            },
            {
              "id": "timelockDuration",
              "label": "Timelock duration",
              "value": null,
              "detail": "ADMIN_ACTIONS_DELAY is a compile-time constant with no getter, confirmed absent from all four deployed wasms, so the LENGTH of the upgrade window is not readable from the contract. The deadline and the current time are, which is why the window is graded as a state and never as a fraction remaining."
            },
            {
              "id": "emergencyBypass",
              "label": "Emergency Admin bypass",
              "value": null,
              "detail": "Aquarius's own response to Certora H-01: \"In the case of system vulnerability fixes, delay may be bypassed by the Emergency Admin role.\" So the reaction window is conditional on one single-signer key choosing not to skip it. Disclosed rather than folded in, because \"a window exists\" and \"the window is unconditional\" are different claims and only the first is true."
            }
          ]
        },
        "assetControlSafety": {
          "value": 40,
          "detail": "worst of 2 gradable reserve(s): USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance",
          "weight": 0.45,
          "components": [
            {
              "id": "reserve:CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
              "label": "XLM",
              "value": 100,
              "detail": "XLM CAS3J7…: native XLM — a SAC with no issuer account to act from"
            },
            {
              "id": "reserve:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
              "label": "USDC",
              "value": 40,
              "detail": "USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance"
            }
          ]
        }
      },
      "computedAt": "2026-08-30T08:30:12.798Z",
      "runAt": "2026-08-30T08:30:10.864Z"
    }
  ]
}

Note adminKeySafety carrying a different weight here (0.55) than on a lending entry (0.20). Weights belong to a category's rulebook, so reading one off a response and applying it to another category's factor of the same name is the same mistake as comparing the two values.

Each value 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).

FieldTypeNotes
valuenumber0–100, higher = safer — the same direction as the overall score.
weightnumberThis factor's share of the overall score. Weights of all non-null factors sum to 1.
detailstringHuman-readable, includes the raw on-chain figure it came from. Safe to surface directly.
componentsarray, optionalSub-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.


Operational state is published, never scored

operationalState reports which user operations a market's own contracts were refusing when it was last scored. It appears on both /v1/protocols and /v1/protocol/:id, and it is not an input to safetyScore — a halted market and a fully open one can publish the same number.

That is deliberate, not an oversight. A pause can mean an admin containing a threat or a market being abandoned, and no on-chain data separates the two; the protocols' restricted states are not even the same shape (Blend never blocks a withdrawal at any of its seven pool statuses, while a paused Kinetic router blocks withdrawals, repayments and liquidations alike). Grading either into one number would assert an equivalence that is not true. The full reasoning, including the scored designs that were rejected, is in methodology/.

"operationalState": {
  "level": "entryDisabled",
  "source": "PoolConfig.status = 4",
  "blocked": ["supply", "borrow"],
  "origin": "admin",
  "detail": "pool status 4 (Admin Frozen) — borrowing and supplying are disabled; withdrawals and repayments still work.",
  "asOf": "2026-08-25T07:40:53.654Z"
}
FieldTypeNotes
levelstring enumOne of active, borrowingDisabled, entryDisabled, exitDisabled, notOperational. See the table below.
sourcestringThe protocol's own reading, verbatim, so you can check it on chain yourself. Never a Stenion label.
blockedarray of stringWhich of supply, withdraw, borrow, repay, liquidate are refused, in that canonical order. Empty when active.
originstring enumadmin | protocol | indeterminate — who could have set this state, never why. See below.
detailstringOne sentence: what was read and what it means for a user.
asOfstringISO 8601 UTC. A live state is only true as of an instant.
levelWhat it means for someone with funds in the market
activeNothing restricted.
borrowingDisabledCannot borrow. Depositing and withdrawing both work.
entryDisabledCannot borrow or supply — no new exposure — but you can still exit.
exitDisabledCannot withdraw. Capital cannot leave the market while this holds.
notOperationalThe market was never opened for use.

origin is the closest the chain comes to the question the score cannot answer, and it is not that answer. admin means only an admin could have set this state — not that they were right or wrong to. protocol means the protocol's own mechanism produced it without anyone acting. indeterminate is a real reading rather than a gap: Blend's status 3 is settable by an admin and by its backstop update path, and Kinetic's pause flag carries no origin at all.

If you render safetyScore, render this beside it. It is on the leaderboard rather than only on the detail response for exactly that reason — a reader who scans a list and leaves must not have been shown only the number. null means the state was not read (never scored, or a run predating the field); it never means "nothing is restricted".


History rows are a discriminated union — read this one

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 ok row carries safetyScore, methodologyVersion, factors, computedAt, and runAt.
  • a failed row carries error and runAt, and does not have a safetyScore key at all — not null, not 0. The key is absent. Nor a factors key: a run that failed produced no breakdown, and an empty object here would read as every factor scoring nothing.

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;
      // the same shape as the top-level `factors`, as THAT run computed it
      factors: Record<string, RiskFactor | null>;
      computedAt: string;
      runAt: string;
    }
  | {
      status: 'failed';
      error: string;
      runAt: string;
    };

A failed row looks like this — captured live, from Blend's history on 2026-08-28. It is a run the shared public RPC refused, which is what most failures here are: our failure to read the chain, not anything about the protocol.

{
  "status": "failed",
  "error": "Request failed with status code 429",
  "runAt": "2026-08-28T12:30:18.171Z"
}

(This example was previously constructed from the schema, because at the time no production run had ever failed. Some now have.)

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.

A history row's factors belong to that row

An ok row's factors are what that run computed, from the on-chain state it read at computedAt — not the current breakdown restated under an old date. That is the whole point of returning them: a score moving from 51 to 49 tells you nothing until you can see which factor moved.

Read them under that row's own methodologyVersion, exactly as you read its safetyScore. Two rows stamped with different versions were scored by different rules, so a factor that appears in both is not necessarily the same measurement — and a factor may not appear in both at all. Nothing is backfilled across a version bump; risk_scores stores outputs, never the raw on-chain inputs, so an old row cannot be recomputed under new rules and never will be.

Sizing note: 50 rows each carry a full factor map, so this response is measured in tens of kilobytes, not hundreds of bytes. It is one response — there is no per-row fetch, and adding one would be 50 requests for data you already have.


Staleness is your problem too

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.


GET /api/v1/health

The machine-readable version of everything the previous section just described. Point an uptime monitor at it and you will know when our data stops updating without polling scores and diffing timestamps yourself.

Request

curl -i https://stenion.vercel.app/api/v1/health

Response — 200 OK

This one is a real capture: curled over HTTP from the production endpoint on 2026-08-26T10:04Z. The Cache-Control: no-store described under Caching was verified on the same request — staleness here advances with the wall clock, so any TTL could serve a healthy 200 after the true answer had become degraded.

{
  "status": "healthy",
  "thresholdMinutes": 30,
  "protocols": [
    {
      "id": "blend",
      "lastSuccessfulRunAt": "2026-08-26T10:01:25.334Z",
      "lastRunAt": "2026-08-26T10:01:25.334Z",
      "lastRunStatus": "ok",
      "staleMinutes": 3
    },
    {
      "id": "etherfuse",
      "lastSuccessfulRunAt": "2026-08-26T10:00:59.824Z",
      "lastRunAt": "2026-08-26T10:00:59.824Z",
      "lastRunStatus": "ok",
      "staleMinutes": 3
    },
    {
      "id": "kinetic",
      "lastSuccessfulRunAt": "2026-08-26T10:01:08.636Z",
      "lastRunAt": "2026-08-26T10:01:08.636Z",
      "lastRunStatus": "ok",
      "staleMinutes": 3
    },
    {
      "id": "yieldblox",
      "lastSuccessfulRunAt": "2026-08-26T10:01:17.757Z",
      "lastRunAt": "2026-08-26T10:01:17.757Z",
      "lastRunStatus": "ok",
      "staleMinutes": 3
    }
  ]
}

Response — 503 Service Unavailable

This second body is NOT a capture. risk_scores has never held a failed run in production, so degraded has never occurred and cannot be captured. It is constructed from the route's tests. The shape is exact; the values are illustrative.

{
  "status": "degraded",
  "thresholdMinutes": 30,
  "protocols": [
    {
      "id": "blend",
      "lastSuccessfulRunAt": "2026-08-22T12:41:00.000Z",
      "lastRunAt": "2026-08-22T12:41:00.000Z",
      "lastRunStatus": "ok",
      "staleMinutes": 9
    },
    {
      "id": "kinetic",
      "lastSuccessfulRunAt": "2026-08-22T09:10:00.000Z",
      "lastRunAt": "2026-08-22T12:49:00.000Z",
      "lastRunStatus": "failed",
      "staleMinutes": 220
    }
  ]
}

Fields

FieldTypeMeaning
status"healthy" | "degraded" | "down"The overall verdict. See below.
thresholdMinutesnumberThe staleness threshold this response was judged against. Echoed so you never have to guess what produced the verdict.
protocols[].idstringThe protocol slug, same id as everywhere else in this API.
protocols[].lastSuccessfulRunAtISO 8601 | nullThe newest run that succeeded. null = never scored successfully.
protocols[].lastRunAtISO 8601 | nullThe newest run of any outcome. null = never ran.
protocols[].lastRunStatus"ok" | "failed" | nullOutcome of that newest run. Same vocabulary as /v1/protocols — ok/failed, not success/failure.
protocols[].staleMinutesnumber | nullWhole minutes since lastSuccessfulRunAt, computed at request time. null when there is no successful run to measure from — not zero.

Protocols are ordered by id. That is deliberate and carries no ranking: entries here are only ever current or not, and alphabetical is the one ordering that asserts nothing about which protocol is doing better.

The status, and the HTTP code

statusMeaningHTTP
healthyEvery protocol scored successfully within thresholdMinutes.200
degradedSome protocols are current, some are not. Probably one broken adapter.503
downNothing is current anywhere. Our indexer itself looks dead.503

Both non-healthy states answer 503, so a monitor that only reads the status line works with no body parsing at all. The distinction between them is for the human who opens it afterwards.

A 500 on this route means something different from a 503: 503 is us successfully reporting that the pipeline is behind, 500 is us being unable to find out. If you alert on this endpoint, they are both worth catching, but only the 503 tells you the scores are aging.

How to read it

  • Staleness is measured from lastSuccessfulRunAt, never lastRunAt. An adapter failing every five minutes has a perpetually fresh lastRunAt; measuring from it would report the exact failure this endpoint exists to catch as perfectly healthy.
  • The two timestamps together tell you where the problem is. A fresh lastRunAt beside a stale lastSuccessfulRunAt is one adapter failing while our pipeline runs fine. Both stale together means our indexer is not running at all. The kinetic row above is the first case.
  • A single fresh failure does not make us unhealthy. If a protocol's newest run failed but its newest success is minutes old, status stays healthy — the data you are being served is current, and we would rather not cry wolf over one cycle. Sustained failure crosses the threshold on its own. If you want to react to any failed cycle, read lastRunStatus yourself; that is why it is published.

Caching and limits

Never cached — this route sends Cache-Control: no-store, unlike the other three. A health check that can be stale is a contradiction, and a cached 503 would go on being served after we recovered. It is rate limited like everything else, which at 60 requests/minute is far more than a monitor probing every 30 seconds will use.

Polling faster than once a minute gains you nothing: we re-score every ~5 minutes, so the answer cannot change more often than that.


The score

safetyScore is 0 to 100, higher is safer. It is a weighted mean of that protocol's factors, each of which is also 0–100 higher-is-safer. How many factors, and which, depends on its category — five for lending, two for dex — because each category is scored on the failures that category actually has. Two safetyScores mean the same thing only when category agrees; see Categories.

Factors, and nothing else. operationalState is published alongside the score and is never an input to it — see Operational state is published, never scored. If you sum the factors yourself, you get safetyScore back; that property is deliberate and is why no pause multiplier was applied to it.

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.


Caching

All cacheable /v1 read routes are served through a CDN. The scored current-state routes — /v1/protocols, /v1/protocols/export, and /v1/protocol/:id — use 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, on those three routes: a cached response can hide a newer indexer run by at most 10 seconds.

GET /api/v1/coverage is the deliberate exception, and says so rather than quietly differing. Its body carries no lastRunAt — there is no run behind a coverage decision — and its records change only when we deploy, so there is no freshness field for a cache to mask and nothing in the body to derive a deadline from. It uses a fixed one-hour shared-cache TTL instead.

What you will actually observe on a 200:

Cache-Control: public, max-age=0
Age: 3
X-Vercel-Cache: HIT

The 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: scored data changes every ~5 minutes, so polling those routes faster than that buys you nothing but cache hits. Once a minute is generous. Coverage records normally change only on deploy and use a one-hour shared-cache TTL, so polling /coverage more than hourly is wasteful. 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.


Rate limits

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.

429 Too Many Requests

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
}
HeaderMeaning
Retry-AfterSeconds to wait. This is the one to back off on.
X-RateLimit-LimitThe sustained per-minute allowance.
X-RateLimit-RemainingAlways 0 — this header only ships on a refusal.
X-RateLimit-ResetUnix 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.


Errors

Every error a consumer can hit, with the real body.

404 Not Found — unknown protocol id

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.

429 Too Many Requests

See Rate limits above.

500 Internal Server Error

{ "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.

405 Method Not Allowed

All three 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/protocol with no id returns an HTML 404, 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 404 from 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();

CORS

All three 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.


Not in this API

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 v1 with a documented default.
  • No historical range query. history is the most recent 50 runs, fixed. There is no ?from= or ?limit=.
  • No historical factor DIFF. History rows do carry their own factors, so you can see which factor moved a score. What there is not is a server-side comparison endpoint: diff two rows yourself, and only when their methodologyVersion matches — factors from two rulebooks are no more comparable than the scores are.
  • No webhooks or streaming. Poll.
  • No authentication. There is nothing to authenticate; it is all public.

Questions, bugs, and disputes

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/index.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.