Quanor webhooks guide

Webhook endpoints

If your API key has can_webhooks, you can create webhook endpoints at /v1/webhook-endpoints. Quanor then sends each endpoint signed HTTPS POSTs carrying the events you chose, each event formatted as a CloudEvent (/v1/event-types lists the types). Messages follow Standard Webhooks: verify webhook-signature against the raw body with the endpoint's whsec_ secret. An endpoint's URL must use https on port 443, at a host that resolves only to public addresses. A message can arrive more than once, and in no promised order: de-duplicate events by their id, decide what is newer by generation, then sequence, and still apply messages one at a time in the order you received them.

What you can receive. announcement.*: every announcement your key can see, with its flash rows, its story or brief, a report's report story. pre_earnings_analysis.*: every pre-earnings analysis. Every endpoint also receives its own webhook_endpoint.reset, and webhook_endpoint.tested when you ask for a test. Company data, such as playbooks, profiles and financial figures, will come as new event types, which an endpoint receives only once you ask for them.

Webhooks, the stream or pulling. Webhooks keep your copy of each item current, usually within seconds, without you holding a connection open. If you need flash rows the instant they are written (for example, to drive a trading system), use the feed stream (its reference). To read without receiving anything, page GET /v1/feed and POST /v1/feed/since (Catching up and pulling). Every route, parameter and schema is in the API reference. The messages follow Standard Webhooks and CloudEvents.

Quickstart

To check that your key can receive webhooks, call GET /v1/event-types: each type's available field says whether your key can receive it. Company ids come from GET /v1/companies.

1. Create an endpoint

Send the destination URL, the event types and, optionally, a list of companies. Omit companies to receive every company your key can see. The response is 201 Created and contains the signing secret in secret. It is shown only once: store it in your secrets manager. If you lose it, rotate the secret to get a new one (Rotating the signing secret).

curl https://api.quanor.com/v1/webhook-endpoints \
  -H "Authorization: Bearer $QUANOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/quanor", "event_types": ["announcement.*"], "companies": [28, 706]}'
HTTP/1.1 201 Created
Cache-Control: no-store

{
  "webhook_endpoint_id": "8d2f0c39-2b63-4c55-9a8a-3f5b1d7c1e20",
  "url": "https://hooks.example.com/quanor",
  "event_types": ["announcement.*"],
  "companies": [28, 706],
  "lang": "en",
  "state": "active",
  "secret": "whsec_…",
  …
}

2. Verify and respond

Your server verifies each message (Verifying a message) and responds 2xx within 15 seconds (Responding to messages).

Quanor sends the endpoint's first message, a reset, within seconds of the create. Until your server has the secret, it rejects messages, and Quanor retries them, so nothing is lost. The retries back off, up to 1 hour apart. Once your server is ready, pause and resume the endpoint to have the reset sent at once:

curl -X POST https://api.quanor.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/pause \
  -H "Authorization: Bearer $QUANOR_API_KEY"

curl -X POST https://api.quanor.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/resume \
  -H "Authorization: Bearer $QUANOR_API_KEY"

3. Send a test

POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-sends returns 202 Accepted with a test_send_id. Within seconds your server receives a message with one webhook_endpoint.tested event. GET /v1/webhook-endpoints/{webhook_endpoint_id}/test-sends/{test_send_id} then shows your server's HTTP status, or a failure reason if no response arrived.

curl -X POST https://api.quanor.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/test-sends \
  -H "Authorization: Bearer $QUANOR_API_KEY"

curl https://api.quanor.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/test-sends/$TEST_SEND_ID \
  -H "Authorization: Bearer $QUANOR_API_KEY"

4. Receive the first events

The first message of the endpoint's feed is a webhook_endpoint.reset with reason created (resumed if you paused and resumed it). Respond 2xx at once, then load your copy from GET /v1/feed in the background (Resets). Events follow as items change, such as announcement.updated and announcement.hidden. Apply each one to your copy (Keeping your copy in sync).

curl -G https://api.quanor.com/v1/feed \
  -H "Authorization: Bearer $QUANOR_API_KEY" \
  --data-urlencode 'selection={"item_kinds": ["announcement"], "companies": [28, 706]}' \
  --data-urlencode 'lang=en'

5. Get failure emails

Quanor emails your key's notice address when an endpoint has failed for 24 hours, and when Quanor pauses it (after 5 days of failures, or a 410). A key has no notice address until you ask for one: write to support@quanor.com with the address. The notices list shows whether your key has one (has_notice_address):

curl https://api.quanor.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/notices \
  -H "Authorization: Bearer $QUANOR_API_KEY"

{"items": [], "next_cursor": null, "has_notice_address": false}

Verifying a message

Every message is signed with HMAC-SHA256 and your endpoint's secret, as the Standard Webhooks specification describes. Verify every message before you act on it. If verification fails, respond 401: Quanor retries, so you lose nothing while you fix the problem.

  1. Take the raw body, byte for byte, before any JSON parsing. Parsing and serialising again changes the bytes, and the signature no longer matches. Read the body as bytes (await request.body(), Express's express.raw), not through a JSON parser (request.json(), express.json(), Django REST framework's request.data).
  2. Decode the key. The secret is whsec_ followed by base64. The HMAC's key is the bytes that base64 decodes to, not the text.
  3. Sign {webhook-id}.{webhook-timestamp}.{body} — the two headers and the raw body, joined by . — with HMAC-SHA256, and base64-encode the result.
  4. Compare. webhook-signature holds space-separated values, each v1, followed by a base64 signature. Accept the message if any of them equals yours, compared in constant time. During a rotation, two are sent, one for each secret.
  5. Check the time. Reject a webhook-timestamp more than 5 minutes from your clock, either way. Keep your server's clock synchronised.
  6. Reject replays. Store the webhook-id of every message you accept. A message whose id you have stored is a duplicate: respond 2xx and do nothing more.
HeaderWhat it holds
webhook-idThe message's id: msg_ and 32 lowercase hex digits, the same on every attempt of the same body. Store it to detect duplicate deliveries.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signatureSpace-separated v1,<base64> values, one for each secret that signs.
quanor-webhook-attemptThe attempt number, from 1, counting every attempt since the last successful delivery. A retry rebuilt with newer data gets a new webhook-id but keeps the count. Not signed: use it for logging only.
content-typeapplication/json
user-agentQuanor-Webhooks/1

With a library

The Standard Webhooks libraries, standardwebhooks on PyPI and on npm, verify a message with one secret, whsec_ prefix included. During a rotation, try each secret in turn.

from standardwebhooks.webhooks import Webhook, WebhookVerificationError


