The feed stream — reference, version 1

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.

Getting started

  1. Get a key from Quanor with can_websocket; with can_access_detail it is sent every flash row, and with can_pending the processing rows (Connect).
  2. Choose the selections, each under a mirror id of the client's own: the companies or markets to follow (A selection).
  3. Read each selection's first page, 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).
  4. Open 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.
  5. Apply each frame as order says, and keep the cursor of the last frame or through received (cursor).
  6. Answer the stream: a 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).
  7. When the socket drops, reconnect with backoff and subscribe again from the cursor held (Liveness and back-off).

The reference client does each of these, and the API's tests run it.

Glossary

The words the reference uses, as an API key's client meets them.

feed itemOne 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.
partOne 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 rowA headline and its detail from Quanor's first reading of an announcement, written within seconds of it; an announcement can have several.
part sent aheadA flash row sent alone, as a part frame (provisional), the moment it is written: before the item frame that holds it.
feed positionA 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).
epochThe 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.
throughThe 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.
selectionWhat 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.
mirrorOne selection on a socket, under a mirror id the client chooses. Each frame names the mirrors it is for (m).
listenerOne open subscription to the feed stream (a socket, from its subscribe on), with its own access, cursor and counter (n).
windowOne selection's items as GET /v1/feed reads them: page by page from the newest arrival, each page as of its through.
page cursornext_cursor: where the next, older page of a window starts. Opaque: pass it back unchanged, never build one.
copyThe 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 versionThe 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 levelWhat 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).
teaserAn 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 headWhat an announcement shows around its parts (head): its title, kind, route, inside-information mark, editions and times.
feed headThe 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.
editionAn 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 rowAn announcement Quanor is reading before its first flash row, sent to a key with can_pending (processing, processing_resolved).
builderThe Quanor process that writes the feed's changes. The stream says when it falls silent and when it speaks again (status).
the stream's memoryThe latest changes the stream holds, from which it answers a subscribe's cursor (a resume). A cursor below it is told catch_up.

Connect

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.

Reading the feed

startRead 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.
orderApply 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).
cursorKeep 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.
resumeWhat 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_upRead 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.
resyncRead GET /v1/feed again for each selection, replace the copy, and subscribe from the lowest through of those reads (start).
access_changedEverything 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.
hiddenDrop the item and remember its seq (order): only a later item frame brings it back; withdrawn: the same, and its replaced_by names the item it was merged into or replaced by (a feed_item_id; null: gone), an item of its own that is sent and read as any other. A hidden is for the selections it names (m): an item that leaves one or more of a client's selections while another takes it, newly or still, is sent hidden for those it left and, at that same seq, the item for those that take it (a refetch in its place, for a change too large to send). A hidden can also come again, at a later seq, for a selection the item had already left, with nothing for those that still take it. A copy for each selection needs nothing more. A client keeping one copy for several remembers which selections hold each item (an item frame's m, the selection a window was read for), drops it only when none does, and applies an item frame at the seq a hidden removed it at when the frame names a selection the hidden did not.
refetchRead 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.
statusThe builder is silent (stream.status): changes are late, none is lost. Nothing to do.
processingWith 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.
unknownIgnore 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.

Resuming, message by message

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.

resume

A 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_head

A 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"}

Flash rows as they are written

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.

Filtering live frames

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_floorAbove 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_kindhead.announcement_kind is one of those listed, or null (not known yet).
story_treatmentThe 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.
reportsinclude (the default) is no filter; only: head.route is report; exclude: head.route is not report (null, not known yet, passes).
inside_informationAn 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.

What a client sends

subscribe

First 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"
      ]
    }
  }
}

selection

A 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
    ]
  }
}

ping

Answered 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
}

pong

The answer to the stream's ping.

{
  "type": "pong",
  "t": 1791439230456
}

A selection

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.

companiesOnly these companies (with watchlist, these and the reader's watchlist); leave both out for every company.
watchlistAlso the app reader's watchlist. A key has none: its selection with watchlist and no companies selects nothing.
marketsOnly companies on these markets (market_exchange_id).
item_kindsLeave out for every kind but overview_audio and insider_transaction. An API key is sent announcements and pre-earnings analyses alone.
part_kindsOnly announcements with one of these parts. An API key may name flash.
teasersAlso send the items of companies outside the key's allowed_company_ids, as teasers.

What the stream sends

ready

After 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_ack

The 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
}

through

Every 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_up

The 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
}

