Skip to Content

News

Scored headlines for Loaf’s listed real assets: property, data centres, refineries, fabs, grids, hotels. No API key required.

News serviceLoaf Backend
RESThttps://news.loafmarkets.com/v1/headlineshttps://api.loafmarkets.com
WebSocketwss://news.loafmarkets.com/v1/ws/headlineswss://api.loafmarkets.com/ws
AuthNoneBearer API key on private routes
Python SDKNot wrapped by LoafClientloaf-python-api-bot-template 

Use requests (or axios) plus a WebSocket client. See Building a trading bot for the seed-then-listen loop.

Headlines live about 7 days. After that, GET /v1/headlines/:id is 404 and a WebSocket last_seen_id replay returns cursor_expired. Re-seed from REST.

How the frontend uses this

Hub, trade, and offerings seed once, then keep a single socket open. Same pattern a bot should use.

  1. GET /v1/headlines to fill the panel.
  2. Open wss://news.loafmarkets.com/v1/ws/headlines, send subscribe, prepend each article frame.

The panel (PropertyNewsItem) reads these fields off each row:

Panel fieldFeed field
id, url, breakingsame names
titletitle_rewritten
publishedAt / datepublished_at
sourcesource_name
summaryexcerpt (fallback summary)
sentimentScoresentiment_score (−1…1, always the story’s own reading; it does not decay with age)
impactScoreimpact_score (0–10)
impactRationaleimpact_rationale
tickerstickersexternal symbols (NVDA, TSM), not Loaf tickers
typefeed_type"property" or "market"
assetsassets — resolved platform badges, only when grading is on

tickers cannot be matched against the platform roster. assets is { ticker, tokenName, name, imageUrl } and is the only field that identifies a listed Loaf asset.

The panel has two tabs. feed_type is the primary tab (higher of property_score / market_score; category breaks a tie). feed_types is the full list: a story that scores hard on both axes appears in both tabs. GET /v1/headlines?feed_type= filters on that array, so a dual story is returned for either tab, once per response.

Ask once vs listen

Same rule as the rest of the API:

  • Ask onceGET /v1/headlines (and /v1/headlines/:id). Photo of the current page.
  • Listen — WebSocket article frames. New and replayed rows only; not a full snapshot.

Do not poll REST in a loop. Seed, then subscribe. Pass last_seen_id so a dropped socket can catch up.

Article object

Every headline row, REST or WebSocket, is this shape (derived fields included):

