One WebSocket carries the changes to the items a key is shown — each announcement with its flash rows and
its story (a report's is its report story), each pre-earnings analysis — as frames, each item at its latest state, each frame
with the cursor to resume from. An
item's frames can arrive out of order: apply each by its seq (order,
below). Every message is one JSON text frame. Ignore fields, frame types, item kinds and part kinds this version
does not name. The reads that go with it, and the rest of the API, are in the API
reference; the same changes sent to a server of yours, in the webhooks
guide.
can_websocket; with can_access_detail it is sent every flash
row, and with can_pending the processing rows (Connect).GET /v1/feed with the selection as JSON in
selection: its frames are the copy, and its through and access_version say
what it was read at (start).wss://api.quanor.com/v1/stream/feed with Authorization: Bearer <API key>
and send subscribe from the lowest through of those pages,
with the same selections.order says, and keep the cursor of the
last frame or through received (cursor).pong to each ping, and do as catch_up,
resync, access_changed and refetch say; a ready naming
another access_version than the pages' says the same as access_changed
(Reading the feed).The reference client does each of these, and the API's tests run it.
The words the reference uses, as an API key's client meets them.
| feed item | One thing in the feed, held at its latest state and named by feed_item_id: for an API key, an announcement (its flash rows, then its story; a report's is its report story) or a pre-earnings analysis. |
| part | One part of an announcement: a flash row (flash) or its story (story; a report's is its report story), named by part_id. Of two copies of a part, the higher part_version is the later. |
| flash row | A headline and its detail from Quanor's first reading of an announcement, written within seconds of it; an announcement can have several. |
| part sent ahead | A flash row sent alone, as a part frame (provisional), the moment it is written: before the item frame that holds it. |
| feed position | A change's place in the feed (seq). Every position is accounted for (its change sent, not for the listener, or replaced by a later change of its item), so a client that holds every one up to N holds every change up to N. Positions compare within one epoch, and an item's within the access version it was rendered under: a change of access replaces the copy, not the cursor (reading.access_changed). |
| epoch | The numbering positions are in: a feed cursor's first number. A restored database starts a new one, and the stream says resync. |
| feed cursor | <epoch>.<seq>: a watermark, every change at or below seq accounted for. What a client resumes from: a bound on positions, not a frame's own position, though it can equal one. |
| through | The feed cursor a page or a catch-up read accounts for; and the stream's message that says the same of what it has sent. |
| selection | What one mirror asks the feed for: companies, markets, item kinds, part kinds, teasers. It takes an item that passes each field it names (company.company_id among companies, company.market_exchange_id among markets, item_kind among item_kinds, an announcement with a part of one of part_kinds; a teaser has no parts), and a teaser (access: teaser) only when it asks for teasers. |
| mirror | One selection on a socket, under a mirror id the client chooses. Each frame names the mirrors it is for (m). |
| listener | One open subscription to the feed stream (a socket, from its subscribe on), with its own access, cursor and counter (n). |
| window | One selection's items as GET /v1/feed reads them: page by page from the newest arrival, each page as of its through. |
| page cursor | next_cursor: where the next, older page of a window starts. Opaque: pass it back unchanged, never build one. |
| copy | The items a client holds, read from the windows, then kept current by the frames: a copy for each selection, or one for several (reading.hidden). |
| access version | The access a key's frames and pages are rendered under (access_version). When it changes, what was read under the older one is read again (access_changed). |
| access level | What a key is sent of an item: api with can_access_detail, api_list without it, api_teaser as a teaser. The examples name theirs (item.announcement.api_list.en). |
| teaser | An item of a company outside the key's allowed_company_ids, sent to a selection that asks for teasers: that it exists, its company, kind and time (access: teaser). |
| announcement head | What an announcement shows around its parts (head): its title, kind, route, inside-information mark, editions and times. |
| feed head | The highest feed position written, which Quanor's builder announces to the stream every second; a client is not sent it. Not an announcement's head. |
| edition | An announcement as one provider and one distributor delivered it, in one or more languages: the document readers see. head.edition_ids lists an announcement's editions; a processing row names one. |
| processing row | An announcement Quanor is reading before its first flash row, sent to a key with can_pending (processing, processing_resolved). |
| builder | The Quanor process that writes the feed's changes. The stream says when it falls silent and when it speaks again (status). |
| the stream's memory | The latest changes the stream holds, from which it answers a subscribe's cursor (a resume). A cursor below it is told catch_up. |
wss://api.quanor.com/v1/stream/feed, with Authorization: Bearer <API key> on the upgrade, from a server: a browser cannot set that header on a WebSocket. The same endpoint answers at wss://api.quanor.com/api/v1/stream/feed, for good: a client connected there changes nothing. An HTTP answer to the upgrade (503 while the stream restarts or is full, another status while no stream answers) or none at all: retry with backoff. The stream reads the key with the first subscribe: a socket that
presents none is closed 4401 at once, and a key it refuses is closed 4401 or 4403 once that subscribe comes.
A key needs can_websocket to stream. With can_access_detail it is sent every flash
row with its detail (the api level), whatever its daily allowance of detail reads
(max_analyses_per_day), which counts what /v1/analyses serves and never the feed; without
it, each announcement's first flash headline and how many rows there are (api_list). With
can_pending it is sent the processing rows of its companies' announcements. Its companies
(allowed_company_ids; none listed: every company) are sent in full. A selection with
"teasers": true is also sent the other companies' items as teasers: that an item exists, its company,
kind and time (A selection). A key may hold 5 sockets at once.
start | Read each selection's first page (GET /v1/feed, a read for each selection), and older pages as wanted (next_cursor); then subscribe, with those selections, from the lowest through of the reads the copy was made from. Each page holds its items as of its own through, so every change past the lowest reaches the client, and one a later read already held changes nothing (order). Two feed cursors order by epoch, then by seq, each as an integer: 1.9 is before 1.10, and 1.10 before 2.0. A client that keeps no history subscribes with since null: from the present. |
order | Apply an item frame only when its seq is higher than the position the copy holds the item at, or last removed it at (hidden, withdrawn): the stream sends a position as soon as it is accounted for, so an item's frames can arrive out of order. Apply a part frame (provisional) only when its part_version is higher than the part held: of two copies of a part the higher part_version is the later, so an item frame keeps a part held at a later version than its own. A part frame has no seq, being at no position of the feed, and carries cursor as every frame the stream sends does (cursor). A part frame of an item not held makes the item from the frame's head, unless the item was removed. A part the stream sent ahead (a part frame) stays in its item until an item frame holds it at its part_version or later, or names it in superseded_parts: an item frame built before the part was written arrives after it and says nothing of it, so the part is kept beside the parts that frame holds. Drop the parts an item names in superseded_parts, and a part frame whose part_id the item held names there. A part no item frame has accounted for within limits.ahead_kept_s of hearing a live builder is dropped: one its item's builds never included (while the socket is down, before a ready and while status is silent, its item frame is late, not missing). |
cursor | Keep the cursor of the last frame (or through) received — never a selection_ack's, a bound to read to — and subscribe again from it after any reconnect. |
resume | What comes between a ready and its through happened while the client was away (the stream's memory answering its cursor); what follows is live. A ready is followed by its through (exchanges.resume), or by catch_up or resync in its place (stream.ready): do as that frame says. |
catch_up | Read POST /v1/feed/since with the cursor held, the selections subscribed with and the filters the windows were read with in its body ({"since": cursor, "selections": selections, ...filters}), page by page until complete, applying each frame; then subscribe from the last through. A change written quietly (an item older than a day made again with nothing changed at its source — a new rendering, a company's new name — which changes no one's access) is not among them: a read of GET /v1/feed carries it. A correction or an update of what an item shows is, however old the item. |
resync | Read GET /v1/feed again for each selection, replace the copy, and subscribe from the lowest through of those reads (start). |
access_changed | Everything read under an older access_version is superseded: read the windows again, replacing the copy and the removals it remembered (a seq is a position of its own access's rendering); drop processing rows. Keep the socket and the cursor held: the stream goes on under the new access, and is not subscribed to again. A ready naming another access_version than the copy was read under (its pages' access_version) says the same of a change while the client was away, or since its read: do the same, as an item that did not change since is not sent again. A window or a since page naming another access_version than the copy's was read across a change, as were windows that name different ones: do the same. |
refetch | Read the item (GET /v1/feed/items/{feed_item_id}) and apply its frame by seq, before keeping a cursor sent after the refetch: its change is accounted for, and a resume from a later cursor does not send it again. A refetch names no mirror: the frame is for each selection that takes the item (glossary: selection). A 404 under the access_version the copy was read under says the key is no longer shown the item: drop it. A 404 naming another access_version says the key's access changed: do as access_changed says. |
status | The builder is silent (stream.status): changes are late, none is lost. Nothing to do. |
processing | With can_pending, ready.processing holds the rows pending at connect; processing frames add one and processing_resolved drops it, whatever its resolution; a row nothing resolves is resolved expired after limits.processing_row_s. A resolution can be lost on its way, so a client also drops a row it has held longer than limits.processing_row_s. A row's item is the one whose head.edition_ids holds its edition_id. |
unknown | Ignore fields, frame types, item kinds and part kinds this version does not name. Read a value it does not name, of a field that names a kind, a state, a reason or a code, as unknown, never as an error; on a close code it does not name, reconnect with backoff. |
What a client meets, in order: each message the client sends (from client) or the stream sends (from stream), with the fields that say the order; n, access_version, server_time and the frames' content are left out.
resumeA subscribe from a cursor the stream still remembers, the feed's head at 1.1046: its ready, the changes above the cursor its mirrors take, then through, which closes the resume; what follows is live.
{"from": "client", "type": "subscribe", "since": "1.1044"}
{"from": "stream", "type": "ready", "cursor": "1.1044"}
{"from": "stream", "type": "item", "seq": 1045, "cursor": "1.1045"}
{"from": "stream", "type": "item", "seq": 1046, "cursor": "1.1046"}
{"from": "stream", "type": "through", "cursor": "1.1046"}at_the_headA subscribe from the head: nothing above it to send, and its through all the same.
{"from": "client", "type": "subscribe", "since": "1.1046"}
{"from": "stream", "type": "ready", "cursor": "1.1046"}
{"from": "stream", "type": "through", "cursor": "1.1046"}
To a key with can_access_detail the stream sends a flash row as soon as it is written, alone, as
a part frame, ahead of the item frame that then holds it among its item's rows (the
item's snapshot). A client that applies item frames alone still keeps a correct copy, and has each
flash row later, with its snapshot. To have rows as they are written it applies part frames too, as
order above says: by part_id, the higher
part_version the later, the part kept in its item until an item frame holds it at that version or
later, or names it in superseded_parts.
How a client applies the filters to the items it holds. GET /v1/feed and POST /v1/feed/since take them by these names; the stream never applies them. A client that read its windows with them holds each item to them as frames of it come — live, on a resume, from a catch-up or a refetch: one that fails a filter is kept in the copy and not shown, never applied as hidden, and its next frame — an item frame, or a part sent ahead of one — decides again. Only a hidden or a withdrawn frame the client is sent removes an item (a catch-up read with the filters sends one that no longer passes them as hidden). Each filter reads the item as the client holds it: its latest item frame's fields, less the parts that frame names in superseded_parts, with the parts sent ahead of it (reading.order). Items of another item_kind pass relevance_floor, story_treatment and reports exclude, and fail reports only, inside_information and announcement_kind. A teaser (access: teaser) shows none of what they read and fails each one set (reports include is none). The reference client's passes() is this table, held by the API's tests to the cases the feed's own filters are held to.
relevance_floor | Above 1, the highest relevance of the item's parts reaches it — its flash rows (every one, or with the list, flash_count present, the first written) and its story — or its story is a report story (its report set), which passes any floor, as /v1/analyses keeps every report as material. A relevance of null is none, and an announcement with none known fails until a frame brings one. |
announcement_kind | head.announcement_kind is one of those listed, or null (not known yet). |
story_treatment | The story part's treatment is one of those listed, or there is no story part (none written yet), or the story is a report story (its report set), which is shown as its report, never as a Story or a Brief. Both full and brief is no filter. |
reports | include (the default) is no filter; only: head.route is report; exclude: head.route is not report (null, not known yet, passes). |
inside_information | An announcement that is not a report: head.inside_information is true; false and null (not known yet) fail, and a later frame decides again. A report (head.route is report) passes, and follows reports alone, because a report's own flag is unreliable: with reports include this keeps inside information and every report, with exclude inside information that is no report. |
subscribeFirst after the socket opens, and again after catch_up or resync: from since (a feed cursor, its position at most 9223372036854775807; null for the present), the mirrors' selections, the served language. Answered by ready; or in its place by catch_up or resync, when the stream cannot send the changes since the cursor; or by a close 1012 (positions_unknown), when it cannot answer the cursor yet. Send lang en or sv, the values the schema names: the stream serves any other in English rather than refusing it (GET /v1/feed refuses one, 422). A socket keeps the language of its first subscribe, the one its access was resolved in: a later subscribe with another lang is refused (4400), and one without lang is read as en. Send v, the protocol's version, 1, as the schema requires: the stream reads a subscribe without it as version 1, and refuses one naming a version it does not speak (4400, unsupported_version).
{
"type": "subscribe",
"v": 1,
"since": "1.1044",
"lang": "sv",
"selections": {
"feed": {
"companies": [
28,
706
],
"teasers": true
},
"home": {
"item_kinds": [
"announcement"
]
}
}
}selectionA mirror's selection set, changed or (null) removed on the same socket; answered by selection_ack in order. Changes for the mirror are sent from its ack on: a client that read the mirror's window before the ack reads, after the ack, the changes from that read's through to at least the ack's cursor (POST /v1/feed/since, that mirror alone), so none is missed.
{
"type": "selection",
"mirror_id": "company:28",
"selection": {
"companies": [
28
]
}
}pingAnswered by pong with the same t, a JSON number (any other t is refused, 4400), after the frames queued before it: a client that takes any message as the socket alive never drops a socket that is delivering a burst.
{
"type": "ping",
"t": 1791439215123
}pongThe answer to the stream's ping.
{
"type": "pong",
"t": 1791439230456
}A socket holds up to 8 selections, each under a mirror id the client chooses; every frame names in m the mirrors it is for, and a client keeps a copy for each selection or one for several. A selection says what one mirror asks the feed for. Every field may be left out, and a field this version does not know is refused (4400 on the stream, 422 on /v1), never ignored.
companies | Only these companies (with watchlist, these and the reader's watchlist); leave both out for every company. |
watchlist | Also the app reader's watchlist. A key has none: its selection with watchlist and no companies selects nothing. |
markets | Only companies on these markets (market_exchange_id). |
item_kinds | Leave out for every kind but overview_audio and insider_transaction. An API key is sent announcements and pre-earnings analyses alone. |
part_kinds | Only announcements with one of these parts. An API key may name flash. |
teasers | Also send the items of companies outside the key's allowed_company_ids, as teasers. |
readyAfter subscribe: the cursor the frames that follow continue, the listener's access version, the stream's clock, the processing rows it holds, each with m, the mirrors it is for, as a frame carries (processing_complete false until the stream holds every row pending: a client keeps its own rows), and from here on the changes its mirrors take. After ready come the changes above its cursor that the stream's memory holds and its mirrors take — none from the head, and from the present at most a change held above a gap — then through: what comes between a ready and its through is the stream's memory answering the cursor (a resume); what follows is live. In place of that through comes catch_up, when the stream's memory let go of changes the resume was still owed, or resync, when the database was restored meanwhile. v is the protocol version the stream speaks on this socket; lang the language served, the one its access was resolved in (en for a language the feed is not written in, whatever the subscribe named); ping_interval_ms how often the stream pings it (stream.ping), which a client's own liveness deadline is read from.
{
"type": "ready",
"v": 1,
"cursor": "1.1044",
"access_version": "b4d934cf",
"lang": "sv",
"ping_interval_ms": 30000,
"server_time": "2026-10-08T06:00:00.120000Z",
"processing": [],
"processing_complete": true,
"n": 1
}selection_ackThe mirror's selection is in force from here on: what is sent after the ack is the new selection's. cursor is the highest position the stream had heard as it acknowledged, at or above the cursor its frames carry: a change heard above a gap is sent at once, under the selections then in force, so a read of that mirror alone (POST /v1/feed/since), issued after the ack from the mirror window's through, reads at least to it (client.selection). Absent only while the stream has heard no head, before any subscribe is answered.
{
"type": "selection_ack",
"mirror_id": "company:28",
"cursor": "1.1046",
"n": 2
}throughEvery change at or below the cursor is accounted for. Sent after a ready, once the changes the stream's memory holds above its cursor are sent (or there were none), closing the resume, unless catch_up or resync ends the resume first; and to a listener that has had no frame for 30 s while the cursor moved.
{
"type": "through",
"cursor": "1.1050",
"n": 3
}catch_upThe stream cannot send the changes since the cursor (below its memory, or another epoch it still knows): read them (POST /v1/feed/since) up to through and beyond, then subscribe again from the read's own through. A client holding no selection reads nothing and subscribes from through, whatever its epoch. No change is sent until then (status and access_changed still are).
{
"type": "catch_up",
"through": "1.1050",
"n": 2
}resyncThe cursor cannot be answered at all (a restored database; or a cursor beyond every position the stream has heard, still so after its next head): read the windows again and subscribe from their cursor. No change is sent until then (status and access_changed still are).
{
"type": "resync",
"n": 2
}access_changedThe listener's access changed: frames and pages read under an older access version are superseded; read the windows again. Its processing rows are superseded too: drop them here, and the processing frames that follow are the rows held that the new access is sent (none for a level sent no processing rows). A ready still queued when the access changed is sent with no processing rows.
{
"type": "access_changed",
"access_version": "0ae8787e",
"n": 7
}statusThe builder — the Quanor process that writes the feed's changes — is silent (nothing heard from it for 5 s), or live again when it next speaks. Sent to every listener, whatever it is waiting for, each time the builder falls silent or speaks again. Every ready resets a client to live, so a ready sent while the builder is silent is followed by status silent.
{
"type": "status",
"builder": "silent",
"n": 8
}refetchAn item changed but its frame was too large to send: read it.
{
"type": "refetch",
"feed_item_id": "announcement:88001",
"n": 9
}reconnectThe stream closes this connection 1012 next and suggests waiting reconnect_after_ms first, as the close's reason says too: sent last before every 1012 that suggests a wait (the stream draining its sockets, a connection's lifetime, a change of access that starts or stops flash rows or processing rows), when the listener's queue has room for it. Some WebSocket stacks (Safari's, and every browser's on iOS) report a 1012 as 1005 with no reason, so a client reads the wait here; one that does not know the frame ignores it, and the close's reason is unchanged.
{
"type": "reconnect",
"reconnect_after_ms": 1500,
"n": 12
}pingEvery 30 s. A listener that sends nothing for 75 s, not even the pong, is closed 4408 (idle) and dropped. t is the stream's clock as it queued the ping, in ms since the epoch: a sample of that clock which is late, never early, by the frames queued before it (a client can estimate the stream's clock from it, as from ready's server_time).
{
"type": "ping",
"t": 1791439230456
}pong{
"type": "pong",
"t": 1791439215123
}
What the stream adds to every frame it sends one listener; a /v1 read carries m alone, and only on a catch-up, which, asked with include_event_ids, names beside it the event a webhook endpoint of the key is sent for the frame (event_type, event_id).
n | This connection's counter, contiguous: an integer of up to 64 bits. |
cursor | The feed cursor after this frame: every position at or below it is accounted for once the frame is sent. A watermark, not the frame's position: it can be below, at or above the frame's seq. Keep the last one received and resume from it. |
m | The mirrors (selections) the frame is for. |
access_version | The listener's access when the frame was sent. |
event_type | /v1/feed/since alone, asked with include_event_ids: the type of the event a webhook endpoint of the key is sent for the frame (/v1/event-types). |
event_id | /v1/feed/since alone, asked with include_event_ids: that event's id; read in the endpoint's lang and without page filters, the id the endpoint is pushed for the change (a refetch it is pushed is an event of its own). |
itemA whole-item snapshot: replaces the item held when its seq is higher. An announcement carries head and parts, a pre-earnings analysis pre_earnings. An item kind this contract does not name is ignored.
partA flash row ahead of its item's next snapshot (a provisional part): upsert it by part_id and part_version. It has no seq, being at no position of the feed; the stream adds its cursor as to every frame.
hiddenNo longer shown to this listener (its access, the company, the selection) or, in a catch-up read with filters, no longer passing them: drop the item.
withdrawnRetracted: merged into, or replaced by, replaced_by (null: gone, replaced by an item this listener may not see, or replaced by one not in the feed yet). A listener who may no longer see the item's company is sent hidden at the withdrawal's seq instead.
processingAn edition the flash lane is working on (with can_pending): its item is the one whose head.edition_ids holds edition_id.
processing_resolvedA processing row settled: drop it.
flashOne flash row: Quanor's first reading of the announcement. The list level (api_list) carries the first row's headline and relevance alone.
storyThe announcement's story: read it on /v1/analyses/STORY/{story_id}, a report story — one whose report is set — on /v1/analyses/REPORT/{story_id}. In the app's teasers it carries its headline, short lead and kicker alone; an API key's teaser carries no parts.
What a pre-earnings analysis (item_kind pre_earnings_analysis) shows of itself: read it whole on /v1/analyses/PRE_EARNINGS/{analysis_id}. A teaser shows none of it.
Each without the fields the stream adds for its listener (n, cursor, m,
access_version: Frames, above).
{
"access": "full",
"changes": [
"story_published"
],
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"corrected_at": "2026-10-08T06:40:00.300000Z",
"feed_item_id": "announcement:88001",
"first_seq": 1040,
"flags": {
"corrected": true
},
"head": {
"announcement_id": 88001,
"announcement_kind": "order",
"edition_ids": [
5001,
5002
],
"first_flash_at": "2026-10-08T06:00:00.341000Z",
"inside_information": true,
"language": "sv",
"received_at": "2026-10-08T06:00:00.300000Z",
"report": null,
"route": "press_release",
"title": "Alfa wins order"
},
"item_kind": "announcement",
"lang": "en",
"op": "changed",
"parts": [
{
"corrects": [],
"created_at": "2026-10-08T06:00:00.351000Z",
"edition_id": 5001,
"flash_id": 11,
"flash_type": "main",
"headline": "Alfa wins SEK 500M order",
"kind": "flash",
"part_id": "flash:11",
"part_version": 1791439200351000,
"relevance": 4,
"sentiment": 4,
"sentiment_confidence": 4
},
{
"corrects": [],
"created_at": "2026-10-08T06:00:00.352000Z",
"edition_id": 5001,
"flash_id": 12,
"flash_type": "context",
"headline": "Deliveries start in Q1 2027",
"kind": "flash",
"part_id": "flash:12",
"part_version": 1791439200352000,
"relevance": 4,
"sentiment": 4,
"sentiment_confidence": 4
},
{
"corrected": true,
"first_published_at": "2026-10-08T06:00:12.300000Z",
"headline": "Alfa wins its largest order",
"inside_information": true,
"kicker": null,
"kind": "story",
"lang": "en",
"lede_first_sentence": "Alfa Group has won an order worth SEK 500 million from Northern Data Centres, its largest to date.",
"part_id": "story:7101",
"part_version": 2,
"reason": "correction",
"relevance": 4,
"report": null,
"story_id": 7101,
"treatment": "full",
"url": "https://app.quanor.com/articles/story/7101",
"version": 2,
"version_published_at": "2026-10-08T06:40:00.300000Z"
}
],
"published_at": "2026-10-08T06:00:00.000000Z",
"seq": 1045,
"sort_at": "2026-10-08T06:00:00.300000Z",
"superseded_parts": [
"flash:9"
],
"type": "item"
}{
"access": "full",
"changes": [
"story_published"
],
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"corrected_at": "2026-10-08T06:40:00.300000Z",
"feed_item_id": "announcement:88001",
"first_seq": 1040,
"flags": {
"corrected": true
},
"flash_count": 2,
"head": {
"announcement_id": 88001,
"announcement_kind": "order",
"edition_ids": [
5001,
5002
],
"first_flash_at": "2026-10-08T06:00:00.341000Z",
"inside_information": true,
"language": "sv",
"received_at": "2026-10-08T06:00:00.300000Z",
"report": null,
"route": "press_release",
"title": "Alfa wins order"
},
"item_kind": "announcement",
"lang": "en",
"op": "changed",
"parts": [
{
"created_at": "2026-10-08T06:00:00.351000Z",
"edition_id": 5001,
"flash_id": 11,
"headline": "Alfa wins SEK 500M order",
"kind": "flash",
"part_id": "flash:11",
"part_version": 1791439200351000,
"relevance": 4
},
{
"corrected": true,
"first_published_at": "2026-10-08T06:00:12.300000Z",
"headline": "Alfa wins its largest order",
"inside_information": true,
"kicker": null,
"kind": "story",
"lang": "en",
"lede_first_sentence": "Alfa Group has won an order worth SEK 500 million from Northern Data Centres, its largest to date.",
"part_id": "story:7101",
"part_version": 2,
"reason": "correction",
"relevance": 4,
"report": null,
"story_id": 7101,
"treatment": "full",
"url": "https://app.quanor.com/articles/story/7101",
"version": 2,
"version_published_at": "2026-10-08T06:40:00.300000Z"
}
],
"published_at": "2026-10-08T06:00:00.000000Z",
"seq": 1045,
"sort_at": "2026-10-08T06:00:00.300000Z",
"superseded_parts": [
"flash:9"
],
"type": "item"
}{
"access": "teaser",
"changes": [],
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"feed_item_id": "announcement:88001",
"first_seq": 1040,
"item_kind": "announcement",
"lang": "en",
"op": "changed",
"published_at": "2026-10-08T06:00:00.000000Z",
"seq": 1045,
"sort_at": "2026-10-08T06:00:00.300000Z",
"type": "item"
}{
"access": "full",
"changes": [],
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"corrected_at": "2026-10-08T06:30:00.300000Z",
"feed_item_id": "announcement:88001",
"first_seq": 1046,
"flags": {
"corrected": true
},
"head": {
"announcement_id": 88001,
"announcement_kind": "order",
"edition_ids": [
5001,
5002
],
"first_flash_at": "2026-10-08T06:00:00.341000Z",
"inside_information": null,
"language": "sv",
"received_at": "2026-10-08T06:00:00.300000Z",
"report": {
"annual": false,
"document_read": true,
"period": "2026Q3",
"period_end": "2026-09-30"
},
"route": "report",
"title": "Alfa wins order"
},
"item_kind": "announcement",
"lang": "en",
"op": "new",
"parts": [
{
"corrects": [],
"created_at": "2026-10-08T06:00:00.351000Z",
"edition_id": 5001,
"flash_id": 11,
"flash_type": "main",
"headline": "Alfa's Q3 revenue SEK 1.2bn, up 14%",
"kind": "flash",
"part_id": "flash:11",
"part_version": 1791439200351000,
"relevance": 4,
"sentiment": 4,
"sentiment_confidence": 4
},
{
"corrected": false,
"first_published_at": "2026-10-08T06:03:00.300000Z",
"headline": "Alfa: strong Q3",
"inside_information": null,
"kicker": "Report · Q3 2026",
"kind": "story",
"lang": "en",
"lede_first_sentence": "Revenue grew 14% on data-centre orders.",
"part_id": "story:7102",
"part_version": 1,
"reason": "initial",
"relevance": 3,
"report": {
"annual": false,
"document_read": true,
"period": "2026Q3",
"period_end": "2026-09-30"
},
"story_id": 7102,
"treatment": "full",
"url": "https://app.quanor.com/articles/story/7102",
"version": 1,
"version_published_at": "2026-10-08T06:03:00.300000Z"
}
],
"published_at": "2026-10-08T06:00:00.000000Z",
"seq": 1046,
"sort_at": "2026-10-08T06:00:00.300000Z",
"superseded_parts": [
"flash:9"
],
"type": "item"
}{
"access": "full",
"changes": [],
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"feed_item_id": "pre_earnings_analysis:28:Q3-2026",
"first_seq": 1046,
"item_kind": "pre_earnings_analysis",
"lang": "en",
"op": "new",
"parts": [],
"pre_earnings": {
"analysis_id": 5150,
"report_period": "Q3 2026",
"title": "What to watch in Alfa's Q3"
},
"published_at": "2026-10-08T06:00:00.300000Z",
"seq": 1046,
"sort_at": "2026-10-08T06:00:00.300000Z",
"type": "item"
}{
"feed_item_id": "announcement:88001",
"seq": 1048,
"type": "hidden"
}{
"feed_item_id": "edition:5001",
"replaced_by": "announcement:88001",
"seq": 1049,
"type": "withdrawn"
}{
"company": {
"company_id": 28,
"listed_exchange": "STO",
"logo_url": null,
"market_exchange_id": 1,
"market_exchange_mic": "XSTO",
"name": "Alfa Group AB",
"short_name": "Alfa Group",
"symbol": "ALFA B"
},
"feed_item_id": "announcement:88001",
"head": {
"announcement_id": 88001,
"announcement_kind": null,
"edition_ids": [
5001
],
"first_flash_at": "2026-10-08T06:00:00.351000Z",
"inside_information": null,
"language": "sv",
"received_at": "2026-10-08T06:00:00.300000Z",
"report": null,
"route": "press_release",
"title": "Alfa wins order"
},
"item_kind": "announcement",
"lang": "en",
"part": {
"corrects": [],
"created_at": "2026-10-08T06:00:00.351000Z",
"edition_id": 5001,
"flash_id": 11,
"flash_type": "main",
"headline": "Alfa wins SEK 500M order",
"kind": "flash",
"part_id": "flash:11",
"part_version": 1791439200351000,
"relevance": 4,
"sentiment": 4,
"sentiment_confidence": 4
},
"provisional": true,
"published_at": "2026-10-08T06:00:00.000000Z",
"sort_at": "2026-10-08T06:00:00.300000Z",
"type": "part"
}{
"company": {
"company_id": 28,
"listed_exchange": "STO",
"market_exchange_id": 1,
"name": "Alfa Group AB",
"symbol": "ALFA B"
},
"dispatched_at": "2026-10-08T06:00:00.120000Z",
"edition_id": 5001,
"expected_relevance": 4.2,
"language": "sv",
"published_at": "2026-10-08T06:00:00.000000Z",
"route": "press_release",
"tier": "HIGH",
"title": "Alfa får order",
"type": "processing"
}{
"edition_id": 5001,
"resolution": "flashed",
"type": "processing_resolved"
}
What an API key's client does on each close. One that should stop never reconnects with the same key: every attempt is refused again. On a close code this table does not name: reconnect with backoff.
1012 | reconnect after the wait the reason suggests (reconnect_after_ms=<n>, also sent just before the close as a reconnect frame) or the backoff's next wait, whichever is longer, and subscribe from the cursor held |
4008 | reconnect at once and subscribe from the cursor held; take frames faster (read the socket on its own task) |
4400 | a message the stream will not read (the reason says which; unsupported_version: speak a version the stream does): stop and fix it — reconnecting sends it again. The one reason that names no message is no_subscribe (no subscribe within limits.subscribe_within_s of the socket opening): reconnect with backoff, and send subscribe as the socket opens |
4401 | no key was presented, or it is unknown, revoked or expired: stop and check the key |
4403 | the key was revoked, rotated or expired while connected, or it may not stream (can_websocket): stop and check the key |
4408 | reconnect with backoff and subscribe from the cursor held; answer the stream's ping within limits.idle_after_s (read the socket on its own task) |
4429 | too many sockets for this key, or messages sent too fast: reconnect with backoff, and hold fewer sockets |
4503 | the stream cannot confirm the key's access now: reconnect with backoff |
The bounds a listener meets. A company or market id above id_max is refused, as is a selection naming too many. A socket that sends no subscribe within subscribe_within_s of opening is closed 4400 (no_subscribe). A message over message_bytes is refused (4400, too_large). A listener that does not take its frames is closed 4008: when a frame queued for it has waited send_deadline_s unwritten (send_deadline), or when more than queue_bytes are queued for it (queue_full). No item frame the stream sends is over frame_bytes, before the fields it adds (per_listener_fields). A processing row nothing has resolved processing_row_s after it was first sent is resolved expired (processing_resolved). A client drops a part sent ahead that no item frame accounts for within ahead_kept_s of hearing a live builder (reading.order): the stream holds no part sent ahead. What a socket sends is counted in four budgets, each refilled per second up to its burst — bytes, commands (subscribe and selection), the ids their selections name (each company and market id, the first subscribe's included) and other messages (ping, pong, any the stream ignores, and each of WebSocket's own ping and pong frames, as a client library's keepalive sends them) — and a socket past any is closed 4429 (rate): a client reconnects with backoff.
subscribe_within_s | 5 |
ping_every_s | 30 |
idle_after_s | 75 |
send_deadline_s | 10 |
queue_bytes | 4194304 |
lifetime | under 24 h: the stream ends a connection with 1012 and a reconnect hint before then |
lifetime_under_s | 86400 |
reconnect_after_ms_max | 5000 |
mirrors_per_socket | 8 |
mirror_id_bytes | 128 |
companies_per_selection | 2000 |
markets_per_selection | 64 |
id_max | 2147483647 |
message_bytes | 221632 |
frame_bytes | 800000 |
lang_bytes | 16 |
inbound_bytes_per_s | 65536 |
inbound_bytes_burst | 886528 |
commands_per_s | 2 |
commands_burst | 16 |
selection_ids_per_s | 2064 |
selection_ids_burst | 33024 |
other_messages_per_s | 4 |
other_messages_burst | 64 |
processing_row_s | 120 |
ahead_kept_s | 600 |
sockets_per_key | at most the number a key may hold at once — the reference page's Connect section says how many; one more is closed 4429 |
history | a key with a history window (history_days) is sent nothing of an item that sorts before it — live, on a resume or as a part — as /v1 reads nothing of it |
frame_too_large | an item whose frame is too large to send is announced by refetch: read it (GET /v1/feed/items/{feed_item_id}) |
POST /v1/feed/since answers a catch_up with the frames the stream would have
sent, at the same detail, for the selections subscribed with, sent in its body as the subscribe sent them
({"since": "<cursor>", "selections": {…}}: every selection the stream admits fits) —
narrowed, given the filters the windows were read with beside them, as those windows were: an item that no
longer passes them is sent as hidden. A change written quietly — an item older than a day made
again with nothing changed at its source (a new rendering, a company's new name), which changes no one's
access — is not among them, and a read of GET /v1/feed carries it; a correction or an update of
what an item shows is, however old the item. GET /v1/feed/items/{feed_item_id}
answers a refetch; GET /v1/feed reads a window. Their parameters and answers are in
the API reference.
Each holds within one epoch (a feed cursor's first number) and one access version (access_version): resync and access_changed begin them again.
The copy converges. A client that applies the frames as "Reading the feed" says, and does what each control frame asks, holds every item at the latest state it is entitled to: once it holds a cursor, each item changed at or below it is held at its latest change at or below the cursor, or a later one, or is removed (hidden, withdrawn). Read with filters, what it shows is what a window read with them would list ("Filtering live frames"). Two things reach it only otherwise: a change written quietly (an item older than a day made again with nothing changed at its source, "Catching up and reading one item"), with its next window read or the item's next change; and a flash row sent ahead, below. An item the API or the stream cannot read when it reads it — a catch-up, the builder's replay to the stream — is left out of that read, and reaches the client with its next change or a window read.
Intermediate changes can be coalesced. The feed does not promise every state an item passed through. A catch-up (POST /v1/feed/since) sends each item changed past the cursor at its latest state — hidden or withdrawn where that is what it is now — not each state it passed through, and a later page may send it again; a resume from the stream's memory leaves out a change of an item that a later removal of it took back. A frame's changes is a hint, which neither a catch-up's frames nor those the builder replays to the stream carry.
Positions. Every position at or below the cursor a frame or a through carries is accounted for once that message is sent: sent to this listener, not for it, or replaced by a later change of its item that is sent (above). A selection_ack's cursor is not such a place: it names the highest position the stream had heard, which may lie above one not yet sent — never resume from it. A subscribe from the cursor held is sent the changes above it that its selections take — from the stream's memory, then live — or is told catch_up or resync in their place. Frames are not sent in the order of their positions, across items or within one: apply them by seq. n numbers a connection's messages but ping and pong, one by one with no gap.
Flash rows sent ahead are at no position. A part frame is not held in the stream's memory, not sent again on a resume and not read by a catch-up. A client that was away, or whose socket dropped, when a row was sent ahead has it in its item's next item frame that holds it — live, from the stream's memory or from a catch-up — which is late while the builder is silent (status). A row no item frame accounts for within limits.ahead_kept_s of a live builder is dropped (reading.order). Processing rows are at no position either: a ready carries the rows the stream holds pending then, every one once it says processing_complete.
When the copy is read again.
catch_up: the cursor is below the stream's memory, or of an earlier epoch. Read the changes since (reading.catch_up).resync, and a catch-up answered resync: true: the cursor cannot be answered as changes at all — a restored database (a later epoch), or a cursor beyond every position the stream has heard, still so after its next head. A listener already live is told resync when the stream finds the database restored. Read the windows again.access_changed, or a ready naming another access_version than the copy was read under: what was read under the older access is superseded. Read the windows again.1012 with the reason positions_unknown: the stream could not answer the cursor yet. Reconnect, and subscribe from the cursor held.Applied once. A frame can be sent more than once — a reconnect resumes from the cursor held, a catch-up page can repeat a frame of an item past its cut, and a catch-up overlaps what the stream sent — so applying it is what happens once:
item, hidden or withdrawn frame by its item and seq: applied only above the position the copy holds the item at, or last removed it at, but in the one case below;part_id and part_version: applied only above the version held;edition_id.Two frames of one item share a seq in one case: an item that leaves one of a client's selections for another is sent hidden for the one and the item for the other, at the same seq. A client keeping one copy for several selections applies the item at the seq a hidden removed it at when the item names a selection the hidden did not. A seq is where one access's rendering of the item last changed: positions compare within one access version and one epoch alone, so a copy read under another is replaced, never compared.
Not promised: that every state an item passed through is sent; that a frame is sent once; that frames arrive in the order of their positions; that a part reaches a listener not connected when it was sent; a size for any message but an item frame. No item frame the stream sends is over limits.frame_bytes before the fields it adds (per_listener_fields); a change too large for it is announced by refetch.
Every time but the stream's ping.t is written in UTC, to the microsecond, with Z (2026-10-08T06:00:12.300000Z), whatever it is really known to: a time given to the second has a zero fraction. Times from different clocks — a provider's and Quanor's — differ by the clocks' offset as well as by the time between the events they mark.
| Time | Marks | Clock | Known to |
|---|---|---|---|
sort_at (item, part) | An announcement: when its first delivery reached Quanor (for an announcement stored before Quanor kept deliveries, when its document was stored). A pre-earnings analysis: when its first analysis of the period was written. The feed's history order (newest first, then first_seq); it does not move. | Quanor's: the process that received the delivery; its database's, as the row was written, for an analysis, an announcement split from another or opened again, or a document stored before Quanor kept deliveries | Microseconds |
published_at (item, part) | An announcement: when the provider says it was published; null when it said nothing. A pre-earnings analysis: when its latest analysis was written, moving with each new one. | The provider's (the issuer's or distributor's); Quanor's for an analysis | An announcement's: seconds, as providers give it (the fraction is zero). An analysis's: microseconds |
corrected_at (item) | The latest correction among the parts the key is shown: a flash row correcting another (with every flash row), or a correction of the story. Null when there is none; it goes back, or to null, when a rerun replaces the correcting part. | Quanor's: its database's, as the row was written | Microseconds |
head.received_at | When the earliest of the announcement's documents reached Quanor, taking for each the arrival of its text, else of its headline (a provider may send a headline before its text). It moves earlier when a document received earlier joins the announcement. | Quanor's: the process that received the delivery; its database's, as the row was written, for a document stored before Quanor kept deliveries | Microseconds |
head.first_flash_at | When the announcement's first flash row was written, one since replaced included. Null before any. | Quanor's: its database's, as the row was written | Microseconds |
a flash part's created_at | When the flash row was written. Its part_version is the microseconds of its latest change: its writing, else its latest edit. | Quanor's: its database's, as the row was written | Microseconds |
a story part's first_published_at | When the story's first version was published. | Quanor's: its database's, as the row was written | Microseconds |
a story part's version_published_at | When the story's current version was published: first_published_at for its first, later for each correction, update or rerun. | Quanor's: its database's, as the row was written | Microseconds |
a processing row's dispatched_at | When Quanor sent the announcement's document to be read for its flash rows. | Quanor's: the process that sent it | Microseconds |
a processing row's published_at | When the provider says the document was published; null when it said nothing. | The provider's (the issuer's or distributor's) | Seconds, as providers give it |
ready.server_time | When the stream answered the subscribe. | Quanor's: the stream's | Microseconds |
the stream's ping.t | When the stream queued the ping: late, never early, by the frames queued before it. | Quanor's: the stream's | Milliseconds since 1970-01-01 UTC, a JSON number |
The stream's pings. The stream pings every limits.ping_every_s — ready.ping_interval_ms says the interval it keeps — and closes a listener that sends it nothing for limits.idle_after_s, not even a pong, with 4408 idle, at best effort: the close frame may not arrive. Answer each ping with a pong carrying its t.
A client's own ping. A socket can stop delivering without closing — a connection a network lost, a proxy that holds it open. Ping the stream with a deadline: send a ping with a t of your own, and take the socket as dead when nothing at all comes within the deadline. The pong comes after the frames queued before it, so any message counts. The reference client pings every 15 s with a deadline of 20 s, twice limits.send_deadline_s: the stream writes a queued message within it or closes the socket 4008, and may be writing a batch when the ping comes. Time the client spends not reading the socket — a catch-up over HTTP — is its own silence, not the stream's: hold no deadline through it, and keep pinging, or the stream takes the client for idle.
Silence. Whether it pings or not, a client that hears nothing — no frame, no ping — for max(3 × the ping interval, 2 × the ping interval + limits.send_deadline_s) takes the socket as dead: 90 s at the stream's 30 s ping and 10 s send deadline. The ping interval is ready.ping_interval_ms, or limits.ping_every_s before a ready and from a stream that does not say one.
A socket taken as dead is closed, and the client reconnects after a jittered wait and subscribes from the cursor it holds: the stream's memory, or a catch_up, sends what it missed.
Backing off. Every failure to connect or to stay connected is retried after a wait drawn at random up to a ceiling that doubles with each failure, to a cap — the reference client's from 1 s to 60 s — and the ceiling starts again only once a connection has stayed up (the reference client: 30 s after its ready), so a failure that always follows a ready waits longer each time. The failures:
1006);503 while the stream restarts or is full; 404, 410 or another status while no stream answers the path. Never stop on an HTTP status alone: the stream refuses a key by a close (4401, 4403), never by a status;1012 after the wait it suggests or the backoff's next wait, whichever is longer (the suggestion is its reason, or the reconnect frame just before it, at most limits.reconnect_after_ms_max; a word for a reason suggests none); 4008 at once, reading the socket faster (the reference client reconnects at once only after a connection that stayed up, and otherwise backs off); 4408 idle, 4429, 4503, a 4400 no_subscribe, 1000, 1001 and any code this reference does not name, with backoff;429, 5xx, a status in a shape that is not the API's (a load balancer's page) — or was not answered; where the answer carries Retry-After, wait at least that long, up to the backoff's cap.A key stops only on what would be refused again: a close 4401 or 4403; a 4400 with any reason but no_subscribe (unsupported_version among them: a subscribe of a version the stream does not speak); an HTTP read answered 401 or 403 in the API's error shape (its code, where it has one, unauthenticated or forbidden), or 413 or 422 in its error shape, a request the API will refuse again.
What version 1 is. The files of the stream's reference whose names carry v1 (the protocol feed_stream.v1.json, the frames feed_frame.v1.json and their JSON Schemas); every path under https://api.quanor.com/v1 (and the same paths under /api/v1); a subscribe's "v": 1; the /v1/webhooks deliveries' bodies (their v), until that route is removed; a webhook endpoint's messages (webhook_message.v1.json, its JSON Schema and their v); and each event type's JSON Schemas (served at https://api.quanor.com/api/docs/events and listed by /v1/event-types), versioned type by type.
Additive: made within version 1 at any time, and listed in the changelog:
A client written for version 1 ignores a field, a type or a kind it does not know; reads a value it does not know as unknown, never as an error; reconnects with backoff on a close code it does not know; and relies on no request being refused.
Event types are versioned one by one. Each type has a schema version (its version in /v1/event-types, the v<n> of its schemas' files). Within a type's version, a new optional field in its data, a new value of an open field and a new type in a family an endpoint names by its wildcard are additive; removing or retyping a field of its data, or changing what one means, makes the type's next version. An endpoint is sent each type at the version it holds (event_type_versions: the version current when the type was named) until you move it — by a writable event_type_versions on PATCH /v1/webhook-endpoints/{webhook_endpoint_id}, built with the first type that has a second version — and both versions are sent for at least 90 days from the next version's announcement. payload, track, state, reason and every other value that names a kind are open: read one you do not know as unknown.
Breaking: made only with notice, or in a new version. Each of these can break a client that relies on what the API does today:
limits);through or status is sent);Content is not the contract. Which announcements, items and parts there are, what an analysis says, which of the values the reference names a field takes, and how fast a change reaches a listener follow the data and how Quanor analyses it, and change without notice.
Notice. A breaking change is announced to the holders of the keys in use, and in the changelog, with the date it takes effect and, where the old and the new can be served side by side, an overlap, stated in the announcement, during which both work. Over HTTP, what is to be removed is answered with Deprecation and Sunset headers from the announcement until it is removed. The stream has no headers after its upgrade: its notice is the announcement and its changelog entry, dated before the change takes effect.
Named exceptions. A change needed for security, for a legal or regulatory reason — inside information, who may see a company (its audience) — or by the terms of a data licence takes effect at once. It is listed in the changelog when it is made, and announced after.
Generated clients. The stream's JSON Schemas list the values version 1 names and admit others, and admit a frame or message type they do not name. A client generated from an earlier copy is not updated by that: generate from the served schemas, and keep the generated code reading a value it does not know as unknown. The OpenAPI document lists two values the API sends as closed enums — a webhook delivery's event and analysis_type — which a client generated from it reads the same way: a value it does not list is unknown. A webhook endpoint's events are an open union: each type's event, and UnknownEvent for a type the document does not list, with no discriminator to close it, so a client generated from it reads a type added later as an UnknownEvent, which it skips, and never refuses the message.
What changed in version 1 from the feed's release on 2026-10-12, newest first, by the UTC date it took effect, and whether it was additive, breaking or a named exception (Versioning and changes). Fixes that made the API keep what its reference already said are listed from 2026-10-20 on.
https://api.quanor.com/v1/openapi.json too, under the API's base, and the documentation at https://docs.quanor.com, made from the same sources as these references. The files a program reads — the stream's beneath https://api.quanor.com/api/docs/stream, the event catalog at https://api.quanor.com/api/docs/events and beneath it — may be read from any origin, without credentials, and the documentation's API reference may call the API from a browser with the key a reader gives it.wss://api.quanor.com/v1/stream/feed, under the API's base. wss://api.quanor.com/api/v1/stream/feed is the same endpoint and stays, for good: a client connected there changes nothing.https://api.quanor.com/v1: the OpenAPI document names its one server, https://api.quanor.com, to which each of its /v1/… paths is joined, and an event's source is under https://api.quanor.com/v1 again (…/v1/feed, …/v1/webhook-endpoints). At once and without notice: from this day's earlier change until this one, the server was https://api.quanor.com/api and source was under https://api.quanor.com/api/v1, and no customer's endpoint was sent an event under it. The same paths answer under /api/v1, as they always have./v1 (the feed stream's socket is unchanged); the deprecated /v1/webhooks names its successor in full in its Link header, https://api.quanor.com/v1/webhook-endpoints; and a redirect for a path's trailing slash keeps https.REPORT on /api/v1/analyses is a report story: its analysis_id is the story's id (a report analysis's id names nothing), its detail a story's with detail.report, and the old REPORT detail keys and the list items' year and quarter are gone. /api/v1/companies/{id}/estimates answers per report story (story_id, period, figures), and so does /api/v1/demo-company's estimates sample, whose analyses sample may list a REPORT. /api/v1/webhooks delivers a report story as story.published, story.corrected or story.updated with analysis_type REPORT; the REPORT analysis.created event is gone. The feed, and so an announcement.updated event, sends no report_analysis part: a report's closing piece is its report story, a story part, and a selection naming the report_analysis part kind is refused (422 on /api/v1/feed, /api/v1/feed/since and a webhook endpoint's create or change) and selects nothing on the stream. announcement.updated stays at version 1: its data is the feed's item as it stands, changed with this notice as the feed is. A watched condition's status (detail.story.visual.trigger_refs[]) is the outcome the story's announcement gave it — met, not_met or undecided — and, for a condition no evaluation of the announcement answered, the playbook's — met, not_met, open or retired — where it was the playbook's live status. has_report_analyses keeps its name and means: has a report story.kicker ("Report · Q3 2026", a report story's in place of Story or Brief) and report (its report's period); a REPORT on /api/v1/analyses and a StoryEvent of /api/v1/webhooks carry report too; a story's version may be a completion, the report story completed from its report document; and a STORY's and a REPORT's detail carries takeaways.https://api.quanor.com/api, to which each of its paths is joined, and an event's source is under https://api.quanor.com/api/v1, the base every other URL of the API uses. At once and without notice: from the release of webhook endpoints until this change, source named the /v1 alias api.quanor.com also answers, and no customer's endpoint was sent an event under it./api/v1/webhook-endpoints, replace the feed webhooks (/api/v1/feed/webhooks, 2026-10-22) at once and without notice: no feed webhook existed. An endpoint has one filter (event_types, a kind's types by its wildcard such as announcement.*, each held at the schema version event_type_versions names; companies, markets, teasers, part_kinds) and reports each of its tracks (state, pause_reason, generation, cursor, lag, pending_reset_reason, failing_since, next_attempt_at, max_events). A message is {v, webhook_endpoint_id, track, generation, since, through, complete, access_version, events}, each event a CloudEvent 1.0.2 whose dataschema names the JSON Schema of its data. A feed item kind's types are <kind>.updated (an upsert), <kind>.withdrawn and <kind>.hidden; every endpoint also receives webhook_endpoint.reset and webhook_endpoint.tested. Every webhook-id is msg_ and 32 hex digits, and a message is at most 256 KiB unless one item alone is larger. Commands are POSTs to sub-paths: /pause, /resume, /tracks/{track}/rewind (from since or since_at), /secrets/rotate and /secrets/{webhook_endpoint_secret_id}/retire. Every operation has an explicit operationId, and a response carrying a secret is Cache-Control: no-store. A URL that breaks a rule is a 422 whose errors[].type names it (url_not_https, url_port, url_credentials, url_fragment, url_ip_address, url_host, url_private_host, url_unresolved, url_length, url_control_characters, url_invalid); a host is stored in punycode. Writes are paced per key: ten creates, rotations, changes of what is sent, resumes, rewinds and test sends a minute, and thirty other writes (429 commands_used_up, with Retry-After). The new codes are duplicate_webhook_endpoint (with webhook_endpoint_id), unknown_companies and event_type_not_sent (422, naming their field in errors), too_many_webhook_endpoints, reset_pending and before_reset; they were duplicate_subscription, too_many_subscriptions, resync_owed and before_resync. The feed webhooks' names become the endpoints': feed_webhook_id is webhook_endpoint_id, feed_webhook_secret_id is webhook_endpoint_secret_id, max_items is max_events, resync_owed is a track's pending_reset_reason, a notice's notice and owed_at are its type and created_at and its outcome repeated is duplicate, the pause reason holder is requested, a feed.resync message is a webhook_endpoint.reset event and its reason changed is endpoint_changed, an attempt's frames are its events, the User-Agent Quanor-Feed-Webhooks/1 is Quanor-Webhooks/1, and the contract feed_webhook.v1.json is webhook_message.v1.json. An attempt names its track, its events and their event_ids, and notices are paged. A key's notice address is emailed at most thirty notices a UTC day (capped past that), and a notice never carries anything you wrote. The event catalog is served: /api/v1/event-types lists every type with its schemas, payload, entitlements and whether your key can receive it, and /api/docs/events serves each type's schemas and example. /api/v1/feed/since takes include_event_ids, off unless sent: with it, each item in its frames names the event a webhook endpoint of your key receives for it (event_type, event_id); without it, a catch-up answers as before. /api/v1/webhooks is deprecated for /api/v1/webhook-endpoints: its responses carry Deprecation, Sunset (2026-11-09, the day its routes are removed) and a Link to their successor, and it is removed on 2026-11-09: a breaking change, which takes effect that day.limits.ahead_kept_s, ten minutes, as it said in words), and says what was left to a guess: a client keeps its socket and cursor after access_changed, answers a refetch before keeping a cursor sent after it, waits the longer of a 1012's suggestion and its backoff, and the stream reads a key with the first subscribe. A hidden can come again for a selection its item already left, as it always could: a client keeping one copy for several selections remembers which hold each item, as reading.hidden now says. A frame's cursor can be below, at or above its seq, as the stream always sent it; the reference said it was never the frame's own. The served feed_stream.v1.json and feed_frame.v1.json hold what an API key meets, not the app's close codes and renderings.limits.processing_row_s, as the stream's reference now says. The reference client does so, waits out a read's Retry-After up to its backoff's cap, and reads its windows again on a refetch's 404 under another access_version.GET /api/v1/feed/webhooks/{feed_webhook_id}/notices lists what the key holder was owed word of about a subscription — its receiver failing for a day, the subscription paused — and how each ended, with whether the key has a notice address: each is mailed to that address, which Quanor sets on the key.GET /api/v1/feed/items/{feed_item_id} carries access_version, the key's access the read was made under, as a page does: under the access a copy was read with, the item is not one the key is shown and is removed; under another, read the feed again./api/v1/feed/webhooks creates, lists, changes, pauses, resumes, rewinds and deletes a subscription that sends the key's feed as Standard Webhooks POSTs; rotates and retires its signing secrets; sends it a test; and lists its attempts. Each message is published in the OpenAPI document's webhooks and in feed_webhook.v1.json. An error carries the new codes conflict (409) and its own — duplicate_subscription, too_many_subscriptions, rotation_in_progress, last_secret, resync_owed, before_resync, cursor_ahead, cursor_not_in_epoch — commands_used_up (429) and secrets_unavailable (503).ready carries v, the protocol version the stream speaks; lang, the language it serves the socket in (en or sv); and ping_interval_ms, how often it pings.selection_ack carries cursor, the highest position the stream had heard as it acknowledged — at or above the cursor its frames carry, since a change heard above a gap is sent at once under the selections then in force: a POST /api/v1/feed/since read of the changed mirror alone, issued after the ack from that mirror window's through, reads at least to it. It is a bound to read to, never a cursor to resume from.v the stream does not speak is closed 4400 with the reason unsupported_version.limits.idle_after_s is closed 4408 with the reason idle, at best effort; it was dropped with no close frame, which a client saw as 1006. Reconnect with backoff and subscribe from the cursor held.code, a word that names it: unauthenticated (401), forbidden (403), not_found (404), method_not_allowed (405), too_large (413), invalid_request (422), allowance_used_up (429: none left today, wait for Retry-After), fewer_left_than_asked (429: some left, ask for at most RateLimit-Remaining, no Retry-After), webhook_unreachable (502, a webhook's test delivery), unavailable (503), and the feed's feed_unavailable or query_timeout (503). An error the load balancer answers carries none: read the status when there is no code, and a code you do not know by its status.GET /api/v1/feed, POST /api/v1/feed/since, GET /api/v1/feed/items/{feed_item_id}) that cannot be answered in time, or while the feed is unavailable, is answered 503 with Retry-After (seconds): retry after it, with backoff.Server-Timing (app, and db where measured).Accept-Encoding accepts it.unknown_frame, unknown_message); a frame of a type they name is still held to its schema.url must be https, and its host must resolve only to public addresses: a POST or PATCH /api/v1/webhooks naming another is refused 422, and every delivery and test delivery checks its destination again as it connects, so one that has come to resolve elsewhere fails that attempt.data null, as GET /api/v1/analyses/{analysis_type}/{analysis_id} refuses it the detail; it was sent the detail.502 webhook_unreachable) says why in fixed words: its detail ends in timeout, connection_failed, tls_error, dns_failed or refused_address. It said the client library's own error, which could quote the URL, and the 502 is now in the API document.X-Quanor-Signature may carry several v1= values, comma-separated: accept the delivery if any matches. One is sent today; a receiver that compares the whole header to one value breaks the day a second is sent, when a secret is replaced.withdrawn frame names replaced_by only to a listener who may see what replaced the item, and a listener who may no longer see the item's company is sent hidden at the withdrawal's seq in its place. The replacement was named to everyone the item had been shown to.announcement_kind (GET /api/v1/feed, POST /api/v1/feed/since, and the stream reference's rule for live frames) keeps announcements alone: while any kind is chosen, a pre-earnings analysis, or an item of any other kind, no longer passes. It passed before. Made within version 1 without notice./api/v1/stream/flash) and the stories stream (/api/v1/stream/stories) were removed: an upgrade at either is answered 410, naming the feed stream (wss://api.quanor.com/api/v1/stream/feed), which replaced both, with the feed's reads (GET /api/v1/feed, POST /api/v1/feed/since, GET /api/v1/feed/items/{feed_item_id}). Made at once, with no overlap.part frame the moment it is written, ahead of the item frame that holds it; processing and processing_resolved frames carry what the flash stream's pending and pending_resolved did; a reconnect frame says the wait of the 1012 close that follows it./api/v1/, fields took the feed's words (ids end in _id, times in _at); every time is written in UTC to the microsecond with Z; an error is {"detail": …}; a query parameter an endpoint does not take is refused (422); and a webhook delivery is signed over its timestamp and body. Made at once, with no overlap.Python 3.11+, httpx and websockets: the API's tests run this file against the API
and a stream that refuses, drains, goes silent and closes it, and replay against it the exchanges a client meets
— a catch-up over pages, a resync, a change of access, a drain, an idle close, rows sent ahead through a silent
builder — with every message either side sends held to the JSON Schemas.
"""Quanor's feed for an API key — the reference client.
Keeps a correct, current copy of the feed for one or more selections: reads
each selection's window from ``GET /v1/feed``, listens on the feed stream
from the cursor it read, and answers every message the stream sends as the
protocol says (https://api.quanor.com/api/docs/stream): a catch-up from
``POST /v1/feed/since`` with the selections it subscribed with, a resync,
a change of access, a refetch of one item, processing rows. It holds each
item at its latest position, a removal's included, so a frame that arrives
after a later one of its item changes nothing; a flash row the stream sent
ahead stays until an item frame holds it or lists it as superseded, since one
built before the row was written arrives after it; and a ``ready`` under another
access than its copy was read under (the key's access changed while it was
away) reads its windows again. Given filters (``GET /v1/feed``'s, by its
names), it reads its windows and catch-ups with them, keeps every item the
stream sends and shows those that pass them (``passes``, ``shown``): what a
window read now would list. An item that does not pass stays in its copy,
since its next flash row may make it pass; only a ``hidden`` or ``withdrawn``
the stream sent removes one, or a refetch the API answers 404 under the
access the copy was read under (under another, it reads its windows again).
A flash row sent ahead ages only while the copy hears the stream live and the
builder is not silent: while either is not so, its item's frame is late, not
missing. A processing row no resolution reaches goes after
``PROCESSING_ROW_S``.
``on_change(copy, frame)`` hears each frame that changed what the copy shows,
and ``{"type": "copy_replaced", "cause": ...}`` — the client's own, never the
stream's — when the windows were read and the copy replaced by them: at the
start (``start``), on a ``resync`` (``resync``), and under another access
(``access``). Redraw from the copy then.
It keeps its socket alive (the reference's "Liveness and back-off"): it
pings the stream every ``PING_EVERY_S`` and takes the socket as dead when
nothing at all comes within ``PONG_DEADLINE_S`` of a ping, or nothing for
``silent_after`` the stream's ping interval (``ready.ping_interval_ms``), and
then reconnects from the cursor held. Every failure to connect or to stay
connected — a refused connection, any HTTP answer to the upgrade (503 while
the stream restarts or is full, 404 while none answers the path), a close such as 4408
``idle`` — is retried with exponential backoff and full jitter, which starts
again from its first wait only once a connection has stayed up
``STEADY_S``. A key that is refused (4401, 4403) or a message the stream will
not read (4400) stops it: reconnecting would be refused again — but for a
4400 whose reason is ``no_subscribe``, which names no message: no subscribe
reached the stream within its wait from the socket opening, a moment's
trouble. So does a read the API refuses as the key's (401 or 403 in its
error shape) or as a request it will not take (413, 422); any other failed
read — a load balancer's page among them — is retried, no sooner than its
``Retry-After`` asks, up to the backoff's longest wait. A restart's 1012
suggests a wait, in its reason and in a ``reconnect`` frame just before it
(the frame for a WebSocket stack that drops a close's reason); the client
waits it. A 4008 (too slow to read) reconnects at once after a connection
that stayed up, and waits like any close when it comes sooner.
Needs Python 3.11+, ``httpx`` and ``websockets`` (14 or later)::
import asyncio
from reference_client import FeedListener
listener = FeedListener("qnr_...", {"mine": {"companies": [28, 706]}}, filters={"inside_information": True},
on_change=lambda copy, frame: print(frame["type"], frame.get("feed_item_id")))
asyncio.run(listener.run())
This file is the one the documentation shows; the API's tests run it against
the API and a stream that refuses, drains and closes it.
"""
from __future__ import annotations
import asyncio
import json
import random
import re
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
import httpx
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, InvalidHandshake
#: The API's host: every path the client reads begins ``/v1``.
API = "https://api.quanor.com"
STREAM = "wss://api.quanor.com/v1/stream/feed"
#: Closes after which this key reconnecting would be refused again.
STOP = {4400: "a message the stream will not read", 4401: "the key is not valid",
4403: "the key may not stream (revoked, rotated, expired, or no can_websocket)"}
#: The one 4400 that names no message: no subscribe reached the stream within
#: its wait from the socket opening (the protocol's ``limits.subscribe_within_s``).
NO_SUBSCRIBE = "no_subscribe"
#: The longest wait a 1012 suggests that a client takes, in seconds: the
#: protocol's ``limits.reconnect_after_ms_max``.
RECONNECT_AFTER_MAX_S = 5.0
_HINT = re.compile(r"reconnect_after_ms=(\d{1,9})")
#: Removals a copy remembers, the oldest let go first: a frame comes out of
#: order by moments (a position the stream sent above a gap, the gap's after
#: it), never by thousands of removals.
MAX_REMOVED = 5_000
#: The part kinds whose relevance the relevance floor reads: an announcement's
#: flash rows and its story (the protocol's ``filters.relevance_floor``). A
#: part of any other kind is not read for one, whatever fields it carries.
RELEVANCE_PART_KINDS = ("flash", "story")
#: How long a flash row the stream sent ahead is kept while no item frame has
#: held it or listed it as superseded, in seconds of the copy hearing the
#: stream live (the protocol's ``limits.ahead_kept_s``): its item's next frame
#: comes within moments of a live builder, and a row none ever accounts for is
#: not kept for good.
AHEAD_KEPT_S = 600.0
#: The item kinds version 1 names (the selection's ``item_kinds``); an item or
#: part frame of any other kind is ignored, as the protocol says of every kind
#: a reader does not know.
ITEM_KINDS = frozenset({"announcement", "market_alert", "earnings_call", "pre_earnings_analysis", "overview_audio",
"insider_transaction"})
#: How long a processing row is kept with no resolution heard, in seconds:
#: the protocol's ``limits.processing_row_s``, after which the row is resolved
#: ``expired`` — a resolution that can be lost before it reaches the stream.
PROCESSING_ROW_S = 120.0
#: The stream's ping interval where its ``ready`` does not say one, in
#: seconds: the protocol's ``limits.ping_every_s``.
PING_INTERVAL_S = 30.0
#: The stream writes a message queued for a listener within this, in seconds,
#: or closes the socket 4008: the protocol's ``limits.send_deadline_s``.
SEND_DEADLINE_S = 10.0
#: How often this client pings the stream, in seconds, and how long after its
#: ping it waits for any message — the pong comes after the frames queued
#: before it, so any message says the socket is alive — before taking the
#: socket as dead: twice the send deadline, as the stream may be writing a
#: batch when the ping comes, and a large frame takes its time over a slow
#: link.
PING_EVERY_S = 15.0
PONG_DEADLINE_S = 2 * SEND_DEADLINE_S
#: The error ``code`` values that say the API refused the key itself.
KEY_REFUSED = ("unauthenticated", "forbidden")
#: How long a connection stays up after its ``ready`` before the backoff
#: starts again from its first wait, in seconds: a failure that always
#: follows a ready waits longer each time.
STEADY_S = 30.0
#: What ``on_change`` is told when the copy was replaced by its windows read
#: again: the client's own, never a frame of the stream's.
COPY_REPLACED = "copy_replaced"
def silent_after(ping_interval_s: float) -> float:
"""How long a socket may be heard saying nothing — no frame, no ping —
before a client takes it as dead, in seconds: three of the stream's ping
intervals, or two and the time it takes to write a message queued behind
others, whichever is longer."""
return max(3 * ping_interval_s, 2 * ping_interval_s + SEND_DEADLINE_S)
def suggested_wait(reason: str, said: Optional[float]) -> float:
"""The wait a 1012 suggests, in seconds: its reason's — the whole reason,
as the protocol writes it — else the ``reconnect`` frame's sent just
before it, else none; never more than the protocol's bound."""
hint = _HINT.fullmatch(reason or "")
wait = int(hint.group(1)) / 1000 if hint else (said or 0.0)
return min(max(wait, 0.0), RECONNECT_AFTER_MAX_S)
class Stopped(Exception):
"""The stream refused this key or a message, or the API a request: fix it
before listening again."""
def _detail(answer: httpx.Response) -> Optional[dict[str, Any]]:
"""The API's own error body, or none for an answer in another shape (a
load balancer's page, a proxy's)."""
try:
body = answer.json()
except ValueError:
return None
return body if isinstance(body, dict) and "detail" in body else None
def passes(frame: dict[str, Any], filters: Optional[dict[str, Any]]) -> bool:
"""Whether an item passes the filters a window was read with, from its
own fields ("Filtering live frames" in the reference): an item frame, or
the item a copy holds — its frame's fields, and its parts with the flash
rows sent ahead of it. The stream sends every frame; a client narrows
them itself."""
if not filters:
return True
reports = filters.get("reports") or "include"
kinds = filters.get("announcement_kind")
if frame.get("item_kind") != "announcement":
# What only an announcement answers: reports only, inside information, its kind.
return not (reports == "only" or filters.get("inside_information") or kinds)
teaser = frame.get("access") == "teaser" # shows none of what a filter reads
head = frame.get("head") or {}
gone = set(frame.get("superseded_parts") or ())
parts = [p for p in frame.get("parts") or () if p.get("part_id") not in gone]
story = next((p for p in parts if p.get("kind") == "story"), None)
floor = filters.get("relevance_floor") or 1
if floor > 1:
# The highest its flash rows (every one, or the list's first) and its
# story show; a report story is material, whatever its relevance. A
# part of a kind this version does not name is ignored, as the
# protocol says of every kind a reader does not know.
known = [p["relevance"] for p in parts
if p.get("kind") in RELEVANCE_PART_KINDS and isinstance(p.get("relevance"), (int, float))]
report = story is not None and story.get("report") is not None
if teaser or not (report or (known and max(known) >= floor)):
return False
if kinds and (teaser or head.get("announcement_kind") not in (None, *kinds)):
return False
treatments = set(filters.get("story_treatment") or ())
if treatments >= {"full", "brief"}:
treatments = set() # both is every story: no filter
# A report story is shown as its report, never as a Story or a Brief.
if treatments and (teaser or (story is not None and story.get("report") is None
and story.get("treatment") not in treatments)):
return False
if reports == "only" and (teaser or head.get("route") != "report"):
return False
if reports == "exclude" and (teaser or head.get("route") == "report"):
return False # a route not known yet passes; a teaser shows none
# Inside information: every announcement but a report is known to be;
# reports follow `reports` alone (a report's own flag is unreliable).
inside = head.get("route") == "report" or head.get("inside_information") is True
return not filters.get("inside_information") or (not teaser and inside)
@dataclass
class FeedCopy:
"""The client's copy of the feed: items by id, the position at which each
item it no longer holds was hidden or withdrawn, processing rows by
edition, the cursor to resume from and the access it was read under."""
items: dict[str, dict[str, Any]] = field(default_factory=dict)
removed: dict[str, int] = field(default_factory=dict)
processing: dict[int, dict[str, Any]] = field(default_factory=dict)
cursor: Optional[str] = None
access_version: Optional[str] = None
builder: str = "live"
#: Per item, the parts its item frame held, and the flash rows the stream
#: sent ahead (part frames) that no item frame has yet held or listed as
#: superseded, with when each came: an item's parts are the two together.
snapshots: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
ahead: dict[str, dict[str, tuple[dict[str, Any], float]]] = field(default_factory=dict)
#: The selections a ``hidden`` named (``m``), for an item it removed.
removed_from: dict[str, frozenset[str]] = field(default_factory=dict)
#: The clock the rows sent ahead are aged on, while the copy is live.
clock: Callable[[], float] = time.monotonic
#: The seconds the copy was live until ``_live_since``, and when it last
#: became live (none while it is not): a row sent ahead ages by them.
_aged: float = field(default=0.0, init=False, repr=False)
_live_since: Optional[float] = field(default=None, init=False, repr=False)
#: When each processing row came, by the same clock.
_processing_at: dict[int, float] = field(default_factory=dict, init=False, repr=False)
def __post_init__(self) -> None:
self._live_since = self.clock()
def age(self) -> float:
"""The seconds the copy has been live: the clock rows sent ahead age on."""
since = self._live_since
return self._aged + (self.clock() - since if since is not None else 0.0)
def set_live(self, live: bool) -> None:
"""Whether the copy hears its stream live with a builder that is not
silent. While it does not, a row sent ahead does not age: its item's
frame is late, and comes once the stream is heard again."""
self._aged = self.age()
self._live_since = self.clock() if live else None
def apply(self, frame: dict[str, Any]) -> bool:
"""Apply a frame, as the reference's "Reading the feed" says; whether
it applied. The stream sends a position as soon as it is accounted
for, so an item's frames can arrive out of order — one held above a
gap, then the gap's: a frame at or below the position the copy holds
the item at, or last removed it at, is older, and changes nothing."""
kind = frame.get("type")
if kind in ("item", "part") and frame.get("item_kind") not in ITEM_KINDS:
return False # an item kind this version does not name
if kind == "item":
return self._item(frame)
if kind == "part":
return self._part(frame)
if kind in ("hidden", "withdrawn"):
return self._remove(frame)
if kind == "processing":
self.processing[frame["edition_id"]] = frame
self._processing_at.setdefault(frame["edition_id"], self.clock())
elif kind == "processing_resolved":
self._processing_at.pop(frame["edition_id"], None)
# A row not held, or one this copy already let go of: nothing changes.
return self.processing.pop(frame["edition_id"], None) is not None
else:
return False # A frame type this client does not know is ignored, as are fields.
return True
def _older(self, frame: dict[str, Any]) -> bool:
feed_item_id, seq = frame["feed_item_id"], frame["seq"]
held = self.items.get(feed_item_id)
if held is not None:
return held["seq"] is not None and seq <= held["seq"]
removed = self.removed.get(feed_item_id)
if removed is None or seq != removed:
return removed is not None and seq < removed
# At the position a ``hidden`` removed it: an item that left one of
# this client's selections for another is sent ``hidden`` for the one
# and, at that same position, the item for the other.
elsewhere = set(frame.get("m") or ()) - self.removed_from.get(feed_item_id, frozenset(frame.get("m") or ()))
return not (frame["type"] == "item" and elsewhere)
def _item(self, frame: dict[str, Any]) -> bool:
feed_item_id = frame["feed_item_id"]
if self._older(frame):
return False
superseded = set(frame.get("superseded_parts") or ())
parts = [part for part in frame.get("parts") or () if part["part_id"] not in superseded]
holds = {part["part_id"]: part["part_version"] for part in parts}
# A flash row the stream sent ahead stays until an item frame holds it
# at its version or later, or lists it as superseded: a frame built
# before the row was written arrives after it, and says nothing of it.
kept = {part_id: row for part_id, row in self.ahead.get(feed_item_id, {}).items()
if part_id not in superseded and holds.get(part_id, -1) < row[0]["part_version"]}
self._forget(feed_item_id)
self.snapshots[feed_item_id] = parts
if kept:
self.ahead[feed_item_id] = kept
self.items[feed_item_id] = {**frame, "parts": self._parts(feed_item_id)}
return True
def _parts(self, feed_item_id: str) -> list[dict[str, Any]]:
"""An item's parts: those its item frame held — each in the later
copy the stream sent ahead, where it did — then the rows sent ahead
that the frame does not hold."""
ahead = {part_id: part for part_id, (part, _) in self.ahead.get(feed_item_id, {}).items()}
parts = [ahead.pop(part["part_id"], part) for part in self.snapshots.get(feed_item_id, ())]
return parts + list(ahead.values())
def _part(self, frame: dict[str, Any]) -> bool:
feed_item_id, part = frame["feed_item_id"], frame["part"]
held = self.items.get(feed_item_id)
if held is None:
if feed_item_id in self.removed:
return False # only a later item frame brings back an item hidden or withdrawn
# A flash row ahead of its item's first snapshot: the item as the
# frame's head draws it, until the snapshot comes.
item = {key: value for key, value in frame.items() if key not in ("type", "part", "provisional")}
held = self.items[feed_item_id] = {**item, "type": "item", "seq": None, "parts": []}
elif part["part_id"] in (held.get("superseded_parts") or ()):
return False # a row the item frame held says a later one took the place of
old = next((p for p in held["parts"] if p["part_id"] == part["part_id"]), None)
if old is not None and part["part_version"] <= old["part_version"]:
return False
self.ahead.setdefault(feed_item_id, {})[part["part_id"]] = (part, self.age())
held["parts"] = self._parts(feed_item_id)
return True
def sweep(self) -> list[str]:
"""Drop the flash rows sent ahead that no item frame has held or
listed as superseded within ``AHEAD_KEPT_S`` of the copy being live —
a row its item's builds never included — and say the items they went
from. An item made from such rows alone goes with its last."""
now, changed = self.age(), []
for feed_item_id, rows in list(self.ahead.items()):
kept = {part_id: row for part_id, row in rows.items() if now - row[1] < AHEAD_KEPT_S}
if len(kept) == len(rows):
continue
self.ahead[feed_item_id] = kept
if not kept:
del self.ahead[feed_item_id]
held = self.items[feed_item_id]
held["parts"] = self._parts(feed_item_id)
if held["seq"] is None and not held["parts"]:
del self.items[feed_item_id]
changed.append(feed_item_id)
return changed
def _remove(self, frame: dict[str, Any]) -> bool:
feed_item_id = frame["feed_item_id"]
if self._older(frame):
return False
self.items.pop(feed_item_id, None)
self._forget(feed_item_id) # remembered as the newest removal
self.removed[feed_item_id] = frame["seq"]
if frame["type"] == "hidden" and frame.get("m"):
self.removed_from[feed_item_id] = frozenset(frame["m"])
while len(self.removed) > MAX_REMOVED:
self._forget(next(iter(self.removed)))
return True
def gone(self, feed_item_id: str) -> Optional[int]:
"""An item the API no longer shows this key (a refetch answered 404):
dropped, and remembered as removed at the position it was held at, so
a frame of it no later than that, arriving late, does not bring it
back, nor does a flash row; a later item frame does. The position it
was held at, none for one held from flash rows alone."""
held = self.items.pop(feed_item_id, None)
seq = held["seq"] if held is not None else None
removed = self.removed.get(feed_item_id)
self._forget(feed_item_id)
self.removed[feed_item_id] = max(seq or 0, removed or 0)
while len(self.removed) > MAX_REMOVED:
self._forget(next(iter(self.removed)))
return seq
def _forget(self, feed_item_id: str) -> None:
"""What the copy remembers beside an item's own frame: its removal,
the parts its frame held and the rows sent ahead of it."""
self.removed.pop(feed_item_id, None)
self.removed_from.pop(feed_item_id, None)
self.snapshots.pop(feed_item_id, None)
self.ahead.pop(feed_item_id, None)
def ready(self, message: dict[str, Any]) -> None:
"""A ``ready``'s processing rows: they replace the copy's when the
stream says it holds every row pending (``processing_complete``); a
stream still warming may not, and its rows join the ones kept."""
rows = {row["edition_id"]: row for row in message.get("processing", ())}
if message.get("processing_complete"):
self.processing = rows
else:
self.processing.update(rows)
now = self.clock()
self._processing_at = {edition_id: self._processing_at.get(edition_id, now) for edition_id in self.processing}
def clear_processing(self) -> None:
"""Drop every processing row: what an older access was sent."""
self.processing.clear()
self._processing_at.clear()
def expire_processing(self) -> list[int]:
"""Drop the processing rows held ``PROCESSING_ROW_S`` with no
resolution heard, and say their editions: the stream resolves such a
row ``expired`` by then, and a resolution lost on its way would leave
it for good."""
now = self.clock()
expired = [edition_id for edition_id, came in self._processing_at.items() if now - came >= PROCESSING_ROW_S]
for edition_id in expired:
self.processing.pop(edition_id, None)
del self._processing_at[edition_id]
return expired
def replace(self, frames: list[dict[str, Any]], access_version: Optional[str]) -> None:
"""The copy as windows read under ``access_version`` list it. What the
older copy remembered of removals goes with it: a frame's seq is the
position its own access's rendering last changed at, so one access's
positions say nothing of another's."""
self.items.clear()
self.removed.clear()
self.removed_from.clear()
self.snapshots.clear()
self.ahead.clear()
self.access_version = access_version
for frame in frames:
self.apply(frame)
class Backoff:
"""Exponential backoff with full jitter: a random wait up to a ceiling that
doubles on each failure, from ``first`` to ``most`` seconds."""
def __init__(self, first: float = 1.0, most: float = 60.0) -> None:
self.first, self.most, self.ceiling = first, most, first
def reset(self) -> None:
self.ceiling = self.first
async def wait(self, at_least: float = 0.0) -> None:
delay = max(at_least, random.uniform(0, self.ceiling))
self.ceiling = min(self.most, self.ceiling * 2)
await asyncio.sleep(delay)
class FeedListener:
def __init__(self, key: str, selections: dict[str, dict[str, Any]], lang: str = "en", *,
filters: Optional[dict[str, Any]] = None, api: str = API, stream: str = STREAM,
backoff: Optional[Backoff] = None,
on_change: Callable[[FeedCopy, dict[str, Any]], None] = lambda copy, frame: None) -> None:
if not selections:
raise ValueError("a listener needs at least one selection: with none, it is sent no item")
self.selections, self.lang, self.filters = selections, lang, filters or {}
self.api = httpx.AsyncClient(base_url=api, headers={"Authorization": f"Bearer {key}"}, timeout=30.0)
self.stream, self.headers = stream, {"Authorization": f"Bearer {key}"}
self.backoff = backoff or Backoff()
self.copy, self.on_change = FeedCopy(), on_change
#: The wait the connection's last ``reconnect`` frame suggested, in seconds.
self.reconnect_after: Optional[float] = None
#: The stream's ping interval, as its last ``ready`` said it, in seconds.
self.ping_interval = PING_INTERVAL_S
#: When the socket was last heard, or the client last finished with a
#: message; when the connection's ``ready`` came, until it is steady;
#: whether the client is handling a message (reading over HTTP), when
#: the socket's silence is the client's, not the stream's.
self._heard = 0.0
self._ready_at: Optional[float] = None
self._handling = False
#: Whether this connection has had its ``ready``, and whether it
#: stayed up ``STEADY_S`` after it.
self._answered = self._steady = False
async def run(self) -> None:
"""Listen until the stream refuses the key (``Stopped``)."""
try:
while True:
try:
if self.copy.cursor is None:
await self.read_windows(cause="start")
await self._listen()
# The socket closed as a normal end (1000, 1001, or no
# status): none of the stream's own closes, so something
# between it and the client — and, as any close the
# reference does not name, reconnected after a wait.
# Reconnected at once, a proxy that accepts the upgrade
# and ends it would be asked again in a tight loop.
await self.backoff.wait(suggested_wait("", self.reconnect_after))
except ConnectionClosed as closed:
code = closed.rcvd.code if closed.rcvd else 1006
reason = closed.rcvd.reason if closed.rcvd else ""
if code in STOP and (code, reason) != (4400, NO_SUBSCRIBE):
raise Stopped(f"{code}: {STOP[code]} ({reason})") from None
if code == 4008 and self._steady:
continue # too slow to read: reconnect at once, and read faster
# A 4008 soon after a connection's ready again and again is
# a client too slow for its stream: it waits, as on any close.
await self.backoff.wait(suggested_wait(reason, self.reconnect_after))
except _Transient as failed:
# A read the API could not answer now: no sooner than its
# Retry-After asks, within the backoff's longest wait.
await self.backoff.wait(min(failed.retry_after, self.backoff.most))
except (InvalidHandshake, OSError, TimeoutError, httpx.TransportError):
# An HTTP answer to the upgrade (503 while the stream
# restarts or is full), or no answer at all.
await self.backoff.wait()
finally:
await self.api.aclose()
async def _listen(self) -> None:
self.reconnect_after, self.ping_interval, self._ready_at = None, PING_INTERVAL_S, None
self._answered = self._steady = False
async with connect(self.stream, additional_headers=self.headers, open_timeout=10) as socket:
loop = asyncio.get_running_loop()
self._heard, self._handling = loop.time(), False
watch = asyncio.create_task(self._watch(socket))
try:
await self._subscribe(socket, self.copy.cursor)
async for text in socket:
self._heard, self._handling = loop.time(), True
await self._handle(socket, json.loads(text))
self._heard, self._handling = loop.time(), False
if self._ready_at is not None and self._heard - self._ready_at >= STEADY_S:
self.backoff.reset() # a connection that stayed up
self._ready_at, self._steady = None, True
finally:
watch.cancel()
self.copy.set_live(False)
async def _handle(self, socket: Any, message: dict[str, Any]) -> None:
kind = message.get("type")
if kind == "ready":
await self._ready(message)
elif kind == "ping":
await socket.send(json.dumps({"type": "pong", "t": message["t"]}))
self._sweep()
elif kind == "catch_up":
await self._subscribe(socket, await self.catch_up(self.copy.cursor))
elif kind == "resync":
await self._subscribe(socket, await self.read_windows(cause="resync"))
elif kind == "access_changed":
await self._supersede()
elif kind == "refetch":
await self.read_item(message["feed_item_id"])
elif kind == "status":
self._builder(message["builder"])
elif kind == "reconnect":
# The stream closes 1012 next: the wait it suggests.
self.reconnect_after = message["reconnect_after_ms"] / 1000
elif kind != "selection_ack":
self._apply(message)
if message.get("cursor") and kind != "selection_ack":
# A selection_ack's cursor is a bound to read the mirror to — the
# highest position the stream had heard — and may stand above a
# position not sent yet: never one to resume from.
self.copy.cursor = message["cursor"]
async def _ready(self, message: dict[str, Any]) -> None:
self._ready_at = asyncio.get_running_loop().time()
interval = message.get("ping_interval_ms")
if isinstance(interval, (int, float)) and not isinstance(interval, bool) and interval > 0:
self.ping_interval = interval / 1000
if message["access_version"] != self.copy.access_version:
# The key's access changed while this client was away, or after
# its windows were read: an item that did not change since is not
# resent, so what was read under the older access is superseded
# here or never.
await self._supersede()
self.copy.ready(message)
self._answered = True
self._builder("live") # every ready says the builder is live; a status follows if not
def _builder(self, state: str) -> None:
"""The builder's state, as ``ready`` or ``status`` says it: rows sent
ahead age only while it is live (a state this version does not name
is not) and the connection has had its ``ready``."""
self.copy.builder = state
self.copy.set_live(state == "live" and self._answered)
async def _watch(self, socket: Any) -> None:
"""Close a socket that stopped delivering, so the client reconnects
from its cursor: nothing at all within ``PONG_DEADLINE_S`` of this
client's ping, or nothing for ``silent_after`` the stream's ping
interval. The client's own reads over HTTP are its silence, not the
stream's: they hold no deadline, and the pings go on through them, so
the stream does not take the client for idle."""
loop = asyncio.get_running_loop()
next_ping, waiting_since = loop.time() + PING_EVERY_S, None
try:
while True:
now = loop.time()
if waiting_since is not None and self._heard >= waiting_since:
waiting_since = None # something came after the ping
late = waiting_since is not None and now - waiting_since >= PONG_DEADLINE_S
if not self._handling and (late or now - self._heard >= silent_after(self.ping_interval)):
await socket.close(1000, "no message in time")
return
if now >= next_ping:
await socket.send(json.dumps({"type": "ping", "t": round(time.time() * 1000)}))
next_ping = now + PING_EVERY_S
if waiting_since is None:
waiting_since = now
due = [next_ping]
if not self._handling: # a deadline passed while handling waits for its end
due.append(self._heard + silent_after(self.ping_interval))
if waiting_since is not None:
due.append(waiting_since + PONG_DEADLINE_S)
# Waking at least every second, it reads a ping interval a
# ``ready`` changed meanwhile.
await asyncio.sleep(min(max(min(due) - loop.time(), 0.0) + 0.001, 1.0))
except ConnectionClosed:
return
except Exception:
# A check that failed leaves no socket unwatched: the client
# reconnects, as from any socket closed under it.
await socket.close(1011, "liveness check failed")
async def _supersede(self) -> None:
"""What was read under an older access is superseded: its processing
rows go and the windows are read again; the stream goes on from its
own cursor."""
self.copy.clear_processing()
await self.read_windows(resume=False, cause="access")
async def _subscribe(self, socket: Any, since: Optional[str]) -> None:
await socket.send(json.dumps({"type": "subscribe", "v": 1, "since": since, "lang": self.lang,
"selections": self.selections}))
async def _get(self, path: str, **params: Any) -> dict[str, Any]:
return self._answer(await self.api.get(path, params=params))
async def _post(self, path: str, body: dict[str, Any], **params: Any) -> dict[str, Any]:
return self._answer(await self.api.post(path, json=body, params=params))
@staticmethod
def _answer(answer: httpx.Response) -> dict[str, Any]:
"""A read's answer; ``Stopped`` where asking again would be refused
again — the key refused by the API itself (401 or 403 in its error
shape, its ``code``, where it has one, naming the key), a request it
will not take (413, 422) — and ``_Transient`` for any other failure,
a load balancer's answer among them."""
status, error = answer.status_code, _detail(answer)
refused = status in (401, 403) and error is not None and error.get("code") in (None, *KEY_REFUSED)
if refused or (status in (413, 422) and error is not None):
raise Stopped(f"{status}: {error['detail']}")
if status >= 400:
raise _Transient(status, _retry_after(answer))
return answer.json()
async def read_windows(self, *, resume: bool = True, cause: str = "start") -> Optional[str]:
"""Each selection's newest page, replacing the copy, which is under the
access they were read under — none when the access changed between
them, so the stream's next ``ready`` or ``access_changed`` reads them
again — and telling ``on_change`` so, with why (``cause``); with
``resume``, the cursor to subscribe from: the lowest any of them was
read at."""
frames, throughs, accesses = [], [], set()
for selection in self.selections.values():
page = await self._get("/v1/feed", selection=json.dumps(selection), lang=self.lang, **self.filters)
frames += page["frames"]
throughs.append(page["through"])
accesses.add(page["access_version"])
self.copy.replace(frames, accesses.pop() if len(accesses) == 1 else None)
if resume:
self.copy.cursor = min(throughs, key=lambda c: tuple(int(x) for x in c.split(".")))
self.on_change(self.copy, {"type": COPY_REPLACED, "cause": cause})
return self.copy.cursor
async def catch_up(self, since: str) -> str:
"""Every change past ``since`` for the selections subscribed with,
page by page until complete; the cursor to subscribe from."""
while True:
# The selections in the body, as the subscribe sent them (eight of
# 2,000 companies each are more than a URL carries), and the
# windows' filters beside them, by the same names.
found = await self._post("/v1/feed/since", {"since": since, "selections": self.selections,
**self.filters}, lang=self.lang)
if found["resync"]:
return await self.read_windows(cause="resync")
for frame in found["frames"]:
self._apply(frame)
since = found["through"]
if found["complete"]:
self.copy.cursor = since
return since
async def read_item(self, feed_item_id: str) -> None:
"""Answer a ``refetch``: the item as the key is shown it now, applied
by its ``seq``; the API's 404 under the access the copy was read under
says the key is shown it no longer, a removal ``on_change`` hears as
``hidden`` where the item was shown, and under another access that
the key's access changed: the windows are read again. Another 404 — a
load balancer's page — is a read that failed."""
answer = await self.api.get(f"/v1/feed/items/{feed_item_id}", params={"lang": self.lang})
error = _detail(answer)
if answer.status_code == 404 and error is not None: # the API's, not a load balancer's
if error.get("access_version") not in (None, self.copy.access_version):
await self._supersede()
return
was_shown = self._shows(feed_item_id)
seq = self.copy.gone(feed_item_id)
if was_shown:
self.on_change(self.copy, {"type": "hidden", "feed_item_id": feed_item_id, "seq": seq})
return
self._apply(self._answer(answer)["frame"])
def _sweep(self) -> None:
"""Drop the rows sent ahead past their time (``FeedCopy.sweep``),
telling ``on_change`` each item shown that they changed: as it is now
held, or ``hidden`` where it is no longer shown; and the processing
rows no resolution reached (``FeedCopy.expire_processing``), each
told as resolved ``expired``."""
for edition_id in self.copy.expire_processing():
self.on_change(self.copy, {"type": "processing_resolved", "edition_id": edition_id,
"resolution": "expired"})
shown = {feed_item_id for feed_item_id in self.copy.ahead if self._shows(feed_item_id)}
for feed_item_id in self.copy.sweep():
if self._shows(feed_item_id):
self.on_change(self.copy, self.copy.items[feed_item_id])
elif feed_item_id in shown:
held = self.copy.items.get(feed_item_id)
self.on_change(self.copy, {"type": "hidden", "feed_item_id": feed_item_id,
"seq": held["seq"] if held is not None else None})
def shown(self) -> dict[str, dict[str, Any]]:
"""The items of the copy that pass the filters: what a window read
now would list."""
return {feed_item_id: item for feed_item_id, item in self.copy.items.items() if passes(item, self.filters)}
def _shows(self, feed_item_id: Optional[str]) -> bool:
item = self.copy.items.get(feed_item_id)
return item is not None and passes(item, self.filters)
def _apply(self, frame: dict[str, Any]) -> None:
"""Apply a frame to the copy, which holds every item the stream
sends whatever the filters make of it, and tell ``on_change`` each
frame that applied — an item's or a flash row's as it came when the
item passes the filters now, as ``hidden`` at the item's position
(none, for one held from flash rows alone) when the item was shown
and passes no longer, and not at all when it neither was shown nor
is. An item the filters leave out is not
removed: the copy keeps it with the flash rows sent ahead of it, so
its next row can make it pass, where one the stream removed
(``hidden``, ``withdrawn``) comes back with a later item frame alone."""
feed_item_id = frame.get("feed_item_id")
was_shown = self._shows(feed_item_id)
if not self.copy.apply(frame):
return
if frame["type"] in ("item", "part") and not self._shows(feed_item_id):
if not was_shown:
return
frame = {"type": "hidden", "feed_item_id": feed_item_id, "seq": self.copy.items[feed_item_id]["seq"]}
self.on_change(self.copy, frame)
def _retry_after(answer: httpx.Response) -> float:
"""The wait an answer's ``Retry-After`` asks for, in seconds; none for a
form other than the whole seconds the API writes."""
said = answer.headers.get("Retry-After", "").strip()
return float(said) if said.isascii() and said.isdigit() else 0.0
class _Transient(Exception):
"""A read the API could not answer now: back off and reconnect, no sooner
than its ``Retry-After`` asks, within the backoff's longest wait."""
def __init__(self, status: int, retry_after: float = 0.0) -> None:
super().__init__(status)
self.retry_after = retry_after