def verified(raw_body, headers, secrets):
    for secret in secrets:  # two while a rotation overlaps
        try:
            Webhook(secret).verify(raw_body, dict(headers))
            return True
        except WebhookVerificationError:
            pass
    return False
import { Webhook } from "standardwebhooks";

const verified = (raw, headers, secrets) => secrets.some((secret) => {
  try {
    new Webhook(secret).verify(raw, headers);
    return true;
  } catch {
    return false;
  }
});

These libraries check the timestamp against your real clock, so they reject the signed examples below, which are dated in the past. To test them against the examples, fix the clock in your test at the example's webhook-timestamp: in Python, with a library such as freezegun; in Node, by setting Date.now or with your test runner's fake timers.

Python, with the standard library

import base64
import hashlib
import hmac
import time

TOLERANCE_S = 300  # five minutes, either way


def verify(raw_body, headers, secrets, now=None):
    """Whether a message is Quanor's. raw_body: the request body exactly as received, never parsed;
    headers: the request's headers, any mapping; secrets: the endpoint's whsec_ secrets."""
    headers = {name.lower(): value for name, value in headers.items()}
    msg_id, timestamp = headers.get("webhook-id", ""), headers.get("webhook-timestamp", "")
    if not (msg_id and timestamp.isascii() and timestamp.isdigit() and len(timestamp) <= 12):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_S:
        return False
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    offered = [s[3:].encode() for s in headers.get("webhook-signature", "").split(" ") if s.startswith("v1,")]
    for secret in secrets:  # two while a rotation overlaps
        key = base64.b64decode(secret.removeprefix("whsec_"))
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
        if any(hmac.compare_digest(expected, given) for given in offered):
            return True
    return False

In a web application — FastAPI here; Flask's request.get_data() and Django's request.body give the raw body the same way:

import os

from fastapi import FastAPI, Request, Response

app = FastAPI()
SECRETS = os.environ["QUANOR_WEBHOOK_SECRETS"].split(",")  # whsec_…; two while a rotation overlaps
MAX_BODY = 1_048_576  # 1 MiB, as express.raw's limit below: no message Quanor sends is larger


async def read_body(request: Request) -> bytes | None:
    """The body as sent, or None once it passes MAX_BODY: never buffered past it."""
    length = request.headers.get("content-length", "")
    if length.isdigit() and int(length) > MAX_BODY:
        return None
    body = bytearray()
    async for chunk in request.stream():
        body += chunk
        if len(body) > MAX_BODY:
            return None
    return bytes(body)


@app.post("/quanor/webhooks")
async def receive(request: Request) -> Response:
    raw_body = await read_body(request)  # the bytes as sent: never request.json() before verifying
    if raw_body is None:
        return Response(status_code=413)
    if not verify(raw_body, request.headers, SECRETS):
        return Response(status_code=401)
    # One transaction: the message and its webhook-id. A webhook-id stored before is a duplicate.
    await inbox.add(request.headers["webhook-id"], raw_body)
    return Response(status_code=204)  # and process the inbox apart from this request

Node

verify.mjs, with Node's own crypto:

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_S = 300; // five minutes, either way

// raw: the request body as a Buffer, exactly as received. headers: the request's, names in lower case.
// secrets: the endpoint's whsec_ secrets. True when the message is Quanor's.
export function verify(raw, headers, secrets, now = Date.now() / 1000) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"] ?? "";
  const signature = headers["webhook-signature"] ?? "";
  if (!id || !/^[0-9]{1,12}$/.test(timestamp)) return false;
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_S) return false;
  const offered = signature.split(" ").filter((s) => s.startsWith("v1,")).map((s) => Buffer.from(s.slice(3), "base64"));
  return secrets.some((secret) => {
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(raw).digest();
    return offered.some((given) => given.length === expected.length && timingSafeEqual(given, expected));
  });
}

In Express:

import express from "express";
import { verify } from "./verify.mjs";

const SECRETS = process.env.QUANOR_WEBHOOK_SECRETS.split(","); // whsec_…; two while a rotation overlaps
const app = express();

// express.raw keeps the bytes as sent. express.json() must not see this route: it would parse the body first.
app.post("/quanor/webhooks", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verify(req.body, req.headers, SECRETS)) return res.sendStatus(401);
  await inbox.add(req.headers["webhook-id"], req.body); // one transaction; a webhook-id seen before is a duplicate
  res.sendStatus(204); // and process the inbox apart from this request
});

app.listen(3000);

Checking your verifier against the signed examples

webhook_message.v1.json holds a signed example of every kind of message, signed with its public example_secret. For each message, body_bytes is the exact body that was signed, as a string; body is the same JSON, for reading; and headers are the headers it was sent with. Check that your verifier accepts each one, and rejects it when you change one byte of the body or use another secret.

The examples' webhook-timestamp (1791439212) is in the past by design, more than 5 minutes ago, so a verifier that reads the real clock must reject them. Run yours with its clock set to the example's timestamp, as the code above allows. In Python, encode the body first: verify(m["body_bytes"].encode("utf-8"), m["headers"], [example_secret], now=int(m["headers"]["webhook-timestamp"])).

Responding to messages

Respond 2xx within 15 seconds, connecting and TLS included. Do as little as possible before you respond: verify the message, store it with its webhook-id in one transaction, respond, then process it in a background job. A message whose webhook-id you have stored before is a duplicate: respond 2xx and do nothing more.

Quanor sends one message at a time to each endpoint and waits for your response before sending the next (test sends excepted). A slow response delays everything behind it.

Apply messages in the order you received them. Your background job applies each endpoint's stored messages one at a time, oldest first, for each track — never two at once, and never reordered. Quanor's order is what keeps your copy right: a reset must be applied before anything sent after it, and a message applied ahead of an earlier reset makes that reset look older, so it is skipped.

On a reset, respond first. A webhook_endpoint.reset asks you to reload your copy, which takes longer than 15 seconds. Respond 2xx at once and reload in the background. Messages that follow the reset can arrive while you reload: keep them, and apply them by the same rules (Resets).

Your responseWhat Quanor does
2xxDelivered: the next message follows.
410Stops: the endpoint is paused (pause_reason gone) until you resume it, and you are notified. Respond 410 only when you mean it.
413The message was too large for you. Quanor sends it again with half as many events, at once or after the Retry-After you state; a message of one item's events is sent as that item's refetch; neither counts as a failure. A 413 to a reset or to a refetch does. Accept bodies up to 1 MiB to avoid it.
429 or 503 with Retry-AfterSent again at the time you state, in seconds or as an HTTP date, at most 1 hour later.
Anything else — another status, a redirect, or no response within 15 secondsSent again after a wait (Retries and failures), and never sooner than a Retry-After you state.