type FeedType = 'property' | 'market'; type Category = | 'market_news' | 'property_update' | 'policy' | 'economic' | 'geopolitical' | 'sale_transaction' | 'energy' | 'infrastructure' | 'other'; interface Article { id: string; source_id: string; source_name: string; url: string; title_original: string; title_rewritten: string; excerpt?: string; summary?: string; published_at: string; // ISO-8601 suburbs: { name: string; region?: 'central' | 'eastern' | 'north' | 'western' }[]; foreign_locations: string[]; /** External market symbols the enricher attached. Not Loaf tickers. */ tickers: string[]; category: Category; sentiment: 'positive' | 'negative' | 'neutral'; /** Net direction (−1…1): asset leg mixed with market leg. */ sentiment_score?: number; /** Asset-specific direction, before the market is mixed in. */ property_sentiment: number; /** Direction for the asset’s market / class. */ market_sentiment: number; impact_score: number; // 0–10, derived from the two axes property_score: number; // 0–10 market_score: number; // 0–10 asset_types: string[]; impact_rationale: string; tags: string[]; breaking: boolean; feed_type: FeedType; feed_types: FeedType[]; last_updated_at?: string; update_count: number; also_reported_by: string[]; /** Present only when asset grading is on and this story graded to listed assets. */ assets?: { ticker: string; tokenName: string; name: string; imageUrl: string | null }[]; }

property_score is how much the story reprices a tagged asset; market_score is how much it moves the class. A leaky roof is high property / low market. An RBA cut is the reverse. sentiment_score can disagree with property_sentiment on one asset: good asset news into a sold-off market is a real case, and a single net number cannot say it.

Duplicate coverage of the same story collapses onto the first entry (update_count, also_reported_by, last_updated_at). published_at on that entry is when the story broke.

Endpoints

MethodPathDescription
GET/v1/headlinesPaginated feed
GET/v1/headlines/:idSingle article
GET/v1/assetsRoster as this service sees it (grading on)
GET/v1/assets/:token/headlinesThat asset’s news (grading on)
GET/v1/articles/:id/assetsWhich assets one story graded to (grading on)
GET/v1/sentimentImpact-weighted sentiment for a scope + window
GET/v1/pricesLive instrument quotes (price feed on)
GET/health{ status, redis, … } — 503 if Redis is down

Error body:

{ "error": { "code": "INVALID_QUERY", "message": "...", "details": [] } }

GET /v1/assets, /v1/assets/:token/headlines, /v1/articles/:id/assets, and /v1/prices return 503 with ASSET_GRADING_DISABLED or PRICE_FEED_DISABLED when that add-on is off. That is not an empty feed. Do not treat 503 as “no news.”

List routes send Cache-Control: public, max-age=30. Price routes send no-store.


List headlines

GET /v1/headlines

Newest-first page, ranked by decayed impact_score then presented reverse-chronologically. A publisher cap keeps one outlet from filling the page.

Request

import axios from 'axios'; const response = await axios.get('https://news.loafmarkets.com/v1/headlines', { params: { limit: 20, min_impact: 5, feed_type: 'property' }, }); const { headlines, next_cursor } = response.data;

Parameters

ParameterTypeRequiredDescription
feed_typeproperty | marketNoTab filter. Matches feed_types, so a dual story is returned for either value.
categoryCategoryNoOne of the nine category values.
min_impactint 0–10NoDrop rows below this impact_score.
sinceISO-8601NoOnly articles published at or after this timestamp.
suburbstringNoSingle suburb name.
suburbsCSV stringNoSeveral suburbs, comma-separated.
regioncentral | eastern | north | westernNoSydney region.
limitint 1–200NoPage size. Default 50.
cursorstringNoOpaque cursor from the previous next_cursor.

Response

interface HeadlinesResponse { headlines: Article[]; next_cursor: string | null; server_time: string; }

Walk pages with next_cursor until it is null.

Example row:

{ "id": "a-8f3c21", "title_rewritten": "China halts gallium exports", "source_name": "DigiTimes", "url": "https://example.com/gallium", "published_at": "2026-08-25T04:12:00.000Z", "excerpt": "Export controls tighten on a key semiconductor input.", "category": "geopolitical", "sentiment": "negative", "sentiment_score": -0.62, "property_sentiment": -0.7, "market_sentiment": -0.4, "impact_score": 8, "property_score": 4, "market_score": 8, "feed_type": "market", "feed_types": ["market"], "tickers": ["NVDA"], "breaking": true, "assets": [ { "ticker": "YONG", "tokenName": "yongin", "name": "Yongin", "imageUrl": "https://…/banner.webp" } ] }

assets is omitted when grading is off, or when the story graded to nothing on the roster.


Get one article

GET /v1/headlines/:id

Same Article as a list row, including assets when grading is on. 404 NOT_FOUND if the id expired or never existed.

const { data } = await axios.get('https://news.loafmarkets.com/v1/headlines/a-8f3c21');

Per-asset headlines

Only when ASSET_GRADING_ENABLED is on; otherwise 503 ASSET_GRADING_DISABLED.

Each article is scored per listed asset. The same story can be negative for a fab and noise for a hotel. The headline on an asset page is a property of (article × asset), not of the article alone.

GET /v1/assets — roster as this service sees it (token, ticker, name, type, country, profiled, synced_at). First thing to check if an asset feed is empty.

GET /v1/assets/:token/headlines — that asset’s news. :token accepts the Loaf tokenName or the ticker (e.g. yongin or YONG).

Request

const { data } = await axios.get( 'https://news.loafmarkets.com/v1/assets/opera/headlines', { params: { min_property_score: 7, limit: 20 } }, );

Parameters

ParameterTypeRequiredDescription
min_property_scoreint 0–10NoFloor on this asset’s property_score. Default on the service is 6 for what appears on an asset page.
sinceISO-8601NoInclusive lower bound on published_at.
beforeISO-8601NoExclusive upper bound.
limitint 1–200NoDefault 50.
cursorstringNoFrom the previous next_cursor.

404 NOT_FOUND if :token is not on the roster.

Extra asset block on each row

interface AssetHeadlineRow extends Article { asset: { ticker: string; token: string; property_score: number; property_sentiment: number; // this asset’s direction sentiment_score: number; // netted against the article’s market leg relation: 'same_asset' | 'operator' | 'commodity' | 'demand' | 'same_city' | 'same_country' | 'sector'; why: string; headline_for_asset: string; breaking_for_asset: boolean; }; }

relation is verified against the article text; an unsupported claim is downgraded to the strongest relation the text does support.

GET /v1/articles/:id/assets returns the same grades strongest-first, for a detail view. The list already includes resolved assets badges, so you do not need one request per row.


WebSocket

wss://news.loafmarkets.com/v1/ws/headlines

One connection. Send JSON client messages; handle server frames by type. Heartbeat every 30s. Two missed protocol pongs and the server closes with 1011.

Subscribe to headlines

{ "type": "subscribe", "filters": { "min_impact": 5 }, "last_seen_id": "a-8f3c21" }

filters (all optional):

FieldMeaning
suburbsstring[]
regioncentral | eastern | north | western
categoriesstring[] of category values
min_impactint 0–10
tokenLoaf tokenName or ticker. Only articles graded to that asset, delivered after grading, not at ingest. Omit this unless you want a per-asset stream.
min_property_scoreint 0–10, used with token

Tab filtering (feed_type) is REST-only. On the socket, use categories, min_impact, or token.

last_seen_id triggers a replay of what you missed. Replay frames are ordinary article messages (dedupe on id), then replay_complete.

const ws = new WebSocket('wss://news.loafmarkets.com/v1/ws/headlines'); let lastSeenId: string | undefined; ws.onopen = () => { ws.send(JSON.stringify({ type: 'subscribe', filters: { min_impact: 5 }, last_seen_id: lastSeenId, })); }; ws.onmessage = (event) => { const msg = JSON.parse(String(event.data)); if (msg.type === 'article') { lastSeenId = msg.article.id; // handle msg.article } else if (msg.type === 'replay_complete' && (msg.truncated || msg.reason === 'cursor_expired')) { // hole in history — re-seed GET /v1/headlines } }; ws.onclose = () => setTimeout(() => { /* reconnect with lastSeenId */ }, 3000);

Server frames

typeWhen
heartbeatEvery 30s while subscribed (server_time)
subscribedAfter a valid subscribe
article{ article: Article } — live or replay. Per-asset subscribers also get the asset block.
replay_completeEnd of backfill. count, optional truncated, optional reason: cursor_expired | error
error{ code, message }
sentiment_subscribed / sentiment_updateAfter subscribe_sentiment
prices_subscribed / price_updateAfter subscribe_prices

If replay_complete.truncated is true, you hit the 200-article replay ceiling. Re-seed from REST rather than assuming you are caught up. reason: "cursor_expired" means last_seen_id aged out of the 7-day window.

Optional: sentiment and prices on the same socket

{ "type": "subscribe_sentiment", "scopes": [{ "type": "global" }] }
{ "type": "subscribe_prices", "instrument_ids": ["BRENT", "US10Y", "TSLA"] }

instrument_ids are price-feed ids (BRENT, US10Y), not article.tickers and not Loaf tickers. If the price feed is off, the server replies error / PRICE_FEED_DISABLED instead of acking. A successful subscribe_prices is immediately followed by a snapshot price_update (the feed only pushes on change).

REST equivalents: GET /v1/sentiment?scope=global&window=24h (window: 1h | 6h | 24h | 7d; suburb/region need name) and GET /v1/prices?ids=BRENT,US10Y. Prices 503 PRICE_FEED_DISABLED or PRICES_UNAVAILABLE when the feed is off or still cold.

These are extras. A bot that wants news into a strategy only needs headlines REST + subscribe.

Last updated on