The public JSON endpoint

102 published key paths. No key, no account, CORS open.

Updated just now

The endpoint

/api/v1/board.json

What this endpoint guarantees

17 claims, each with the test behind it

The endpoint
One GET, no parameters, no request body: /api/v1/board.json returns the whole board as JSON. There is nothing else to call. web.app.create_app — tests/web/test_web_app.py::test_the_payload_is_the_pages_data_model
Access
No key, no account, no sign-up. Access-Control-Allow-Origin is *, so a page on another origin can fetch this straight from the browser. web.app._json_response — tests/web/test_web_app.py::test_the_payload_is_the_pages_data_model
Licence
The published data is licensed under Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/. You may copy it, republish it and build on it, including commercially, on these terms: credit TiBlock with a link to https://tiblock.com, link to the licence, and indicate if you changed anything. This covers the data at /api/v1/board.json. web.api_contract.LICENCE_NAME — tests/web/test_web_api_contract.py::test_the_licence_terms_reach_the_page
Versioning
version is this payload's shape contract. A breaking change ships at a new path beside this one rather than as a silent mutation of the shape a consumer already cites. web.public.PAYLOAD_VERSION — tests/web/test_web_public.py::test_the_version_and_the_route_agree
Two versions, not one
score_version names the function behind score_v1 and moves independently of version. The payload's shape and that function's identity are different claims, and either can change without the other. web.public.board_payload — tests/web/test_web_public.py::test_the_score_version_is_published_at_the_top_level
The key set
Every object is projected onto an explicit list of field names. A key that is not on that list cannot reach you even if something upstream produces it, and a listed key that goes missing raises rather than being dropped. web.public._project — tests/web/test_web_public.py::test_the_payload_is_exactly_the_allow_listed_shape
What is deliberately absent
The engine's private ranking inputs are not published here: its internal rating of a programme, its own ordering, the notes and queues it keeps for one operator, and the collector failure counts. They are asserted absent by test rather than merely left off a list. web.public — tests/web/test_web_public.py::test_no_never_present_field_is_a_key_anywhere, tests/web/test_web_public.py::test_no_never_present_word_survives_into_published_prose
Key order
Keys are serialised in the order the builder writes them, never sorted. That order is stable across builds and is a convenience for diffing — it is not something to parse by position. web.public.board_payload — tests/web/test_web_public.py::test_the_golden_payload_is_unchanged
What is capped
markets[] is never truncated: the complete table is the product. milestones, sources and notes are never truncated either. Three lists are capped — deadlines, movers and news — and each has a total in counts taken before the cap, so a short section can be told from a quiet one. Read movers[] against counts.movers in particular: of the three its default cap is the smallest, so that array is a top-N and not a census. web.public.board_payload — tests/web/test_web_public.py::test_which_lists_are_capped_and_which_counts_make_detectable
Row order
markets[] arrives ordered by score_v1, with flagged rows at zero and last. This order can change again. Sort on a published field to replace it. web.public.NOTES — tests/web/test_web_public.py::test_the_payload_publishes_markets_in_the_order_its_own_notes_declare, tests/web/test_web_public.py::test_the_order_note_declares_score_v1_and_that_the_order_can_change
What null means
null means not measured, and it never means zero: a measured zero is published as 0.0, and the two states are separate values on every field that has both. What null does not carry is a single reason. Several fields are null for more than one, and on some of them this payload cannot tell you which — so read the field's own row below before acting on a null. Inside score_factors a null factor contributes nothing to the total and is still published as null, never as a zero that would read as a measurement. web.public — tests/web/test_web_public.py::test_null_means_not_measured_and_never_zero, tests/web/test_web_public.py::test_a_factor_is_null_rather_than_zero_where_its_input_was_not_measured, tests/web/test_web_public.py::test_the_yield_offset_is_zero_when_a_measured_rate_was_zero
Precision
Every published float is rounded to six significant digits. A figure derived from one price reading does not get seventeen digits of apparent precision, and the rounding is also what keeps a cached copy valid from one machine to the next. web.public._round — tests/web/test_web_public.py::test_every_published_float_is_rounded_to_six_significant_digits
ETag
Every 200 carries a strong ETag over the JSON bytes, so it changes exactly when the published data does — including when only the age moved, because generated_at and stale are part of what you cached. web.cache.CachedBoard — tests/web/test_web_app.py::test_the_etag_changes_when_the_age_does
Conditional requests
Send If-None-Match and an unchanged generation answers 304 with no body. The header is read as a list and compared weakly, so a validator a CDN has weakened still revalidates instead of silently transferring the whole payload every time. web.app._if_none_match — tests/web/test_web_app.py::test_if_none_match_returns_304_with_no_body, tests/web/test_web_app.py::test_a_weakened_etag_still_revalidates
How often it changes
The board is rebuilt every 120 seconds and each response may be cached for 60 seconds, so polling faster than that returns bytes you already hold. Send If-None-Match instead: an unchanged generation costs a 304 and no body. web.config.WebConfig — tests/web/test_web_app.py::test_two_requests_in_one_generation_are_byte_identical, tests/web/test_web_app.py::test_the_payload_is_the_pages_data_model
Before the first build
A container that has not finished its first read of the engine answers 503 with {"error":"starting up"} rather than an empty board. Nothing here and unavailable are different facts, and a consumer is entitled to tell them apart. web.app.handle_payload — tests/web/test_web_app.py::test_the_payload_says_it_is_starting
notes[]
notes[] carries the payload's own changelog and its order declaration, so a shape change is something you can discover from the payload rather than trip over. web.public.NOTES — tests/web/test_web_public.py::test_the_shape_change_note_names_a_published_key