Quanor reads at most the first 64 KiB of your response body and keeps its first 1 KiB, redacted, in the endpoint's attempts list (Troubleshooting). Respond with an empty body or a short one.

Keeping your copy in sync

Each message carries the latest state of the items that changed. Keep a copy of the items for each endpoint, and apply each message to it by the rules below. Your copy then holds every item the endpoint's filter selects, at its latest state.

What a message holds

FieldWhat it holds
v1: the message format's version.
webhook_endpoint_idThe endpoint the message was sent for. Verify with that endpoint's secrets, and keep a copy for each endpoint you receive.
trackThe series of changes the message belongs to. Today there is one, feed: announcements and pre-earnings analyses. Company data will arrive on a track of its own. generation, since and through belong to one track. Absent on a test.
generationA number that grows with every reset and every rewind. A reset Quanor counted as failed is sent again in the next generation. Ignore a message whose generation is lower than the newest you hold.
since, throughThe cursors the message's events lie between. Keep the through of the last message of events you applied: it is where you rewind to after restoring a backup. Treat them as opaque strings: you only send them back.
completetrue if this message brought you up to the latest change when it was built. You don't need to act on it.
access_versionIdentifies your key's access when the message was built. Pages of /v1/feed carry it too. If it differs from the one your copy was loaded with, reload.
eventsThe events, each a CloudEvent. A reset message holds one webhook_endpoint.reset, a test one webhook_endpoint.tested.
A whole message: its headers and its body
{
  "headers": {
    "webhook-id": "msg_1d379278e867509b5ba09a4dfcd7eaf6",
    "webhook-timestamp": "1791439212",
    "webhook-signature": "v1,jlH1Y9afJmxRyfwNSyRjrhUEG1G5DZ28FDd89yyRbfw=",
    "quanor-webhook-attempt": "1",
    "content-type": "application/json",
    "user-agent": "Quanor-Webhooks/1"
  },
  "body": {
    "access_version": "b4d934cf",
    "complete": true,
    "events": [
      {
        "companyid": 28,
        "data": {
          "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: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"
        },
        "datacontenttype": "application/json",
        "dataschema": "https://api.quanor.com/api/docs/events/announcement.updated.v1.data.schema.json",
        "id": "evt_2qyd41b3mqbv451ksq45hkm6tf",
        "sequence": "0000000001.0000000000000001045",
        "source": "https://api.quanor.com/v1/feed",
        "specversion": "1.0",
        "subject": "announcement:88001",
        "type": "announcement.updated"
      },
      {
        "data": {
          "feed_item_id": "announcement:88001",
          "seq": 1048,
          "type": "hidden"
        },
        "datacontenttype": "application/json",
        "dataschema": "https://api.quanor.com/api/docs/events/announcement.hidden.v1.data.schema.json",
        "id": "evt_1zrs9j3563b1tqssanx7rs4ajv",
        "sequence": "0000000001.0000000000000001048",
        "source": "https://api.quanor.com/v1/feed",
        "specversion": "1.0",
        "subject": "announcement:88001",
        "type": "announcement.hidden"
      }
    ],
    "generation": 3,
    "since": "1.1040",
    "through": "1.1050",
    "track": "feed",
    "v": 1,
    "webhook_endpoint_id": "8d2f0c39-2b63-4c55-9a8a-3f5b1d7c1e20"
  }
}

The body is JSON whose events are CloudEvents in structured form, not CloudEvents' batched form (application/cloudevents-batch+json): a CloudEvents library reads each of events, not the body.

What an event holds

AttributeWhat it holds
idevt_…, unique to the change. The same change, sent to the same key with the same access and language, has the same id on every retry and rewind, on every endpoint of your key with that language, and in /v1/feed/since when you ask for it (Catching up and pulling). De-duplicate by it, for each endpoint, in the same store as that endpoint's copy.
typeWhat happened: route by it (The event catalog).
subjectThe item the event is about, the same string as data.feed_item_id: announcement:88001, pre_earnings_analysis:28:Q3-2026, or edition:<id>. An edition:<id> is a provisional id for an announcement not matched yet: it is later withdrawn, and replaced_by names the matched announcement.
sequenceWhere the change stands among the item's changes: fixed-width text, so that within one generation the later change sorts higher as a string.
companyidThe company, when the event names one: not on withdrawn and hidden, nor on an event whose data.type is refetch.
sourceThe API URL the subject can be read from.
specversionAlways 1.0.
datacontenttypeAlways application/json.
dataschemaThe JSON Schema of data.
dataThe item as POST /v1/feed/since returns it in its frames, for your key's access and the endpoint's lang.

