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.
webhook_endpoint.reset asks you to reload your copy from GET /v1/feed.generation and sequence, never by when it arrived.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.
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.
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_…",
…
}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"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"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'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}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.
await request.body(), Express's express.raw), not through a JSON parser (request.json(), express.json(), Django REST framework's request.data).whsec_ followed by base64. The HMAC's key is the bytes that base64 decodes to, not the text.{webhook-id}.{webhook-timestamp}.{body} — the two headers and the raw body, joined by . — with HMAC-SHA256, and base64-encode the result.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.webhook-timestamp more than 5 minutes from your clock, either way. Keep your server's clock synchronised.webhook-id of every message you accept. A message whose id you have stored is a duplicate: respond 2xx and do nothing more.| Header | What it holds |
|---|---|
webhook-id | The 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-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | Space-separated v1,<base64> values, one for each secret that signs. |
quanor-webhook-attempt | The 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-type | application/json |
user-agent | Quanor-Webhooks/1 |
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 Falseimport { 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.
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 FalseIn 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 requestverify.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);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"])).
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 response | What Quanor does |
|---|---|
| 2xx | Delivered: the next message follows. |
| 410 | Stops: the endpoint is paused (pause_reason gone) until you resume it, and you are notified. Respond 410 only when you mean it. |
| 413 | The 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-After | Sent 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 seconds | Sent 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.
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.
| Field | What it holds |
|---|---|
v | 1: the message format's version. |
webhook_endpoint_id | The endpoint the message was sent for. Verify with that endpoint's secrets, and keep a copy for each endpoint you receive. |
track | The 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. |
generation | A 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, through | The 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. |
complete | true if this message brought you up to the latest change when it was built. You don't need to act on it. |
access_version | Identifies 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. |
events | The events, each a CloudEvent. A reset message holds one webhook_endpoint.reset, a test one webhook_endpoint.tested. |
{
"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.
| Attribute | What it holds |
|---|---|
id | evt_…, 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. |
type | What happened: route by it (The event catalog). |
subject | The 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. |
sequence | Where the change stands among the item's changes: fixed-width text, so that within one generation the later change sorts higher as a string. |
companyid | The company, when the event names one: not on withdrawn and hidden, nor on an event whose data.type is refetch. |
source | The API URL the subject can be read from. |
specversion | Always 1.0. |
datacontenttype | Always application/json. |
dataschema | The JSON Schema of data. |
data | The item as POST /v1/feed/since returns it in its frames, for your key's access and the endpoint's lang. |
track (Responding to messages).generation is lower than the newest you hold.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.type, and ignore a type, a track or a field you do not know: new ones are added without notice (Versions).subject, with the generation and sequence you applied it at.<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.<kind>.hidden or <kind>.withdrawn, remove the item but keep a tombstone: its subject, with the event's generation and sequence.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.generation and drops the older ones (Resets).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.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.
GET /v1/analyses/{analysis_type}/{analysis_id}.generation, then sequence — never by when it arrived; and still apply messages one at a time in the order you stored them, which keeps a reset before everything sent after it.data.changes is always empty in webhook events. Each updated holds the whole item: to see what changed, compare its parts with the ones you hold, by part_id and part_version.data.op (new or changed) is a hint, not a promise: an item you do not hold is new to you, whatever it says.history_days), an item older than the window is no longer updated or removed: remove it yourself once its sort_at falls outside./v1/feed too — until its next change, which sends it; a reload does not bring it back sooner. The rest a reset corrects: an item first written more than a day after its date is sent when it next changes; an item that moved into and out of your filter more than once after your cursor, with a page you read in between, or left your filter while hidden from your key and was shown again, can stay in your copy as it was; and an item that changed more than 8 times in ways that could take it out of your filter can miss its removal. /v1/feed/since and the feed stream share these cases. A reset's reload corrects each of the last three: to bound how long one can last, pause and resume the endpoint on a schedule (nightly, say), which sends a reset (resumed).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:
part_id) whose created_at is recent.part_id) whose first_published_at is recent, or a later version of one (a higher version) whose reason is correction.edition:<id>) is later withdrawn and replaced by announcement:<id>, which lists the edition in head.edition_ids: recognise it there, and don't act on it twice.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 seqA 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.
webhook-id.GET /v1/webhook-endpoints/{webhook_endpoint_id}, for its filter and lang as they are now.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.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.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.
reason | What caused it |
|---|---|
created | The endpoint was created: load your copy for the first time. |
endpoint_changed | You changed its url, its filter or its lang. |
resumed | It was resumed after a pause — yours, or Quanor's after failures, a 410 or your key losing webhook access — or changed while paused. |
access_changed | What your API key can see changed: its plan, its companies or its history window. |
behind | Reloading 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_timeout | Catching you up from far behind took too long, so a reload replaces it. |
epoch_changed | Quanor's feed was restored, so the cursors of your copy no longer apply. |
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.
| When | What happens |
|---|---|
| After a failure | The 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 attempt | The 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 row | The 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 send | When 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 failures | The endpoint is paused (pause_reason failing), and you are notified. |
| A 410 | The 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).
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's | In the selection |
|---|---|
event_types | item_kinds: each wildcard's kind, announcement.* as announcement |
companies, markets, part_kinds, teasers | The same fields; leave out one the endpoint holds as null |
lang | The 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.
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:
code | Meaning | What to do |
|---|---|---|
before_reset | Your cursor is at or before the endpoint's last reset, which reloaded your copy. | Reload your copy from /v1/feed. |
cursor_not_in_epoch | Your cursor is from before Quanor's feed was restored. | Reload your copy. |
cursor_ahead | Your cursor is past where the endpoint stands. | Nothing: a rewind only goes back. |
reset_pending | A reset is about to reload your copy anyway. | Nothing. |
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.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.
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[].type | The rule |
|---|---|
url_invalid | The URL is not a valid URL. |
url_control_characters | The URL must not contain control characters. |
url_not_https | The URL must be an absolute https:// URL with a host. |
url_port | The URL's port must be 443, or omitted. |
url_credentials | The URL must not contain a user name or password. Authenticate messages by their signature. |
url_fragment | The URL must not contain a #fragment: fragments are never sent. |
url_ip_address | The URL's host must be a domain name, not an IP address. |
url_host | The 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_host | The 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_unresolved | The URL's host does not resolve. |
url_length | The 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.
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.
| Type | What it means |
|---|---|
<kind>.updated | The item as it now stands. It may be the first event you see for it: store it, replacing what you hold. |
<kind>.withdrawn | The 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>.hidden | Your 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.reset | Reload your copy (Resets). |
webhook_endpoint.tested | A 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.
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.
announcement.updatedIts JSON Schema: the event, its data.
{
"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.withdrawnIts JSON Schema: the event, its data.
{
"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.hiddenIts JSON Schema: the event, its data.
{
"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.updatedIts JSON Schema: the event, its data.
{
"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.withdrawnIts JSON Schema: the event, its data.
{
"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.hiddenIts JSON Schema: the event, its data.
{
"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.resetIts JSON Schema: the event, its data.
{
"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.testedIts JSON Schema: the event, its data.
{
"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"
}
}| Field | What it says |
|---|---|
url | Where messages are sent (Networking). |
event_types | The kinds an endpoint receives, by their wildcards; at most 64 names. Omitted: announcement.*, pre_earnings_analysis.*. |
companies | Only 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. |
markets | Only companies on these markets (market_exchange ids, as GET /v1/companies names them), at most 64; null means every market. |
teasers | Whether items of companies outside your key's scope arrive as teasers. |
part_kinds | Send an announcement only once it holds one of these parts: flash, story; null means every announcement. |
lang | The language events are written in: en or sv. Omitted: en. |
description | Your 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.
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"
}
]
}| Status | code | Meaning |
|---|---|---|
| 401 | unauthenticated | No API key, or an unknown, revoked or expired one. |
| 403 | forbidden | Your 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. |
| 404 | not_found | No such endpoint of your key, or no such track, secret or test send of it. |
| 409 | duplicate_webhook_endpoint | Another endpoint of your key has the same url, filter and lang; webhook_endpoint_id names it. |
| 409 | too_many_webhook_endpoints | Your key already has 10 endpoints. |
| 409 | filter_unreadable | The endpoint's filter was written by a newer version of the API, as during a release. Retry in a few minutes. |
| 409 | rotation_in_progress | Two secrets already sign: retire one before you rotate again. |
| 409 | last_secret | The last secret that signs cannot be retired: rotate first. |
| 409 | before_reset, cursor_not_in_epoch, cursor_ahead, reset_pending | A rewind was rejected (After restoring a backup). |
| 413 | too_large | The request body is over 256 KiB. |
| 422 | invalid_request | The request did not validate: errors names each problem. For a URL, errors[].type names the rule it broke (Networking). |
| 422 | unknown_companies | The filter names companies that do not exist. |
| 422 | event_type_not_sent | A test send names a type the endpoint does not receive. |
| 429 | commands_used_up | Too many writes this minute: wait for Retry-After. |
| 503 | feed_unavailable | Quanor's feed is briefly unavailable: retry after Retry-After. |
| 503 | secrets_unavailable | A signing secret cannot be made right now: retry after Retry-After. |
/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/webhooks | Webhook endpoints | |
|---|---|---|
| Events | story.published, story.corrected, story.updated, a report story's with report; analysis.created for flash rows; test | announcement.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 |
| Body | One event; data the analysis as /v1/analyses returns it, or null without detail access | A 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 |
| Headers | X-Quanor-Webhook-Id, X-Quanor-Event-Id, X-Quanor-Delivery-Attempt, X-Quanor-Timestamp, X-Quanor-Signature | webhook-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 |
| Signature | v1=<hex HMAC-SHA256 of "{timestamp}.{body}">, keyed with the secret's text, comma-separated | v1,<base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{body}">, keyed with the bytes the base64 after whsec_ decodes to, space-separated |
| Filters | company_ids, analysis_types | event_types, companies, markets, part_kinds, teasers, and lang |
| Retries | 6 attempts, retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, each given 10 seconds; then given up | Retried until it gets through, at most 1 hour apart; a reset after 24 hours of failures, paused after 5 days |
| Order | None promised; one delivery per event | One 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_types | On a webhook endpoint |
|---|---|
STORY | part_kinds: ["story"] |
REPORT | part_kinds: ["story"]: a report story's part carries report |
FLASH | part_kinds: ["flash"] |
PRE_EARNINGS | event_types: ["pre_earnings_analysis.*"] |
event_types ["announcement.*"] for stories, report stories and flash rows.DELETE /v1/webhooks/{webhook_id}, before 2026-11-09.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:
message_id (the webhook-id), its type (events or reset) and its track;events and event_ids, the events it carried;attempt, the attempt number;status, or a failure reason if no response arrived;latency_ms, and response_excerpt, the first 1 KiB of your response body, redacted;next_attempt_at, when the next attempt is due.| You see | Usually | What to do |
|---|---|---|
| No messages at all | The endpoint is paused, or failing and backing off | Check state and pause_reason; resume it |
| The first reset never came | Your server rejected it before it had the secret, and the retries back off | Pause and resume the endpoint |
| The same event twice | Delivery is at least once | De-duplicate by event id |
| 401 or 403 from you | Your verification failed | Verify the raw body, not parsed JSON; check the secret; check your clock |
| 404 or 405 from you | The URL's path or your route is wrong | Correct the URL (PATCH the endpoint) |
| A 3xx | A redirect, which is not followed | Give the URL that answers |
timeout | No whole response in time | Respond before you process |
tls_error | The certificate is not publicly trusted, has expired or names another host; or the server offers no TLS 1.2 or later | Fix the certificate or the TLS settings |
dns_failed | The host does not resolve | Fix its DNS |
refused_address | The host resolves to an address that is not public | Serve it on a public address |
connection_failed | Nothing accepts on port 443, or a firewall drops Quanor | Open port 443 to all, and authenticate by signature |
| 413 from you | Your body limit is below the message | Accept bodies up to 1 MiB |
| 410 from you | The 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.
All webhook routes, and the event catalog's. Each takes your API key as a bearer token.
| Route | What it does |
|---|---|
GET /v1/webhook-endpoints | List webhook endpoints |
POST /v1/webhook-endpoints | Create 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}/pause | Pause a webhook endpoint |
POST /v1/webhook-endpoints/{webhook_endpoint_id}/resume | Resume a webhook endpoint |
POST /v1/webhook-endpoints/{webhook_endpoint_id}/tracks/{track}/rewind | Rewind a webhook endpoint's track |
POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/rotate | Rotate a webhook endpoint's secrets |
POST /v1/webhook-endpoints/{webhook_endpoint_id}/secrets/{webhook_endpoint_secret_id}/retire | Retire a webhook endpoint's secret |
POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-sends | Send 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}/attempts | List a webhook endpoint's attempts |
GET /v1/webhook-endpoints/{webhook_endpoint_id}/notices | List a webhook endpoint's notices |
GET /v1/event-types | List event types |
webhook_message.v1.json: a signed example of every message, the limits and how each answer is taken.webhook_message.v1.schema.json: the messages' JSON Schema./api/docs/events (JSON): every event type's JSON Schemas and an example.