Every published field

102 key paths

Every key path the public JSON endpoint publishes, grouped by the object or array that contains it and, within each group, in the payload's own key order. One row per key path: its type, whether it can be null, and what it is.
Key path Type Null What it is
version integer no This payload's shape contract. A breaking change ships at a new path beside this one rather than as a silent change to the shape you already cite.
score_version string no Which function produced score_v1, score_factors and segment. Independent of version: the payload's shape and that function's identity can move apart.
generated_at string no When this board was assembled, in UTC. It is not when the underlying measurements were taken — that is markets[].metrics_as_of, per row, and the two can be hours apart.
as_of_event_id integer no The highest event id in the engine's log when this board was built. A reproducibility anchor: it says what the build knew, not when it ran. 0 on an empty log.
stale boolean no Whether this board is stale, on two conditions rather than one: it stopped being rebuilt, or it is being rebuilt on time over collector data that has stopped moving. An unreadable timestamp counts as stale — an unknown age is not an argument for freshness.
counts object no Seven totals, each taken before any cap is applied. Three of them cover the three capped lists — counts.deadlines, counts.movers and counts.news — so a short section can be told from a quiet one: compare a total against the length of the array it names.
deadlines array no Dated obligations inside the calendar horizon, one card each — the short list the board's strip is built from. Capped; counts.deadlines is the total. For the whole programme calendar at any distance, read milestones instead.
milestones array no One entry per farm, each carrying that farm's whole programme calendar — at any distance, and including seasons that have already settled. Never capped, because a truncated calendar cannot be told apart from a farm with no dates.
movers array no The biggest 24-hour TVL moves, largest absolute change first. A market with no flow window is excluded rather than zero-filled: no history is not no movement. Capped, and its default cap is the smallest of the three: read it against counts.movers, which is the total, never as every market with a flow window.
news array no Material events observed in the last 24 hours, newest first. Only event types on the publication allow-list appear; the operator's own notes never do.
markets array no Every live Pendle points market this build found, one object each. It arrives ordered by score_v1, with flagged rows at zero and last; that order can change again, so sort on a published field if you need a ranking. Never truncated — the complete table is the product.
sources array no Collector health: which public collectors ran, what state they finished in and how long ago. This is not markets[].sources, which is campaign provenance. Two different things share the key name; this is the one about our pipeline.
notes array no Sentences this payload publishes about itself — the estimate marker, what a dash means, what markets[]'s order is, and a changelog line naming the last shape change. The changelog line is rewritten each time, never appended to.
counts.markets integer no How many objects are in markets[]. That array is never capped, so this is also its length.
counts.farms integer no How many distinct farm slugs appear among markets[]. It is not how many farms this site tracks: a farm with no live market today still has a page and an entry in milestones[], and is not counted here.
counts.deadlines integer no How many deadlines exist in total, before the cap. Compare with the length of deadlines[] to see whether the list you received was truncated.
counts.movers integer no How many markets have a 24-hour flow window at all, before the cap. It is not how many markets moved: a market whose TVL was flat is counted, and one with no window is not counted and is absent from movers[] entirely. Compare with the length of movers[] to see how much of the list you did not receive.
counts.news integer no How many news items exist in total, before the cap. Compare with the length of news[] to see whether the list was truncated.
counts.milestones integer no How many farms have a programme record at all. Read beside counts.milestones_dated: the gap between them is this dataset's date coverage.
counts.milestones_dated integer no How many of those farms have published at least one date. Two numbers rather than one because how many farms we track and how many have a date are different questions, and the gap is the honest measure of coverage.
deadlines[].farm string no The farm this obligation belongs to, as the engine labels it.
deadlines[].slug string no The farm's URL-safe identifier, stable across builds. The same value markets[] and milestones[] publish under this name.
deadlines[].kind string no Which obligation this is — the campaign's snapshot, its end, or the token generation event.
deadlines[].date string no The date the obligation falls, as recorded.
deadlines[].days_until integer no Calendar days from this build to that date. Negative once the date has passed and the card is still inside the horizon.
deadlines[].estimated boolean no Whether the date on this card is an estimate. false does not mean confirmed or sourced, only that nobody marked the column as an estimate.
milestones[].farm string no The farm this calendar belongs to, as the engine labels it.
milestones[].slug string no The farm's URL-safe identifier. The same value markets[] and deadlines[] publish under this name, which is what joins the three.
milestones[].airdrop_status string no The same fact as markets[].airdrop_status, stated once for the farm instead of once per market, and carrying the same warning: 'none' is the column's default, so it does not distinguish a researched programme from an unresearched one.
milestones[].season_status string yes What the campaign records about its season: announced, active, settled, ended or cancelled. Null when nothing is recorded.
milestones[].season_label string yes The campaign's own name for this season, when it has one. Null otherwise.
milestones[].snapshot_date string yes The date the programme takes its snapshot, when one is on record. Null means no date is held, not that no snapshot will happen.
milestones[].snapshot_estimated boolean no Whether snapshot_date is an estimate. false does not mean confirmed or sourced, only that nobody marked the column as an estimate.
milestones[].snapshot_in_days integer yes Calendar days from this build to snapshot_date. Negative once it has passed; null when there is no date.
milestones[].end_date string yes The date the season ends, when one is on record. Null means no date is held.
milestones[].end_estimated boolean no Whether end_date is an estimate. false does not mean confirmed or sourced, only that nobody marked the column as an estimate.
milestones[].end_in_days integer yes Calendar days from this build to end_date. Negative once it has passed; null when there is no date.
milestones[].tge_date string yes The expected token generation event, when one is on record. Null means no date is held — which for most programmes is the normal state.
milestones[].tge_estimated boolean no Whether tge_date is an estimate. false does not mean confirmed or sourced, only that nobody marked the column as an estimate — see markets[].tge_estimated, which carries the same value and the same caveat.
milestones[].tge_in_days integer yes Calendar days from this build to tge_date. Negative once it has passed; null when there is no date.
milestones[].market_count integer no How many live markets this farm has in this build. Zero is a real value: a settled season with a TGE still ahead has a calendar and no tradable row.
movers[].market string no The market's name. markets[] publishes the same value under the key 'name'.
movers[].farm string no The farm this market belongs to, as the engine labels it.
movers[].slug string no The farm's URL-safe identifier, joining this row to the others.
movers[].tvl_change_pct number no The 24-hour TVL change, as a signed percentage. markets[] publishes the same quantity under the longer key tvl_change_24h_pct. Never null here: a market with no flow window is absent from this array rather than present with a null.
movers[].tvl_from_usd number yes TVL at the start of the flow window. It has no counterpart on markets[]; this array is the only place it is published.
movers[].tvl_to_usd number yes TVL at the end of the flow window. markets[] publishes the same quantity under the key tvl_usd.
movers[].points_per_usd number yes The same field markets[] publishes under this name, repeated here so a mover can be read without a join back into markets[].
news[].at string no When the event was observed, in UTC.
news[].type string no The event type, from a fixed publication allow-list — multiplier and date changes, listings, airdrop status changes and campaign discoveries. Types carrying operator or third-party text are never published.
news[].farm string no The farm the event concerns, as the engine labels it.
news[].slug string no That farm's URL-safe identifier.
news[].headline string no The event in one line, as the engine recorded it.
markets[].market_id string no This market's stable identifier, chain id and address joined. Unique within a build and stable across builds.
markets[].chain string no The chain id the market trades on, as a string.
markets[].name string no The market's name as upstream reports it. movers[] publishes the same value under the key 'market'.
markets[].farm string no The campaign this market farms when one is linked, and the project otherwise. It is the row's grouping key, not a guarantee that a campaign exists.
markets[].slug string no The farm's URL-safe identifier, stable across builds. Joins this row to milestones[], deadlines[] and news[].
markets[].score_v1 number no This row's ranking total out of 100, from the function score_version names. Reproducible from this payload: every input is a field in this same object. A row carrying any danger flag is given 0.0 and keeps its full breakdown, so the zero explains itself.
markets[].score_factors object no The five contributions that make up score_v1, in points. On any row not flagged, they sum to score_v1 — which is a consistency property between two published numbers and nothing more: it cannot tell you a weight is right, only that the breakdown adds up to the total shown.
markets[].segment string no Which question this row is an answer to, by first match: avoid for any row carrying a danger flag, then short-horizon for a snapshot inside the next fortnight, then hold-to-tge for a TGE still ahead, then unclassified. Those four are the whole partition, and unclassified is a real segment rather than a residue — a row lands there mostly because the programme has published no date.
markets[].points_per_usd number yes Points exposure per dollar deployed: this market's points multiplier divided by the share of the underlying that YT costs. It is not a price and cannot be turned into a value per point — that conversion needs three inputs and no programme in this dataset carries all three. Compare it only between markets of the same farm: two programmes' points are different things sharing a name.
markets[].points_basis string no Which of two things this row's points_per_usd is. 'multiplier' — a points multiplier was reported for this market. 'price_only' — none was, so the figure is YT price leverage per dollar rather than a points multiple. 'unpriced' — the market could not be priced and points_per_usd is null. A consumer aggregating points_per_usd across rows must partition on this.
markets[].points_per_usd_range object yes Where today's points_per_usd sits inside this market's recent range, or null. Never an object of nulls: having no range is one claim, not four. A null carries four different claims this payload does not distinguish — too little price history, the multiplier moved inside the window, the figure resolves through a hand-maintained campaign leg whose history is not stored, or no multiplier history was ever recorded. All four mean the same thing for a decision: do not compare this row against its own past.
markets[].exposure_days_per_usd number yes Dollar-days of underlying exposure one dollar of YT carries to maturity. It is break_even_accrual_annual_pct in different units, the two related by 36500 / exposure_days_per_usd wherever there is at least one day to expiry — so agreement between them is not corroboration, it is one measurement read twice. Commensurable only between markets sharing a farm. Null where the market could not be priced or has no days left, which is not where the two percentages are null: inside one day of expiry this field is published and they are not.
markets[].carry_cost_annual_pct number yes The annualised cost of holding YT to maturity, since YT decays to zero. Per dollar of underlying notional, and gross: no strategy relief is netted off it. Null in two different cases wearing one dash — the market could not be priced, or it is inside one day of expiry, where annualising over a denominator floored at one day would understate the figure without bound.
markets[].underlying_yield_annual_pct number yes The rate the underlying asset earns, which a YT holder is entitled to until maturity and of which carry_cost_annual_pct subtracts none. Same basis as the carry — per dollar of underlying notional — so the difference between the two is the net annual cost of the position. Do not subtract it from break_even_accrual_annual_pct instead: that hurdle is already divided by the points multiplier, so taking the raw rate off it overstates the relief by exactly the multiplier. Null means no rate was ever recorded for this market; 0.0 means one was, and it was zero.
markets[].break_even_accrual_annual_pct number yes The yearly rate at which rewards must accrue, on the underlying notional, for this position to break even: the gross carry divided by the points multiplier. A required accrual rate and never a price per point — divide it by a points count and the result has no unit. It and carry_cost_annual_pct are not one number, and you can check that on any row: they differ by exactly the multiplier this payload publishes beside them. The two coincide only where markets[].multiplier is null or 1x; treating them as one number anywhere else overstates the relief by that multiplier. Null wherever the carry is, and for the same two reasons.
markets[].multiplier number yes The campaign's points multiplier for this market. A stored value of 1.0 or below is published as null, because 1.0 is what the engine floors to when nothing reported a multiplier at all — so null here means either not measured or exactly 1x, and this payload cannot tell you which.
markets[].tvl_usd number yes Total value locked in this market, in dollars, as the collector last read it. Null means no reading, never zero. movers[] publishes the same quantity under the key tvl_to_usd.
markets[].tvl_change_24h_pct number yes The 24-hour change in tvl_usd, as a signed percentage. Null means there is no flow window for this market, which is not the same as no movement. movers[] publishes the same quantity under the shorter key tvl_change_pct.
markets[].ends_in_days integer yes Calendar days to the sooner of the campaign season's end and the market's own maturity. It is not days to expiry — derive that from expiry — and it goes negative on a market still trading after its season has ended.
markets[].ends_estimated boolean no Whether the date behind ends_in_days is an estimate. false does not mean confirmed or sourced — it means nobody marked the column as an estimate, which is a different claim and a weaker one.
markets[].expiry string no The market's maturity date: the first ten characters of a timestamp. Parse it as UTC midnight. Reading it in local time shifts it by your own offset, which has been measured moving a figure derived from it from 7.4e-06 to 5.9e-02.
markets[].airdrop_status string no What the campaign records about an airdrop: confirmed, expected, rumored, none or unknown. 'none' does not mean the programme has no airdrop. The column is NOT NULL DEFAULT 'none', so a campaign nobody has researched reads identically to one researched and found to have none — and where the engine has acted on that value you can see it in the same object: the row carries AIRDROP_NONE in flags, and that code is treated as a blocker. 'unknown' is this site's own value for a market with no campaign linked.
markets[].snapshot_date string yes The programme's snapshot date, carried onto the row so it can be sorted against the other columns. The farm-level record is milestones[]; this is the same date. Null means no date is held.
markets[].snapshot_estimated boolean no Whether snapshot_date is an estimate. false does not mean confirmed or sourced, only that nobody marked the column as an estimate.
markets[].snapshot_in_days integer yes Calendar days from this build to snapshot_date. Negative once it has passed; null when there is no date.
markets[].tge_date string yes The expected token generation event, carried onto the row for the same reason snapshot_date is. Null means no date is held.
markets[].tge_estimated boolean no Whether tge_date is an estimate. false does not mean confirmed or sourced, only that nobody marked this column as an estimate. It is one hand-set flag and nothing joins it to the programme's other estimate markers, so a false here can sit beside the same date marked an estimate elsewhere in the same entry — and this field is the one published. Treat a false as the absence of a claim rather than as one.
markets[].tge_in_days integer yes Calendar days from this build to tge_date. Negative once it has passed; null when there is no date.
markets[].flags array no The blocker codes the engine raised for this row. Five of them make a row a trap — the route looks live and does not pay — and any code this publication boundary does not recognise fails open to 'we cannot say' rather than to farmable. An empty list means no blocker was found.
markets[].flags_text array no One sentence per code in flags, in the same order — minus the ones that are withheld. A blocker resting on a campaign field nobody has recorded a source for cannot be told apart from that column's default, so its sentence is not published and this array comes back shorter than flags. An empty list is therefore not an all-clear: a row carrying a blocker with no sentence here is flagged on a field nobody has recorded — not on a finding. Read flags for what fired, and sources[] for what is backed.
markets[].market_url string no Where to open this market on Pendle.
markets[].metrics_as_of string yes When this row's market measurements were last read. The only per-row measurement age in the payload: generated_at says when the board was assembled, which can be two minutes ago over a TVL reading six hours old.
markets[].sources array no Campaign provenance for this row: where each of four campaign facts came from, under the field's published name so it joins to the key you already read. This is not the top-level sources, which is collector health. An empty list means nobody has recorded a source for this row's campaign — the list is complete and simply has no members, which is why it is [] and not null.
markets[].score_factors.exposure number yes The dollar-days-of-exposure contribution, in points. Null where the market could not be priced or has no days left; a non-positive reading contributes 0.0 rather than null, because that value was measured.
markets[].score_factors.certainty number no The airdrop-status contribution, in points. Never null: the field is always present, and an unrecognised value fails open to unknown rather than to farmable.
markets[].score_factors.timing number yes The soonest-deadline contribution, in points — the tightest of the three clocks, because the first deadline that bites is the one that matters. Null only when all three dates are absent.
markets[].score_factors.crowding number yes The dilution-relief contribution, in points: 1.0 of its weight on any outflow, falling to zero at full dilution. Null when no 24-hour flow was measured.
markets[].score_factors.integrity number no The no-blocker contribution, in points: its full weight with no danger code, zero with any. Never null.
markets[].points_per_usd_range.low number no The lowest points_per_usd observed in the window, attained by a real observation rather than a smoothed bound.
markets[].points_per_usd_range.high number no The highest points_per_usd observed in the window, attained by a real observation rather than a smoothed bound.
markets[].points_per_usd_range.position number no Where today sits between low and high: 0.0 at the low, 1.0 at the high. A fraction, not a percentage.
markets[].points_per_usd_range.observations integer no How many readings back the range. Below the engine's minimum the whole object is null, because a high and a low over three readings describe the collector's uptime rather than the market.
markets[].sources[].field string no Which campaign fact this source backs, under its published name — multiplier, snapshot_date, tge_date or airdrop_status — so it joins to the key you already read without a translation table.
markets[].sources[].url string yes Where the observation can be checked. Null where the observation has no fetchable address.
markets[].sources[].observed_at string no When the observation was made. Never null: a source with no date does not answer the question a source exists to answer.
markets[].sources[].note string yes What the observer added, when anything. Null when there was nothing to add.
sources[].name string no Which collector this is. Only the four whose freshness a reader can act on are published; the rest describe the operator's own inputs.
sources[].state string no How that collector's last run finished. Two states mean it completed — it ran and returned data, or it ran and had nothing to report — and an error does not, because a failed run finished at a time without bringing anything back.
sources[].age_minutes integer yes How long before this board was assembled that collector last finished. Frozen at build time, so on a board being re-served after a failed rebuild it is the age it had when it was built; the top-level stale flag is what accounts for that. Null when nothing is on record.