The rules

  1. Apply messages in the order you received them, one at a time for each track (Responding to messages).
  2. Ignore an older generation: a message whose generation is lower than the newest you hold.
  3. De-duplicate by event id, for each endpoint: skip an event you have applied. Store the ids in the same transactions and backups as your copy. After you restore a backup and rewind, Quanor resends what you had applied since the backup, with the same ids, so the ids must roll back with the copy. You can drop the ids of generations older than the last reset you applied: they are never sent again.
  4. Route by type, and ignore a type, a track or a field you do not know: new ones are added without notice (Versions).
  5. Hold each item by its subject, with the generation and sequence you applied it at.
  6. On <kind>.updated, store data as the item, replacing what you hold of it. It may be the first event you see for an item: treat it as an insert.
  7. On <kind>.hidden or <kind>.withdrawn, remove the item but keep a tombstone: its subject, with the event's generation and sequence.
  8. Apply an event only when it is newer than what you hold for its subject — the item or its tombstone. A newer generation always wins. Within one generation, its sequence must be higher than the one you hold; at the same sequence, a removal wins over an item. Without the tombstone, an older updated that arrives after a hidden brings the item back.
  9. Keep tombstones until a reload drops them: a reload keeps those of its own generation and drops the older ones (Resets).
  10. On an event whose data.type is refetch, the item was too large to send. Before you apply the next message, read GET /v1/feed/items/{feed_item_id} in the endpoint's lang, and apply the response at the event's generation and sequence (not the item's own), by the rule above, when its access_version is your copy's. A 404 with your copy's access_version removes the item at that sequence. If you read refetches in the background instead, keep each one begun with your copy, as you keep reloads, and drop a response that a reset of a newer generation overtook: that reset's reload reads the item. If the response has a different access_version, reload your copy. A 404 with no access_version (from a proxy or load balancer) is a failed read: retry a few times, then reload.
  11. On a message whose access_version is not your copy's, reload your copy (Resets).

Within one generation, an event's sequence and its data.seq name the same place: comparing either gives the same answer. An item read from a page of /v1/feed carries seq but no sequence, so the sketch below holds seq.

What is promised, and what is not

Acting on what is new

Keeping your copy and alerting on news are two different rules. An updated for an item you do not hold is new to your copy, but not always news: the item may have entered your filter late, arrived in the events around a reload, or been shown to your key again after a hidden. Never alert on data.op or on an item you did not hold. Decide by the part instead:

A sketch

The rules in Python, for one endpoint's feed messages, each already verified, and the reload a reset asks for:

items = {}              # subject -> {"generation": g, "seq": n, "item": the event's data, or None: a tombstone}
seen = set()            # the event ids applied
generation = 0          # the newest generation taken
reset_generation = 0    # the generation of the newest reset taken
access_version = None   # the access your copy is loaded, or being reloaded, under
reloading = []          # the generation of each reload begun and not merged: begin them again after a restart
# Keep all six with your copy, in its transactions and backups.
KINDS = {"announcement", "pre_earnings_analysis"}
VERBS = {"updated", "withdrawn", "hidden"}


def apply(message):
    """Apply one verified message of one endpoint: from your store, one at a time, in the order received."""
    global generation, reset_generation, access_version
    if message.get("track") != "feed" or message["generation"] < generation:
        return  # a test, another track, or a generation older than the one you hold
    generation = message["generation"]
    is_reset = any(event["type"] == "webhook_endpoint.reset" for event in message["events"])
    if not is_reset and access_version is not None and message["access_version"] != access_version:
        access_version = message["access_version"]
        start_reload(generation)  # your key's access changed: reload your copy under it
    for event in message["events"]:
        if event["id"] in seen:
            continue
        seen.add(event["id"])
        if event["type"] == "webhook_endpoint.reset":
            reset_generation, access_version = event["data"]["generation"], message["access_version"]
            start_reload(reset_generation)  # reload in the background: see Resets
            continue
        kind, _, verb = event["type"].partition(".")
        if kind not in KINDS or verb not in VERBS:
            continue  # a type you do not know
        data = event["data"]
        if not newer(event["subject"], generation, data["seq"], removes=verb != "updated"):
            continue  # not newer than what you hold of it: the item, or its tombstone
        if data.get("type") == "refetch":
            refetch(event, generation)  # GET /v1/feed/items/{feed_item_id}, applied at this event's seq
        else:
            items[event["subject"]] = {"generation": generation, "seq": data["seq"],
                                       "item": data if verb == "updated" else None}


def newer(subject, at_generation, seq, removes):
    """Whether a change is newer than what you hold of its subject. At the same seq, a removal wins."""
    held = items.get(subject)
    if held is None or held["generation"] != at_generation:
        return held is None or held["generation"] < at_generation
    return seq > held["seq"] or (seq == held["seq"] and removes)


def start_reload(at):
    reloading.append(at)
    ...  # yours: in the background, read the endpoint and every page of /v1/feed, then call reloaded(at, pages)


def reloaded(at, pages):
    """Merge every page of GET /v1/feed, read for the reload begun at generation `at`, into your copy."""
    reloading.remove(at)
    if at < reset_generation:
        return  # a newer reset came while you read: its reload replaces this one
    for page in pages:
        for item in page["frames"]:
            if newer(item["feed_item_id"], at, item["seq"], removes=False):
                items[item["feed_item_id"]] = {"generation": at, "seq": item["seq"], "item": item}
    for subject in [subject for subject, held in items.items() if held["generation"] < at]:
        del items[subject]  # not on the pages: gone since, or a tombstone no longer needed


def refetch(event, at):
    ...  # yours: before the next message, read the item; apply what it answers when
    # newer(event["subject"], at, event["data"]["seq"], removes=it answered 404) — at the event's seq

Resets

A webhook_endpoint.reset means the events that follow don't continue from your copy, so reload it. Every endpoint starts with one, and Quanor sends another whenever your copy may be stale. Handle every reset the same way, whatever its reason, which is for your logs; a reason not listed here may be added. Its data.resume_from is where the events after it start: you don't need it when you merge by seq.

  1. Respond 2xx to the reset as soon as you have verified it and stored its webhook-id.
  2. In the background, read the endpoint: GET /v1/webhook-endpoints/{webhook_endpoint_id}, for its filter and lang as they are now.
  3. Read every page of GET /v1/feed with the selection its filter makes, in its lang (Catching up and pulling shows the mapping), following next_cursor until has_more is false. Every page carries access_version; when they differ, read them again.
  4. Keep applying messages by the usual rules while you read. Do not clear the event ids you have applied.
  5. Merge the pages into your copy under the reset's generation. For each item on the pages, keep what you hold of it if you hold it at the reset's generation at the same seq or a higher one, or at a newer generation: an event that arrived while you read. Otherwise, store the page's item at its seq.
  6. Remove everything you hold from an older generation: an item that is not on the pages is gone, and an older tombstone is no longer needed.

Merged this way, your copy ends the same whatever order the pages and the messages came in. If a newer reset arrives while you read, stop: its reload replaces this one. A reset your server stored but Quanor counted as failed is sent again in the next generation, and replaces the first in the same way. A rewind raises the generation too, but sends no reset: a reload it overtakes goes on. Note the reset's access_version when the reload begins, as your copy's: a message under another one begins a reload of its own, under the generation it arrived at. Keep the reloads you have begun with your copy, and begin them again after a restart. The sketch in Keeping your copy in sync does all of this.

Reading /v1/feed counts against none of your key's daily allowances.

reasonWhat caused it
createdThe endpoint was created: load your copy for the first time.
endpoint_changedYou changed its url, its filter or its lang.
resumedIt was resumed after a pause — yours, or Quanor's after failures, a 410 or your key losing webhook access — or changed while paused.
access_changedWhat your API key can see changed: its plan, its companies or its history window.
behindReloading replaces catching up: an attempt to your server failed more than 24 hours ago and none has succeeded since — whether or not anything waited to be sent, since only a 2xx shows Quanor your server is back — or Quanor could not send for that long.
read_timeoutCatching you up from far behind took too long, so a reload replaces it.
epoch_changedQuanor's feed was restored, so the cursors of your copy no longer apply.

Retries and failures

While a message fails, nothing after it is sent: an endpoint has one message in flight at a time on each track. When your server responds again, everything since follows, each item at its latest state.

WhenWhat happens
After a failureThe message is sent again after a random wait of up to 5 seconds. That ceiling doubles with each failure since your server last responded 2xx, to at most 1 hour. There is no limit to the attempts.
At every attemptThe message is built again from the items as they stand. When nothing changed, it is the same body under the same webhook-id. When something did, it is a new message with a new webhook-id, carrying the newer state.
After 24 hours of failures in a rowThe next attempt is a reset (behind) in place of everything missed since. An attempt that fails a day or more after failing_since notifies you.
A failure, then nothing to sendWhen what a failed message carried is gone before its retry — an item that left your filter, say — nothing is in flight: the endpoint stays failing and you are not notified. 24 hours after failing_since — the first of its failures in a row — its next attempt is a reset (behind), unless a new message is sent first: a 2xx makes it active, and a failure starts the count of failures again.
After 5 days of failuresThe endpoint is paused (pause_reason failing), and you are notified.
A 410The endpoint is paused at once (pause_reason gone), and you are notified.

Your failures and Quanor's. Any response other than 2xx counts as a failure of your server: a redirect, a 401, a 404 or a 500 alike. So does no response (timeout, connection_failed, tls_error, dns_failed, refused_address). A 410 pauses the endpoint at once, and a 413 is handled as below. Failures in a row count towards the notice and the pause, and set failing_since: from the first since your server last responded 2xx, or since the endpoint last had nothing in flight. Outages on Quanor's side, such as its feed or its signing secrets being unavailable, never count against you: the endpoint waits and catches up. When Quanor could not send for more than 24 hours, it starts again with a reset (behind).

Too large. A 413 halves the number of events a message holds, and a message of one item is sent as its refetch; neither counts as a failure. A 413 to a reset or to a refetch does. The limit doubles again for each hour without a 413, up to 200 (tracks[].max_events).

Pausing and resuming. POST /v1/webhook-endpoints/{webhook_endpoint_id}/pause stops all messages until you resume. Your pause takes precedence over one Quanor made, and only you can resume it. POST /v1/webhook-endpoints/{webhook_endpoint_id}/resume resumes a paused endpoint, starting with a reset (resumed). An endpoint paused because your key lost webhook access (pause_reason key) resumes by itself when the key has it again.

Rewinding sends again what changed after a point you name, for a copy restored from a backup (Catching up and pulling).

Catching up and pulling

After downtime, you do nothing. Quanor keeps each endpoint's place. When your server responds again, it is sent everything since, each item at its latest state; after a long failure, a reset instead.

Pulling. GET /v1/feed returns a page of the items an endpoint receives. POST /v1/feed/since returns the changes after a cursor: pass the through of the last feed message you applied. Read them with the selection the endpoint's filter makes and its lang, and without the pages' own filters (relevance_floor, announcement_kind, story_treatment, reports, inside_information), which an endpoint does not have:

The endpoint'sIn the selection
event_typesitem_kinds: each wildcard's kind, announcement.* as announcement
companies, markets, part_kinds, teasersThe same fields; leave out one the endpoint holds as null
langThe lang query parameter

For an endpoint {"event_types": ["announcement.*"], "companies": [28, 706], "lang": "sv"}:

GET /v1/feed?lang=sv&selection={"item_kinds":["announcement"],"companies":[28,706]}
(the selection URL-encoded)
POST /v1/feed/since?lang=sv
{"since": "<the through you hold>",
 "selections": {"feed": {"item_kinds": ["announcement"], "companies": [28, 706]}},
 "include_event_ids": true}

Send "include_event_ids": true, as above: each item POST /v1/feed/since returns then includes the event_type and event_id of the matching webhook event, the same id as the pushed event for the same key, access and language. So you can pull to check what you were pushed, and apply both by the same rules, de-duplicating by id. Without it, the items carry neither. A refetch is an event of its own, with its own id.

After restoring a backup: rewind

A copy restored from a backup has lost what it applied after the backup. Rewind the endpoint with POST /v1/webhook-endpoints/{webhook_endpoint_id}/tracks/feed/rewind, naming either since, the through you kept with the copy, or since_at, a time (ISO 8601 with its offset). A rewind starts a new generation and resends the latest state of every item changed after that point, not every state in between. It also resumes a paused endpoint. If the last message in your backup was a reset, reload your copy instead of rewinding.

Quanor reads since_at 15 minutes early, so that no change written around that time is missed: a rewind by time resends up to 15 minutes more than you asked. For the same reason, a since_at within about 15 minutes after the endpoint's last reset reaches back past it, and is rejected with before_reset. Reload your copy instead, or rewind by since.

POST /v1/webhook-endpoints/{webhook_endpoint_id}/tracks/feed/rewind
{"since": "<the through you kept>"}

POST /v1/webhook-endpoints/{webhook_endpoint_id}/tracks/feed/rewind
{"since_at": "2026-10-24T06:00:00.000000Z"}

A rewind can be rejected with 409:

codeMeaningWhat to do
before_resetYour cursor is at or before the endpoint's last reset, which reloaded your copy.Reload your copy from /v1/feed.
cursor_not_in_epochYour cursor is from before Quanor's feed was restored.Reload your copy.
cursor_aheadYour cursor is past where the endpoint stands.Nothing: a rewind only goes back.
reset_pendingA reset is about to reload your copy anyway.Nothing.

Rotating the signing secret

  1. Rotate: POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/rotate, with overlap_s (default 86400, 24 hours; at most 7 days). The response carries the new secret, shown only once and marked not to be cached, and secrets: each secret that signs, with the old one's expires_at.
  2. Deploy the new secret beside the old one, verifying with either. Within a minute, every message carries two signatures, one for each secret, until the overlap ends.
  3. Retire the old one: POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/{webhook_endpoint_secret_id}/retire, or let the overlap end at its expires_at. Messages stop carrying its signature within a minute. Then remove it from your server.

At most 2 secrets sign at once (409 rotation_in_progress): retire one before you rotate again. The last secret cannot be retired (409 last_secret). Retiring the new secret during the overlap undoes the rotation.

If someone else may have the secret, rotate with overlap_s 0, which retires the others at once, and deploy the new secret immediately. For up to a minute, messages may still carry only the old secret's signature. Your server rejects them, and Quanor sends them again, so nothing is lost.

Networking

The URL must be an absolute https:// URL on port 443. Its host must be a public domain name, not an IP address, with no user name, password or #fragment. An international domain name is stored in punycode, as it is sent, and a URL is at most 2,048 characters as stored. Quanor checks it when you create or change the endpoint, and again before every message. A URL that breaks a rule is rejected with 422, and errors[].type names the rule. If the host later resolves to an address that is not public, that attempt fails (refused_address).

errors[].typeThe rule
url_invalidThe URL is not a valid URL.
url_control_charactersThe URL must not contain control characters.
url_not_httpsThe URL must be an absolute https:// URL with a host.
url_portThe URL's port must be 443, or omitted.
url_credentialsThe URL must not contain a user name or password. Authenticate messages by their signature.
url_fragmentThe URL must not contain a #fragment: fragments are never sent.
url_ip_addressThe URL's host must be a domain name, not an IP address.
url_hostThe URL's host must be a valid domain name: labels of letters, digits and hyphens, each at most 63 characters, with no trailing dot.
url_private_hostThe URL's host must be public. It cannot be a one-label or special-use name, and it must resolve only to public addresses.
url_unresolvedThe URL's host does not resolve.
url_lengthThe URL must be 9 to 2,048 characters long as stored, with an international host in punycode.

A path and query already percent-encoded are kept exactly as you wrote them, so the URL can carry a token your server checks, even a signed one. A space or a character outside ASCII is percent-encoded. Treat the whole URL as a secret: Quanor logs no URL, its notices never include one, and the API shows it to your key alone.

TLS 1.2 or later, with a certificate from a publicly trusted authority, valid for the host name; HTTP/1.1. Redirects are not followed: a 3xx is a failure, so give the URL that answers. Requests are uncompressed and send Accept-Encoding: identity.

Size. A message is at most 256 KiB and holds at most 200 events; an item larger than that is sent alone. No message is larger than 1 MiB: accept that much, or respond 413.

Sending addresses. Quanor does not send from fixed IP addresses yet, so an allow-list of addresses cannot work. Authenticate each message by its signature, which is what proves it comes from Quanor. When fixed addresses come, this guide will list them, and changes to them will be announced ahead.

Local development. Quanor sends only to public https on port 443, so localhost and private addresses are rejected. Use a tunnelling service to give your local server a public https URL, and create a separate endpoint for it.

The event catalog

An endpoint names the kinds it receives in event_types, each by its wildcard: announcement.* or pre_earnings_analysis.*. A wildcard selects every type of its kind. You can't receive updated without hidden and withdrawn: a copy that never hears of removals goes wrong. GET /v1/event-types lists every type, its schemas and whether your key can receive it; /api/docs/events (JSON) serves every type's schemas and an example.

TypeWhat it means
<kind>.updatedThe item as it now stands. It may be the first event you see for it: store it, replacing what you hold.
<kind>.withdrawnThe item was merged into, or replaced by, another: data.replaced_by names it when your key can see it, else null. Remove the item and keep its tombstone.
<kind>.hiddenYour key no longer sees the item: access to its company was restricted, or it no longer passes the endpoint's filter. Remove it and keep its tombstone.
webhook_endpoint.resetReload your copy (Resets).
webhook_endpoint.testedA test you asked for. With a type named, data.example holds an example event of that type. It is never a change: do not apply it.

An announcement's data holds its company, its head (title, kind, whether it is inside information) and its parts: its flash rows (flash), its story (story: a Story or a Brief, by its treatment, full or brief; a report's is its report story, whose part carries report). It carries headlines and first sentences, not a story's full text. Read that from GET /v1/analyses/{analysis_type}/{analysis_id} (STORY, or REPORT for a part with report, and the part's story_id); each such read counts against your key's max_analyses_per_day. A pre-earnings analysis's data holds it under pre_earnings. Without detail access (can_access_detail), an announcement carries its first flash headline and how many flash rows there are. With teasers, items of companies outside your key's scope arrive as teasers.