resync

The 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_changed

The 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
}

status

The 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
}

refetch

An item changed but its frame was too large to send: read it.

{
  "type": "refetch",
  "feed_item_id": "announcement:88001",
  "n": 9
}

reconnect

The 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
}

ping

Every 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
}

Frames

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).

nThis connection's counter, contiguous: an integer of up to 64 bits.
cursorThe 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.
mThe mirrors (selections) the frame is for.
access_versionThe 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).

item

A 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.

part

A 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.

hidden

No 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.

withdrawn

Retracted: 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.

processing

An edition the flash lane is working on (with can_pending): its item is the one whose head.edition_ids holds edition_id.

processing_resolved

A processing row settled: drop it.

Parts

flash

One flash row: Quanor's first reading of the announcement. The list level (api_list) carries the first row's headline and relevance alone.

story

The 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.

A pre-earnings analysis

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.

Examples, as a key is sent them

Each without the fields the stream adds for its listener (n, cursor, m, access_version: Frames, above).

item.announcement.api.en

{
  "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"
}

item.announcement.api_list.en

{
  "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"
}

item.announcement.api_teaser.en

{
  "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"
}

item.announcement.report.api.en

{
  "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"
}

item.pre_earnings_analysis.api.en

{
  "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"
}

hidden

{
  "feed_item_id": "announcement:88001",
  "seq": 1048,
  "type": "hidden"
}

withdrawn

{
  "feed_item_id": "edition:5001",
  "replaced_by": "announcement:88001",
  "seq": 1049,
  "type": "withdrawn"
}

part.api.en

{
  "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"
}

processing.api.en

{
  "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"
}

processing_resolved

{
  "edition_id": 5001,
  "resolution": "flashed",
  "type": "processing_resolved"
}

Close codes, for an API key

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.

1012reconnect 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
4008reconnect at once and subscribe from the cursor held; take frames faster (read the socket on its own task)
4400a 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
4401no key was presented, or it is unknown, revoked or expired: stop and check the key
4403the key was revoked, rotated or expired while connected, or it may not stream (can_websocket): stop and check the key
4408reconnect 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)
4429too many sockets for this key, or messages sent too fast: reconnect with backoff, and hold fewer sockets
4503the stream cannot confirm the key's access now: reconnect with backoff

Limits

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_s5
ping_every_s30
idle_after_s75
send_deadline_s10
queue_bytes4194304
lifetimeunder 24 h: the stream ends a connection with 1012 and a reconnect hint before then
lifetime_under_s86400
reconnect_after_ms_max5000
mirrors_per_socket8
mirror_id_bytes128
companies_per_selection2000
markets_per_selection64
id_max2147483647
message_bytes221632
frame_bytes800000
lang_bytes16
inbound_bytes_per_s65536
inbound_bytes_burst886528
commands_per_s2
commands_burst16
selection_ids_per_s2064
selection_ids_burst33024
other_messages_per_s4
other_messages_burst64
processing_row_s120
ahead_kept_s600
sockets_per_keyat most the number a key may hold at once — the reference page's Connect section says how many; one more is closed 4429
historya 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_largean item whose frame is too large to send is announced by refetch: read it (GET /v1/feed/items/{feed_item_id})

Catching up and reading one item

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.

What the feed guarantees

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.

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:

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.

What each time marks

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.

TimeMarksClockKnown 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 deliveriesMicroseconds
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 analysisAn 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 writtenMicroseconds
head.received_atWhen 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 deliveriesMicroseconds
head.first_flash_atWhen the announcement's first flash row was written, one since replaced included. Null before any.Quanor's: its database's, as the row was writtenMicroseconds
a flash part's created_atWhen 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 writtenMicroseconds
a story part's first_published_atWhen the story's first version was published.Quanor's: its database's, as the row was writtenMicroseconds
a story part's version_published_atWhen 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 writtenMicroseconds
a processing row's dispatched_atWhen Quanor sent the announcement's document to be read for its flash rows.Quanor's: the process that sent itMicroseconds
a processing row's published_atWhen 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_timeWhen the stream answered the subscribe.Quanor's: the stream'sMicroseconds
the stream's ping.tWhen the stream queued the ping: late, never early, by the frames queued before it.Quanor's: the stream'sMilliseconds since 1970-01-01 UTC, a JSON number

Liveness and back-off

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:

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.

Versioning and changes

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:

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.

Changelog

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.

The reference client

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

Machine-readable