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 getFieldWhy it matters
Associated tickerssymbolsRoute the item to holdings/instruments with a set intersection — no NER to build
Urgency levelurgencyTurn the stream into a priority queue; flash breaking items, batch the rest
Cross-source dedup keydkReuters and a wire service report the same event — one dk, so you process it once
Full body + summarycontent, sdRun your own NLP/LLM on the text, or show the summary directly
Machine timestamppublishedUnix 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 websockets

Your 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_KEY

The 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" }
}
FieldTypeNotes
codeIntegerAlways 10020 for a news subscription
traceStringAny random ID; echoed back so you can match request to response
data.langStringLanguage channel — required, case-insensitive (normalized to lowercase server-side)

Supported lang values:

CodeLanguageCodeLanguage
enEnglish (global)ptPortuguese
zh-HansSimplified ChineseruRussian
zh-HantTraditional ChinesedeGerman
jaJapanesefrFrench
koKoreanesSpanish
trTurkish

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

CodeDirectionMeaning
200server → clientHandshake succeeded
10020client → serverSubscribe to a language channel
10021server → clientSubscription confirmed
10022server → clientNews push
10010client ↔ serverHeartbeat — 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 ..."
    }
}
FieldTypeAlways presentDescription
dkStringYesDeduplication key — MD5 of title + body. Same event from a different source → same dk
titleStringYesHeadline
contentStringNoFull article body, plain text
sdStringNoShort summary / standfirst
publishedLongYesPublish time, Unix seconds, UTC
urgencyIntegerYesPriority — lower is more urgent
providerStringYesSource, e.g. reuters, dow-jones, business_wire
symbolsArrayNoAssociated instrument codes, e.g. ["DELL.US", "SMCI.US"]
countryStringNoCountry / region; may be empty for global news
langStringYesLanguage of the item
routeStringYesHow it was collected: lang or country
linkStringNoOriginal 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 True

In 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 None

5.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.py

You 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 Buyback

7. Operational checklist

The mistakes that cost the most time, and how to avoid them:

  • Keep the heartbeat running. Send 10010 every ~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 10020 again 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; dk is content-based and cross-source. Make your ingestion idempotent on it.
  • Treat published as 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 both TICKER.SUFFIX and EXCHANGE:TICKER forms; map to your own convention on ingest, and handle items with no symbols at 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:

CodeMeaningFix
200Handshake success
506 / 507Missing / malformed parametersCheck the 10020 payload shape; data.lang is required
513Heartbeat timeoutSend 10010 every 30s
514Wrong WebSocket pathUse /news, not /ws
515Payload is not valid JSONSerialize the message properly
517Missing apikeyAdd ?apikey=... to the URL
518apikey does not existCheck the key
519No news permissionUpgrade to Premium or an equivalent custom plan
520This key already has a connectionClose the other connection, or use another key
521Server at capacityRetry 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 seconds

Log 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:

  1. published is 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.
  2. 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.

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.