Versions

An endpoint stays on the schema version each type had when you named it (event_type_versions). Within a version, Quanor may add optional fields to data, new values to open fields (reason, state, payload, track) and new types to a wildcard. A field that changes its meaning or its type, or is removed, makes a new version of the type. An endpoint receives the version it holds until you move it to the next, by naming the version in event_type_versions on PATCH, which is offered once a type has a second version. Both versions are sent for at least 90 days. The API's versioning policy (on the feed stream's reference) covers the rest.

A type you do not know, Quanor's added since you wrote your code, is to be skipped. A client generated from the API's OpenAPI document by a generator that reads anyOf as a union (datamodel-code-generator does) reads it as an UnknownEvent: the document's events are each type's event and UnknownEvent, with no discriminator, so a new type never makes your client refuse the message. Skip an UnknownEvent, answer the message 2xx as usual, and generate the client again to read the new type. The message itself is the document's WebhookMessage.

Every type

announcement.updated

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_2qyd41b3mqbv451ksq45hkm6tf",
  "source": "https://api.quanor.com/v1/feed",
  "type": "announcement.updated",
  "subject": "announcement:88001",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/announcement.updated.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001045",
  "data": {
    "type": "item",
    "op": "changed",
    "seq": 1045,
    "first_seq": 1040,
    "changes": [],
    "access": "full",
    "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",
    "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",
    "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",
    "sort_at": "2026-10-08T06:00:00.300000Z",
    "superseded_parts": [
      "flash:9"
    ]
  },
  "companyid": 28
}

