Infoway’s real-time financial news API is live. With the same API key you already use for market data, you can now open a WebSocket and receive a structured stream of global financial news — every item tagged with the tickers it affects, an urgency level, and a cross-source deduplication key, in 11 languages.
This guide is a hands-on walkthrough. By the end you’ll have a production-ready Python client that connects, subscribes, survives disconnects, deduplicates, and routes each headline to the instruments it concerns. If you just want to see a news push land in your terminal, jump to Quickstart.
Everything below — endpoints, protocol codes, field names, and the latency figures — reflects the live production service.
Who this is for
The news feed is built for programs, not readers. It’s the right tool if you’re building:
- Event-driven strategies that need to act within seconds of a headline.
- Portfolio and watchlist alerting — “tell me when something happens to a stock I hold.”
- Per-instrument news panels inside a trading terminal or broker app.
- Cross-market, multi-language monitoring dashboards.
- News-to-price research pipelines that line up a headline against what the market did next.
If all you need is a list of headlines to render on a page, a plain REST “latest news” endpoint is simpler and it’s the better fit. The Infoway feed is aimed at the case where a machine consumes each item and decides what to do with it.
1. How the feed is designed: events, not articles
Most news APIs hand you an article: a title, a blurb, a source name, a link, maybe a thumbnail. That’s everything you need to show the news to a person, and nothing you need to act on it programmatically. To go from that payload to “which of my positions does this affect, and how urgent is it?” you’d build and maintain a named-entity-recognition pipeline, a company-name-to-ticker mapping, a cross-source dedup layer, and a priority model.
Infoway pushes the result of that work instead. Each message is a structured event:
| You get | Field | Why it matters |
|---|---|---|
| Associated tickers | symbols | Route the item to holdings/instruments with a set intersection — no NER to build |
| Urgency level | urgency | Turn the stream into a priority queue; flash breaking items, batch the rest |
| Cross-source dedup key | dk | Reuters and a wire service report the same event — one dk, so you process it once |
| Full body + summary | content, sd | Run your own NLP/LLM on the text, or show the summary directly |
| Machine timestamp | published | Unix seconds, ready for arithmetic and alignment against price bars |
The transport is a WebSocket, so there’s no polling interval sitting between an event happening and your code hearing about it. You subscribe once; the server pushes.
2. Prerequisites
An API key with news access.
The news feed is available on the Premium plan ($199/month) and above, or an equivalent custom plan. It uses the same key and the same authentication as Infoway’s real-time market data — if you’re already streaming prices, you only need to make sure news is enabled on your plan. Get a key from the Infoway dashboard.
One Python dependency:
pip install websocketsYour key in an environment variable — never hard-code it, and never ship it in front-end code, since it travels in the URL:
export INFOWAY_API_KEY="your_key_here"Quickstart
Here is the smallest client that does something useful: connect, subscribe to the English channel, keep the connection alive, and print each headline as it arrives.
import asyncio, json, os, uuid
import websockets
WS_URL = f"wss://data.infoway.io/news?apikey={os.environ['INFOWAY_API_KEY']}"
async def main():
async with websockets.connect(WS_URL) as ws:
# Subscribe to one language channel (code 10020)
await ws.send(json.dumps({
"code": 10020,
"trace": uuid.uuid4().hex,
"data": {"lang": "en"},
}))
async def heartbeat():
while True:
await asyncio.sleep(30)
await ws.send(json.dumps({"code": 10010, "trace": uuid.uuid4().hex}))
asyncio.create_task(heartbeat())
async for raw in ws:
msg = json.loads(raw)
if msg.get("code") == 10022: # news push
n = msg["data"]
syms = ", ".join(n.get("symbols") or []) or "—"
print(f"[{n['urgency']}] {n['provider']:<16} {n['title']}")
print(f" symbols: {syms}")
asyncio.run(main())Run it during market hours and you’ll start seeing lines like:
[2] dow-jones Global Server Shipments to Hit Record 5 Million Units in 3Q
symbols: DELL.US, SMCI.US, HPE.US
[3] reuters Shein seeks $30-$40 billion valuation for August Hong Kong IPO
symbols: —That’s the whole idea in 30 lines. The rest of this guide is about making it robust and putting the structured fields to work.
3. The protocol, step by step
The news feed is a small, purpose-built protocol — five message types, one subscription dimension.
3.1 Connection
wss://data.infoway.io/news?apikey=YOUR_API_KEYThe path is /news (not the /ws path used for market data), and the key goes in the apikey query parameter. On a successful handshake the server sends:
{ "code": 200, "msg": "ws connect success" }One connection per API key. If a connection already exists for your key, the new one is rejected. To consume more than one language at once, use more than one key (see recipe 5.5).
3.2 Subscribe (10020)
Send one subscribe message after the connection opens:
{
"code": 10020,
"trace": "5dd7e89cdcc247d78e817b67269167e0",
"data": { "lang": "en" }
}| Field | Type | Notes |
|---|---|---|
code | Integer | Always 10020 for a news subscription |
trace | String | Any random ID; echoed back so you can match request to response |
data.lang | String | Language channel — required, case-insensitive (normalized to lowercase server-side) |
Supported lang values:
| Code | Language | Code | Language |
|---|---|---|---|
en | English (global) | pt | Portuguese |
zh-Hans | Simplified Chinese | ru | Russian |
zh-Hant | Traditional Chinese | de | German |
ja | Japanese | fr | French |
ko | Korean | es | Spanish |
tr | Turkish |
One subscription per connection. There is no separate “unsubscribe” command — sending 10020 again with a different lang replaces the current subscription.
3.3 Subscribe acknowledgement (10021)
{
"code": 10021,
"trace": "5dd7e89cdcc247d78e817b67269167e0",
"msg": "ok",
"data": { "lang": "en" }
}The trace matches what you sent; data.lang comes back lowercased. After this, news pushes flow.
3.4 News push (10022)
The payload your client exists to handle. Full field reference in section 4.
3.5 Heartbeat (10010)
Send a heartbeat every ~30 seconds:
{ "code": 10010, "trace": "..." }If the server receives no heartbeat for 60 seconds, neither an application 10010 nor a WebSocket-level ping, it closes the connection. A standard WebSocket ping/pong keeps the connection alive too, but sending the explicit 10010 is the simplest thing that always works.
Protocol summary
| Code | Direction | Meaning |
|---|---|---|
200 | server → client | Handshake succeeded |
10020 | client → server | Subscribe to a language channel |
10021 | server → client | Subscription confirmed |
10022 | server → client | News push |
10010 | client ↔ server | Heartbeat — send every 30s |
4. Anatomy of a news push
A real push (body truncated for display):
{
"code": 10022,
"data": {
"dk": "e9ecbfbc6799c39d5ec09c8c771ac0fe",
"country": "CN",
"lang": "zh-Hans",
"route": "lang",
"title": "China bond market: easing liquidity lifts short-end, tech rally caps long bonds",
"published": 1785815048,
"urgency": 2,
"provider": "reuters",
"symbols": ["399006.SZ", "000688.SH"],
"link": "",
"content": "China's bond market was mixed on Tuesday, with short-dated notes firmer as funding conditions loosened ...",
"sd": "China's bond market was mixed on Tuesday, with short-dated notes firmer ..."
}
}| Field | Type | Always present | Description |
|---|---|---|---|
dk | String | Yes | Deduplication key — MD5 of title + body. Same event from a different source → same dk |
title | String | Yes | Headline |
content | String | No | Full article body, plain text |
sd | String | No | Short summary / standfirst |
published | Long | Yes | Publish time, Unix seconds, UTC |
urgency | Integer | Yes | Priority — lower is more urgent |
provider | String | Yes | Source, e.g. reuters, dow-jones, business_wire |
symbols | Array | No | Associated instrument codes, e.g. ["DELL.US", "SMCI.US"] |
country | String | No | Country / region; may be empty for global news |
lang | String | Yes | Language of the item |
route | String | Yes | How it was collected: lang or country |
link | String | No | Original article URL (not always available) |
Three fields worth understanding
symbols — a ready-made news→instrument mapping. The tickers aren’t just the ones that appear verbatim in the text; they include semantically related instruments. A story about an Indian refiner’s earnings came through tagged with RELIANCE, the NIFTY index, and several other oil names — nine symbols in total. That relationship is exactly what you’d otherwise build an entity-linking system to produce.
Note that symbols can arrive in a couple of conventions depending on the market: suffix form like AAPL.US, 005930.KS, 399006.SZ, and exchange-prefixed form like LSE:HSBA or NSE:NIFTY. Normalize to your own convention on ingest.
urgency — a priority signal. Breaking wire flashes carry a lower number than routine analysis pieces. Use it to decide what interrupts a user and what just gets logged.
dk — cross-source idempotency. In our own testing, every push carried a dk (100% coverage). Key your storage and your downstream fan-out on it and duplicate reports of the same event collapse to one.
5. Recipes
Each recipe is a small function you drop into the message handler. They’re independent — take the ones you need.
5.1 Deduplicate across sources
_seen: set[str] = set()
def is_new(news: dict) -> bool:
dk = news.get("dk")
if not dk or dk in _seen:
return False
_seen.add(dk)
return TrueIn a long-running process, bound the set — an LRU or a periodic flush of entries older than a day is plenty, since duplicates arrive close together in time.
5.2 Route news to a portfolio
Intersect symbols with the instruments you care about. No text parsing.
def route_to_holdings(news: dict, holdings: set[str]) -> set[str]:
hits = set(news.get("symbols") or []) & holdings
if hits:
print(f"⚠ position news [urgency {news['urgency']}] {news['title']} → {hits}")
return hits
route_to_holdings(news, {"AAPL.US", "005930.KS", "DELL.US"})5.3 Prioritize with urgency
Feed the stream into a heap so breaking items are worked first:
import heapq
queue: list = []
def enqueue(news: dict) -> None:
# (urgency, published) — lower urgency first, then oldest first
heapq.heappush(queue, (news["urgency"], news["published"], news["dk"], news))
def next_item():
return heapq.heappop(queue)[-1] if queue else None5.4 A per-instrument news feed for a trading UI
If you run a terminal or broker app, the highest-value use is a news strip on every instrument page. symbols gives you the mapping directly, so the whole feature is an in-memory index:
from collections import defaultdict, deque
_by_symbol: dict[str, deque] = defaultdict(lambda: deque(maxlen=50))
def index_news(news: dict) -> None:
item = {
"title": news["title"],
"summary": news.get("sd"),
"published": news["published"],
"urgency": news["urgency"],
"provider": news["provider"],
"link": news.get("link"),
}
for sym in news.get("symbols") or []:
_by_symbol[sym].appendleft(item)
def instrument_news(symbol: str) -> list:
return list(_by_symbol.get(symbol, []))
# When a user opens the DELL.US page:
for n in instrument_news("DELL.US"):
print(f"[{n['urgency']}] {n['title']} — {n['provider']}")Your front end calls instrument_news(symbol) when rendering a page; urgency drives whether a headline is pinned and highlighted. The hard part — deciding which of thousands of listed instruments a story belongs to — is already done.
5.5 Multi-language monitoring
One key allows one connection, and one connection carries one language. To watch several channels at once, run one client per language, each with its own key, and merge on dk:
async def run_all(keys_by_lang: dict[str, str]):
await asyncio.gather(*(
NewsClient(api_key=key, lang=lang).start()
for lang, key in keys_by_lang.items()
))
# run_all({"en": KEY_1, "zh-Hans": KEY_2, "ja": KEY_3})Because dk is content-based, the same event reported in the English and Chinese channels will not share a dk (different text), so treat cross-language dedup as a separate, semantic problem if you need it. Within a single language, dk handles it.
5.6 Join news to price
News and market data share the key, so an event can immediately trigger a price lookup. When a high-urgency item lands for a symbol you follow, pull its latest trade:
import requests
def price_on_news(news: dict, watch: set[str]) -> None:
hits = set(news.get("symbols") or []) & watch
if not hits or news["urgency"] > 2:
return
codes = ",".join(s for s in hits if s.endswith(".US"))
if not codes:
return
r = requests.get(
f"https://data.infoway.io/stock/batch_trade/{codes}",
headers={"apiKey": os.environ["INFOWAY_API_KEY"]}, timeout=10,
).json()
for tick in r.get("data", []):
print(f" {tick['s']} ${tick['p']} ← {news['title']}")For the reaction after a headline — the price a few minutes before versus a few minutes after — use the candlestick endpoint (/stock/v2/batch_kline) and align on the published timestamp.
6. A production-ready client
This is the Quickstart hardened for real use: explicit handshake handling, a heartbeat task bound to the connection, exponential-backoff reconnection, automatic re-subscribe on every new connection, and dedup built in.
import asyncio
import json
import logging
import os
import uuid
from typing import Optional
import websockets
from websockets.exceptions import ConnectionClosed
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("infoway-news")
# --- Protocol codes --------------------------------------------------------
REQ_SUBSCRIBE = 10020
REQ_HEARTBEAT = 10010
ACK_SUBSCRIBE = 10021
PUSH_NEWS = 10022
HANDSHAKE_OK = 200
class NewsClient:
"""Infoway real-time news WebSocket client.
One connection, one language. Handles heartbeat, reconnect with
exponential backoff, re-subscribe on every new connection, and dedup.
"""
def __init__(self, api_key: str, lang: str = "en"):
self.url = f"wss://data.infoway.io/news?apikey={api_key}"
self.lang = lang
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.running = True
self._heartbeat: Optional[asyncio.Task] = None
self._seen: set[str] = set()
@staticmethod
def _trace() -> str:
return uuid.uuid4().hex
async def _send(self, msg: dict) -> None:
if self.ws:
await self.ws.send(json.dumps(msg))
async def _subscribe(self) -> None:
await self._send({"code": REQ_SUBSCRIBE, "trace": self._trace(),
"data": {"lang": self.lang}})
logger.info("Sent subscribe: lang=%s", self.lang)
# --- heartbeat -------------------------------------------------------
def _start_heartbeat(self) -> None:
self._stop_heartbeat()
async def beat():
try:
while True:
await asyncio.sleep(30)
if not self.ws or self.ws.close_code is not None:
break
await self._send({"code": REQ_HEARTBEAT, "trace": self._trace()})
except (ConnectionClosed, asyncio.CancelledError):
pass
self._heartbeat = asyncio.create_task(beat())
def _stop_heartbeat(self) -> None:
if self._heartbeat and not self._heartbeat.done():
self._heartbeat.cancel()
self._heartbeat = None
# --- message handling ----------------------------------------------
def _on_news(self, n: dict) -> None:
dk = n.get("dk")
if dk and dk in self._seen:
return
if dk:
self._seen.add(dk)
symbols = ", ".join(n.get("symbols") or []) or "—"
logger.info("NEWS urgency=%s provider=%s symbols=%s\n %s",
n.get("urgency"), n.get("provider"), symbols, n.get("title"))
# TODO: persist / publish to a queue / trigger strategy logic
def _dispatch(self, raw: str) -> None:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("non-JSON frame: %r", raw)
return
code = msg.get("code")
if code == PUSH_NEWS:
self._on_news(msg.get("data", {}))
elif code == ACK_SUBSCRIBE:
logger.info("Subscription confirmed: lang=%s",
msg.get("data", {}).get("lang"))
elif code == HANDSHAKE_OK:
logger.info("Handshake OK: %s", msg.get("msg"))
else:
logger.debug("unhandled code=%s: %s", code, msg)
# --- connection lifecycle -----------------------------------------
async def _connect_once(self) -> None:
async with websockets.connect(self.url, ping_interval=None) as ws:
self.ws = ws
logger.info("Connected")
await self._subscribe() # MUST re-subscribe on every connection
self._start_heartbeat()
try:
async for message in ws:
self._dispatch(message)
finally:
self._stop_heartbeat()
self.ws = None
async def start(self) -> None:
backoff, backoff_max = 5, 60
while self.running:
try:
await self._connect_once()
backoff = 5
logger.warning("Connection closed by server")
except ConnectionClosed as e:
logger.warning("Connection closed: %s", e)
except Exception as e:
logger.error("Connection error: %s", e)
if not self.running:
break
logger.info("Reconnecting in %ss...", backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, backoff_max)
def stop(self) -> None:
self.running = False
async def main():
api_key = os.environ.get("INFOWAY_API_KEY", "YOUR_API_KEY")
await NewsClient(api_key, lang="en").start()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("stopped")Running it
pip install websockets
export INFOWAY_API_KEY="your_key_here"
python infoway_news.pyYou should see the handshake, the subscription confirmation, and then news pushes:
2026-09-08 13:02:11 INFO Connected
2026-09-08 13:02:11 INFO Sent subscribe: lang=en
2026-09-08 13:02:11 INFO Handshake OK: ws connect success
2026-09-08 13:02:11 INFO Subscription confirmed: lang=en
2026-09-08 13:02:39 INFO NEWS urgency=2 provider=dow-jones symbols=LSE:HSBA
HSBC to Complete Up to $1 Billion Share Buyback7. Operational checklist
The mistakes that cost the most time, and how to avoid them:
- Keep the heartbeat running. Send
10010every ~30s. No traffic for 60s and the server drops you. The client above binds the heartbeat task to the connection’s lifetime so it can’t outlive or lag the socket. - Re-subscribe on every reconnect. The connection has no memory. After any drop-and-reconnect you must send
10020again or no pushes arrive. The client does this at the top of_connect_once. - One key, one connection, one language. A second connection on the same key is rejected. For multiple languages, use multiple keys.
- Always dedup on
dk. Multiple sources report the same event;dkis content-based and cross-source. Make your ingestion idempotent on it. - Treat
publishedas UTC seconds. Convert to the user’s timezone only at display time. Don’t assume it equals your receive time — it’s the source’s stated publish time (see next section). - Normalize
symbols. Expect bothTICKER.SUFFIXandEXCHANGE:TICKERforms; map to your own convention on ingest, and handle items with nosymbolsat all. - Protect the key. It travels in the URL query string. Keep it server-side, in an environment variable or secret manager — never in browser code or a public repo.
8. Error codes
If the handshake or a subscription fails, the server tells you why. The ones you’ll actually encounter:
| Code | Meaning | Fix |
|---|---|---|
200 | Handshake success | — |
506 / 507 | Missing / malformed parameters | Check the 10020 payload shape; data.lang is required |
513 | Heartbeat timeout | Send 10010 every 30s |
514 | Wrong WebSocket path | Use /news, not /ws |
515 | Payload is not valid JSON | Serialize the message properly |
517 | Missing apikey | Add ?apikey=... to the URL |
518 | apikey does not exist | Check the key |
519 | No news permission | Upgrade to Premium or an equivalent custom plan |
520 | This key already has a connection | Close the other connection, or use another key |
521 | Server at capacity | Retry with backoff |
Full, current definitions are in the official docs.
9. Measure the latency yourself
Don’t take anyone’s “real-time” label at face value, including ours. Here’s the method we use, and the numbers we get.
End-to-end latency = the moment your client receives an item minus the item’s own published timestamp:
import time
def latency_seconds(news: dict) -> float:
return time.time() - news["published"] # both UTC secondsLog that for every push over a fixed window and look at the distribution, not the best case.
What we observed on a 10-minute sample of the en channel in production: 13 pushes (~1.3/min), fastest end-to-end ~22 seconds, median ~54 seconds, ~77% of items carrying symbols, and 100% carrying dk.
Two things to keep in mind when you read your own numbers:
publishedis the source’s timestamp, not ours. Wire flashes are near-instant, so their end-to-end latency is dominated by collection and delivery. Long-form analysis pieces often enter the pipeline already minutes old at the source — that lag is inherent to the source and no downstream API removes it.- The streaming model adds no interval of its own. A polling client’s discovery latency is source lag plus up to one poll interval. The WebSocket feed removes that second term — you hear about an event as soon as it’s collected. If you want the lowest latency, subscribe to the busiest channel with the most wire sources, which is usually
en.
10. How to get access
The real-time news feed is included with the Premium plan ($199/month) and above, and with equivalent custom plans. Once news is enabled, the same API key you use for market data works for the news WebSocket — same authentication, same SDK patterns, same operational model. If you already stream prices from Infoway, adding news is one more connection.
- Sign up or upgrade: Infoway API Website
- Full field, language, and error-code reference: API Documentations
FAQ
Is there a REST version of the news feed?
The structured news feed is delivered over WebSocket. That’s deliberate — the value is in receiving events the instant they’re collected, without a polling interval in the path.
Does reconnecting restore my subscription?
No. The connection is stateless. After every connect — including automatic reconnects — you must send the 10020 subscribe message again. The production client above does this in _connect_once.
Can one connection stream multiple languages?
No. One connection carries one language, and one API key allows one connection. Run one client per language, each with its own key, and merge the streams in your application.
How do I stop getting duplicates?
Deduplicate on dk, which is a content hash and matches across sources reporting the same event. In our testing every push carried one. Within a language channel this fully handles duplicates; across languages the text differs, so dk values differ too.
What symbol format does symbols use?
Mostly suffix form (AAPL.US, 005930.KS, 399006.SZ), with some exchange-prefixed entries (LSE:HSBA, NSE:NIFTY). Normalize to your own convention when you ingest, and handle items that carry no symbols.
Why is my measured latency higher than the median you quote?published is the source’s publish time. Breaking wire items are near-instant; long-form pieces can be minutes old at the source before collection. Subscribe to the en channel for the highest throughput and lowest typical latency.
Can I use this to trigger trades?
Yes, that’s a core use case. Filter on low urgency and a symbols match against your universe, then use the shared key to pull live prices or candles for confirmation before acting.