announcement.withdrawn

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_69rvmh4524xw1esa6n3rnk4ek6",
  "source": "https://api.quanor.com/v1/feed",
  "type": "announcement.withdrawn",
  "subject": "edition:5001",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/announcement.withdrawn.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001049",
  "data": {
    "feed_item_id": "edition:5001",
    "replaced_by": "announcement:88001",
    "seq": 1049,
    "type": "withdrawn"
  }
}

announcement.hidden

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_1zrs9j3563b1tqssanx7rs4ajv",
  "source": "https://api.quanor.com/v1/feed",
  "type": "announcement.hidden",
  "subject": "announcement:88001",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/announcement.hidden.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001048",
  "data": {
    "feed_item_id": "announcement:88001",
    "seq": 1048,
    "type": "hidden"
  }
}

pre_earnings_analysis.updated

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_11zfe0qfv79kw883qrwpm0t22s",
  "source": "https://api.quanor.com/v1/feed",
  "type": "pre_earnings_analysis.updated",
  "subject": "pre_earnings_analysis:28:Q3-2026",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/pre_earnings_analysis.updated.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001046",
  "data": {
    "type": "item",
    "op": "changed",
    "seq": 1046,
    "first_seq": 1045,
    "changes": [],
    "access": "full",
    "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",
    "item_kind": "pre_earnings_analysis",
    "lang": "en",
    "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",
    "sort_at": "2026-10-08T06:00:00.300000Z"
  },
  "companyid": 28
}

pre_earnings_analysis.withdrawn

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_77hpxf8fjhj8k0d86f6zfnsnyf",
  "source": "https://api.quanor.com/v1/feed",
  "type": "pre_earnings_analysis.withdrawn",
  "subject": "pre_earnings_analysis:28:Q3-2026",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/pre_earnings_analysis.withdrawn.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001049",
  "data": {
    "feed_item_id": "pre_earnings_analysis:28:Q3-2026",
    "replaced_by": null,
    "seq": 1049,
    "type": "withdrawn"
  }
}

pre_earnings_analysis.hidden

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_2qjkr5jh24c2nh7gnb3zvqyv7m",
  "source": "https://api.quanor.com/v1/feed",
  "type": "pre_earnings_analysis.hidden",
  "subject": "pre_earnings_analysis:28:Q3-2026",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/pre_earnings_analysis.hidden.v1.data.schema.json",
  "sequence": "0000000001.0000000000000001048",
  "data": {
    "feed_item_id": "pre_earnings_analysis:28:Q3-2026",
    "seq": 1048,
    "type": "hidden"
  }
}

webhook_endpoint.reset

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_5pmyrx19hfgetfs9bjfnfwnnhq",
  "source": "https://api.quanor.com/v1/webhook-endpoints",
  "type": "webhook_endpoint.reset",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/webhook_endpoint.reset.v1.data.schema.json",
  "data": {
    "track": "feed",
    "generation": 4,
    "reason": "access_changed",
    "resume_from": "1.1052",
    "access_version": "1e04d786c6f40408d816b51cd62d63ee"
  }
}

webhook_endpoint.tested

Its JSON Schema: the event, its data.

Example
{
  "specversion": "1.0",
  "id": "evt_6388rapyghjsmtvn07e915scek",
  "source": "https://api.quanor.com/v1/webhook-endpoints",
  "type": "webhook_endpoint.tested",
  "datacontenttype": "application/json",
  "dataschema": "https://api.quanor.com/api/docs/events/webhook_endpoint.tested.v1.data.schema.json",
  "data": {
    "test_send_id": "5f0e3c1a-7b2d-4e8f-9a6b-1c2d3e4f5a6b"
  }
}

Managing endpoints

FieldWhat it says
urlWhere messages are sent (Networking).
event_typesThe kinds an endpoint receives, by their wildcards; at most 64 names. Omitted: announcement.*, pre_earnings_analysis.*.
companiesOnly events about these companies, at most 2,000; null means every company your key can see. One outside your key's scope is rejected (403) unless teasers is true; one that does not exist, with 422 unknown_companies.
marketsOnly companies on these markets (market_exchange ids, as GET /v1/companies names them), at most 64; null means every market.
teasersWhether items of companies outside your key's scope arrive as teasers.
part_kindsSend an announcement only once it holds one of these parts: flash, story; null means every announcement.
langThe language events are written in: en or sv. Omitted: en.
descriptionYour own label for it, at most 500 characters.

Each endpoint also shows, for each of its tracks: its cursor, how far behind it is (lag), a reset about to be sent (pending_reset_reason), the next attempt's time (next_attempt_at) and max_events.

Limits. A key has at most 10 endpoints (409 too_many_webhook_endpoints). An endpoint with the same url, filter and lang as another of your key's is rejected, on a create and on a change (409 duplicate_webhook_endpoint, its webhook_endpoint_id naming the other).

A create whose response you lost — a timeout, a dropped connection — may still have made the endpoint, and its secret is shown only in that response. Send the same create again: a 409 duplicate_webhook_endpoint names the endpoint it made in webhook_endpoint_id (or list your endpoints with GET /v1/webhook-endpoints and find it by its url). Then rotate its secret (POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/rotate) to receive one you hold.

Pace. Each minute, a key can make ten creates, rotations, changes of what is sent, resumes, rewinds and test sends, and thirty other writes (a description change, a pause, a secret retirement, a deletion). Past that, Quanor responds 429 commands_used_up, with Retry-After.

Changes. A new url, filter or lang starts again with a reset (endpoint_changed); while the endpoint is paused, the reset comes when you resume it. A description alone changes nothing that is sent. A message in flight when you change the URL may still arrive at the old one. Deleting an endpoint removes it and everything it holds — its secrets, attempts, test sends and notices — at once.

Test sends are sent within seconds, outside the endpoint's order of messages, and can arrive beside another message. Name an event_type the endpoint receives, and the test carries that type's example under data.example (422 event_type_not_sent for one it does not receive). Its message id is msg_ followed by the test send's id in hex digits. A test that cannot be sent — your key no longer has webhook access, or it is still unsent ten minutes after you asked — ends not_sent.

Errors

Every error has the same JSON body: detail says what is wrong in words, and code says it for a program. A request that did not validate (422) adds errors, each with its loc, msg and type. A 409 duplicate_webhook_endpoint adds webhook_endpoint_id. Read a code you do not know by its status alone.

HTTP/1.1 422 Unprocessable Entity

{
  "detail": "The request is not valid \u2014 url: The URL's port must be 443, or omitted..",
  "code": "invalid_request",
  "errors": [
    {
      "loc": [
        "body",
        "url"
      ],
      "msg": "The URL's port must be 443, or omitted.",
      "type": "url_port"
    }
  ]
}
StatuscodeMeaning
401unauthenticatedNo API key, or an unknown, revoked or expired one.
403forbiddenYour key's plan does not include webhooks (can_webhooks), or the filter names a company outside your key's scope without teasers. A key without can_webhooks can still read and delete its endpoints.
404not_foundNo such endpoint of your key, or no such track, secret or test send of it.
409duplicate_webhook_endpointAnother endpoint of your key has the same url, filter and lang; webhook_endpoint_id names it.
409too_many_webhook_endpointsYour key already has 10 endpoints.
409filter_unreadableThe endpoint's filter was written by a newer version of the API, as during a release. Retry in a few minutes.
409rotation_in_progressTwo secrets already sign: retire one before you rotate again.
409last_secretThe last secret that signs cannot be retired: rotate first.
409before_reset, cursor_not_in_epoch, cursor_ahead, reset_pendingA rewind was rejected (After restoring a backup).
413too_largeThe request body is over 256 KiB.
422invalid_requestThe request did not validate: errors names each problem. For a URL, errors[].type names the rule it broke (Networking).
422unknown_companiesThe filter names companies that do not exist.
422event_type_not_sentA test send names a type the endpoint does not receive.
429commands_used_upToo many writes this minute: wait for Retry-After.
503feed_unavailableQuanor's feed is briefly unavailable: retry after Retry-After.
503secrets_unavailableA signing secret cannot be made right now: retry after Retry-After.

Migrating from /v1/webhooks

/v1/webhooks is deprecated and is removed on 2026-11-09. Until then, each of its responses carries Deprecation and Sunset headers and a Link to /v1/webhook-endpoints. Webhook endpoints replace it:

/v1/webhooksWebhook endpoints
Eventsstory.published, story.corrected, story.updated, a report story's with report; analysis.created for flash rows; testannouncement.updated, whose data.parts hold the story (its version and reason, correction for a correction; a report story's with report) and the flash rows; announcement.hidden and announcement.withdrawn when one leaves; pre_earnings_analysis.*; webhook_endpoint.tested
BodyOne event; data the analysis as /v1/analyses returns it, or null without detail accessA message of up to 200 events, each a CloudEvent whose data is the item as /v1/feed/since returns it: headlines and first sentences. A story's full text is one read of /v1/analyses, which counts against your key's max_analyses_per_day
HeadersX-Quanor-Webhook-Id, X-Quanor-Event-Id, X-Quanor-Delivery-Attempt, X-Quanor-Timestamp, X-Quanor-Signaturewebhook-id (the message), webhook-timestamp, webhook-signature, quanor-webhook-attempt; the endpoint is the body's webhook_endpoint_id, and each event's id its id
Signaturev1=<hex HMAC-SHA256 of "{timestamp}.{body}">, keyed with the secret's text, comma-separatedv1,<base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{body}">, keyed with the bytes the base64 after whsec_ decodes to, space-separated
Filterscompany_ids, analysis_typesevent_types, companies, markets, part_kinds, teasers, and lang
Retries6 attempts, retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, each given 10 seconds; then given upRetried until it gets through, at most 1 hour apart; a reset after 24 hours of failures, paused after 5 days
OrderNone promised; one delivery per eventOne message in flight at a time; apply by generation and sequence

Your filter. company_ids becomes companies. analysis_types maps onto part_kinds, which selects announcements that hold such a part. It is not the same filter: such an announcement arrives whole, with its flash rows and its other parts. To act on a story's publication or correction, as story.published and story.corrected told you, decide by its part (Acting on what is new).

analysis_typesOn a webhook endpoint
STORYpart_kinds: ["story"]
REPORTpart_kinds: ["story"]: a report story's part carries report
FLASHpart_kinds: ["flash"]
PRE_EARNINGSevent_types: ["pre_earnings_analysis.*"]
  1. Create a webhook endpoint with the same companies, and event_types ["announcement.*"] for stories, report stories and flash rows.
  2. Verify the new signature (Verifying a message) and apply the events (Keeping your copy in sync) beside your current receiver. Event ids differ between the two, so don't de-duplicate across them.
  3. Once the new one is live, delete the old webhook, DELETE /v1/webhooks/{webhook_id}, before 2026-11-09.

Troubleshooting

Where to look. GET /v1/webhook-endpoints/{webhook_endpoint_id} shows the endpoint's state (active, failing or paused), its pause_reason and failing_since, and for each of its tracks: cursor, lag, pending_reset_reason, next_attempt_at and max_events.

GET /v1/webhook-endpoints/{webhook_endpoint_id}/attempts lists its attempts, newest first: every failure, kept 14 days, and a sample of successes, kept a day; at most the newest 1,000. Each attempt lists:

You seeUsuallyWhat to do
No messages at allThe endpoint is paused, or failing and backing offCheck state and pause_reason; resume it
The first reset never cameYour server rejected it before it had the secret, and the retries back offPause and resume the endpoint
The same event twiceDelivery is at least onceDe-duplicate by event id
401 or 403 from youYour verification failedVerify the raw body, not parsed JSON; check the secret; check your clock
404 or 405 from youThe URL's path or your route is wrongCorrect the URL (PATCH the endpoint)
A 3xxA redirect, which is not followedGive the URL that answers
timeoutNo whole response in timeRespond before you process
tls_errorThe certificate is not publicly trusted, has expired or names another host; or the server offers no TLS 1.2 or laterFix the certificate or the TLS settings
dns_failedThe host does not resolveFix its DNS
refused_addressThe host resolves to an address that is not publicServe it on a public address
connection_failedNothing accepts on port 443, or a firewall drops QuanorOpen port 443 to all, and authenticate by signature
413 from youYour body limit is below the messageAccept bodies up to 1 MiB
410 from youThe endpoint is paused (gone)Resume it when you are ready

Notices. GET /v1/webhook-endpoints/{webhook_endpoint_id}/notices lists the endpoint's notices — your server failing for 24 hours, the endpoint paused — and their outcomes, including ones not emailed, newest first, kept 30 days. Each notice is emailed to your key's notice address, from webhooks@quanor.com. It names the endpoint by its id and your key by its name, since when your server has failed, its last response and when the endpoint will be paused. It never includes your URL or description. At most 30 notices are emailed for each API key a UTC day, whichever of its endpoints they are about. has_notice_address says whether your key has one. Without one, nothing is emailed: ask Quanor (support@quanor.com, where notices' replies go) to set one on your key.

To skip a retry's wait, pause and resume the endpoint: it starts again at once, with a reset (resumed).

The signature never matches though the secret is right: a proxy or a framework changed the body before you verified it, you verified parsed JSON, your clock is off by more than 5 minutes, or — during a rotation — your server holds only one of the two secrets.

Reference

All webhook routes, and the event catalog's. Each takes your API key as a bearer token.

RouteWhat it does
GET /v1/webhook-endpointsList webhook endpoints
POST /v1/webhook-endpointsCreate a webhook endpoint
GET /v1/webhook-endpoints/{webhook_endpoint_id}Retrieve a webhook endpoint
PATCH /v1/webhook-endpoints/{webhook_endpoint_id}Update a webhook endpoint
DELETE /v1/webhook-endpoints/{webhook_endpoint_id}Delete a webhook endpoint
POST /v1/webhook-endpoints/{webhook_endpoint_id}/pausePause a webhook endpoint
POST /v1/webhook-endpoints/{webhook_endpoint_id}/resumeResume a webhook endpoint
POST /v1/webhook-endpoints/{webhook_endpoint_id}/tracks/{track}/rewindRewind a webhook endpoint's track
POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/rotateRotate a webhook endpoint's secrets
POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/{webhook_endpoint_secret_id}/retireRetire a webhook endpoint's secret
POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-sendsSend a webhook endpoint a test
GET /v1/webhook-endpoints/{webhook_endpoint_id}/test-sends/{test_send_id}Retrieve a webhook endpoint's test send
GET /v1/webhook-endpoints/{webhook_endpoint_id}/attemptsList a webhook endpoint's attempts
GET /v1/webhook-endpoints/{webhook_endpoint_id}/noticesList a webhook endpoint's notices
GET /v1/event-typesList event types