{
  "markdown": "<div align=\"center\">\n\n# Quorum\n\n**Market evidence with receipts.**\n\nGive it a subject, get back what a market actually says. Every claim carries\nreceipt ids, and every receipt id resolves to a real stored record you can fetch\nback. Fabricated citations are structurally impossible, and a test proves it\nrather than a README asserting it.\n\n[![CI](https://github.com/Godzilla-lab/Quorum-API/actions/workflows/ci.yml/badge.svg)](https://github.com/Godzilla-lab/Quorum-API/actions/workflows/ci.yml)\n[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%3E%3D22.18-brightgreen.svg)](https://nodejs.org)\n[![npm](https://img.shields.io/npm/v/quorum-api.svg)](https://www.npmjs.com/package/quorum-api)\n[![Tests](https://img.shields.io/badge/tests-1%2C233-brightgreen.svg)](#development)\n[![Runtime dependencies](https://img.shields.io/badge/runtime%20dependencies-1-brightgreen.svg)](#requirements)\n[![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-6ba539.svg)](spec/openapi.yaml)\n\n</div>\n\n> [!TIP]\n> The interesting part is not that it finds evidence. It is that you can check\n> it. Every id in every answer resolves through `GET /v1/evidence/{id}`, and\n> `POST /v1/verify` will re-resolve a set of claims against the corpus, ours or\n> anybody else's. An id that does not resolve is a claim that was never real.\n\n```json\n{\n  \"finding\": \"Sizing runs small, and buyers only discover it after delivery\",\n  \"corroboration\": { \"records\": 31, \"channels\": 5 },\n  \"receipts\": [\"rc_8f2a1\", \"rc_44b0e\", \"rc_91c37\"],\n  \"confidence\": \"finding\"\n}\n```\n\n`GET /v1/evidence/rc_8f2a1` returns the actual comment, its score, where it was\nposted, when, and its permalink. A customer of your customer can click a claim\nand read the human who said it.\n\nThe hosted API lives at **https://quorum-api-j15n.onrender.com**, on a free\ntier instance that sleeps when idle, so the first request after a quiet spell\nwaits a few seconds for it to wake.\n\n**The hosted instance is keyed while the API is early.** The\n[API root](https://quorum-api-j15n.onrender.com) says how to request a key,\nand always says which mode the instance you are talking to is in. The health\nendpoint needs no key, so you can check it is alive right now:\n\n```bash\ncurl https://quorum-api-j15n.onrender.com/v1/healthz\n```\n\nSelf hosting supports open mode or per key auth, quotas, tenancy and webhook\nsecrets, all self served from `GET /v1/usage`.\n\n> [!NOTE]\n> On Windows, the curl bundled with Git Bash uses the Schannel TLS stack,\n> which refuses any HTTPS connection when it cannot reach the certificate\n> revocation servers (`CRYPT_E_REVOCATION_OFFLINE`, common behind corporate\n> proxies and VPNs). That is the network, not this API. Add\n> `--ssl-revoke-best-effort` to the curl command, or call from WSL or\n> PowerShell instead.\n\n## Contents\n\n- [What it does](#what-it-does)\n- [Use cases](#use-cases)\n- [Status](#status)\n- [Requirements](#requirements)\n- [Quick start](#quick-start)\n- [Usage](#usage)\n  - [CLI](#cli)\n  - [HTTP API](#http-api)\n  - [JavaScript SDK](#javascript-sdk)\n  - [Python SDK](#python-sdk)\n  - [MCP server](#mcp-server)\n  - [Webhooks](#webhooks)\n  - [Running the API yourself](#running-the-api-yourself)\n  - [Postgres](#postgres)\n- [What the API does](#what-the-api-does)\n- [Why this is different](#why-this-is-different)\n- [Documentation](#documentation)\n- [Contributing](#contributing)\n- [License](#license)\n- [Acknowledgements](#acknowledgements)\n\n## What it does\n\nVoice of customer from public archives, competitor ads ranked by how long they\nhave actually been running, and a video versus static verdict computed as\narithmetic rather than opinion. It keeps everything it reads, so the second\nquestion about a market is nearly free and the archive can be asked about the\npast.\n\n**Key capabilities**\n\n- **Receipts, not links.** A stable id into a retained corpus, which resolves\n  identically forever, including after the source deletes the original.\n- **Corroboration as arithmetic.** A claim needs at least three independent\n  records before it prints as a finding, and the count travels with it.\n- **Ten sources behind one interface**, from a volunteer Reddit archive and\n  GitHub issue search to four government safety regulators, each degrading\n  rather than failing when unconfigured.\n- **A corpus that compounds.** Cold retrieval measured 596 seconds and about 500\n  throttled requests. The same category answers in half a second once warm.\n- **Offline mode** that touches no network and costs nothing.\n- **Fifteen HTTP operations, a typed SDK, an MCP server and a CLI**, all the same\n  pipeline.\n\n### What it looks like\n\nReal output, excerpted from a cold `npx quorum \"espresso machine\"` run on\n2026-08-24: 401 records in 227 seconds for $0.00.\n\n```\nEVIDENCE  a claim needs 3 independent receipts to be stated as a finding\n          the percentage is share of all 401 records held for this category\n\n  quality     88 receipts /  64 channels  21.9%  A0 B3 C85 D0   [finding]\n      \"I often hear the priority list being 1. Quality of beans 2. Quality\n       of the grinder 3. Quality of the espresso machine...\"\n         hackernews Why a spritz of water before grinding coffee yields\n         better results   rc_4d6d444821b0044f\n\nATTESTED  7 records from 6 named parties   [finding]\n  A named party stated this to a regulator, on the record, with consequences\n  for lying. Two of these are a finding on their own.\n\n      \"CPSC And Krups Announce Action On Espresso Makers...\"\n         cpsc Krups   rc_a4883bfb1b04d1c1\n\nRECEIPTS  195 cited, 195 resolved back to real records\n\nCOST      $0.0000 in 227.3s\n```\n\nEvery id in that output is fetchable from the corpus that run wrote. If an id\ndoes not resolve, the run exits non zero and says which one.\n\n## Use cases\n\n- **Product and brand research.** What buyers actually complain about, with the\n  comment behind every claim, rather than a summary you have to trust.\n- **Competitive teardown.** One full retrieval per rival, so shares are compared\n  instead of counts, and co-occurrence is never mistaken for a signal.\n- **Ad creative decisions.** Video versus static judged by how long real\n  campaigns ran, from dated observations rather than from a platform's own\n  label.\n- **Agent tooling that can be audited.** An MCP server whose every claim the\n  calling model can independently resolve, which is the one thing a research\n  tool cannot fake.\n- **Diligence and monitoring.** Attested records a named party filed with a\n  regulator, and a diff against the last report for the same subject.\n\n## Status\n\n| | |\n|---|---|\n| **Engine** | Working. 1,233 tests, offline and keyless |\n| **CLI** | Working, every flag |\n| **MCP server** | Working, five tools over stdio, four of them also remote at `/mcp` |\n| **JavaScript SDK** | Working, 11 methods |\n| **Python SDK** | **Published**: `pip install quorum-api`. 14 methods, standard library only |\n| **Webhooks** | Working. Signed to Standard Webhooks, durable, retried for about 75 hours |\n| **Hosted API** | **Deployed and live** at https://quorum-api-j15n.onrender.com, on PostgreSQL, verified against the running instance |\n| **npm** | **Published**: `npx quorum-api \"running shoes\"`. Five packages, zero external dependencies |\n| **Corpus** | Young. 22 consumer and developer categories warmed on the hosted instance as of 2026-08-25; everything else is a cold run away |\n\n> [!NOTE]\n> **Do not depend on the API shape yet.** It is stable enough to build against\n> and not yet frozen.\n>\n> Named rather than left as a surprise: **request quotas are held in memory**,\n> so the per minute lookup counter resets on restart and none of the counters\n> would hold across two instances. The hourly report counter is the exception:\n> it is re-seeded from persisted reports at boot, so a restart is not a refill.\n> And **`evals/` holds only its first layer**, a labelled relevance set scored\n> in CI; the paid, scored report evals described in the project docs do not\n> exist yet.\n\n## Requirements\n\n**Node 22.18 or newer**. The engine has **one runtime dependency**, `pg`, and\nonly in the hosted server: no framework, no ORM, no HTTP client, no test runner,\nand nothing at all in the CLI or the corpus. That is a deliberate constraint\nrather than a boast, because every dependency in a tool that fetches untrusted\ntext from the public internet is another thing that can reach the network on\nyour behalf. `pg` earned its place by being a connection pool that survives a\ndatabase restart, which is not a thing worth hand writing.\n\nNo key is required for anything. Reddit through a public archive, Hacker News,\nthe App Store and four government safety archives are all free and keyless. Keys\nonly ever ADD sources (YouTube comments with a free API key, Amazon reviews and\nthe Meta ad library through Apify), and a missing one degrades a run rather\nthan failing it.\n\n<details>\n<summary><strong>Environment variables</strong>, all optional. Put them in a gitignored <code>.env</code> at the repo root.</summary>\n\n| | |\n|---|---|\n| `QUORUM_YOUTUBE_API_KEY` | A free [YouTube Data API v3](https://developers.google.com/youtube/v3/getting-started) key. Comments under review videos join the voice tier; absent, the source reports itself unconfigured and the run proceeds without it. Costs nothing: the free daily quota covers dozens of runs. |\n| `QUORUM_CONTACT_EMAIL` | Not a key. The SEC requires a User-Agent naming who is calling and returns 403 without one, so `sec-edgar` reports itself unconfigured until this is set. A role address outlives whoever set it up. |\n| `OPENROUTER_API_KEY` | Subject expansion, `--synthesise` and `--read-images`. All three are off by default, and the counts never come from a model, so the deterministic report is identical without it. |\n| `APIFY_TOKEN` | The two metered legs: the Meta ad library and Amazon reviews, both through the same Apify account. Every call charges the cost meter and lands on the report's bill. Absent, both legs are skipped, exactly as `--no-ads` does for ads. |\n| `QUORUM_CORPUS` | SQLite corpus path, read by the **server and the MCP server**. Default `./quorum.db`. The CLI does not read it: pass `--corpus` instead. |\n| `QUORUM_PG_URL` | Postgres, for the hosted corpus. Paste the provider uri whole: it is parsed rather than split, so `sslmode` is honoured and a password containing `@` survives. |\n| `QUORUM_PG_CA` | Path to the provider CA. Worth setting, because `sslmode=require` means encrypt and **not** verify. |\n| `QUORUM_PG_CA_PEM` | The same CA as an inline PEM, for hosts that supply secrets as values rather than files. |\n| `QUORUM_API_KEYS` | Comma separated bearer keys for the server. **Absent means the instance is open**, which the server says out loud on boot. |\n| `QUORUM_WEBHOOK_SECRET` | Signing secret for webhook delivery, 24 characters or more. **Absent means webhooks are accepted and never delivered**, which the server also says on boot. |\n| `QUORUM_CONCURRENCY` | Concurrent report runs, default 2. Not a throughput dial: every concurrent run is concurrent pressure on the same volunteer archives. |\n| `PORT` | Default 8787. A host normally sets this. |\n| `QUORUM_REPORTS_PER_MINUTE` | Reports one key may start per minute. Default 20. |\n| `QUORUM_LOOKUPS_PER_MINUTE` | Everything else, per key per minute. Default 600, which is 10 a second. |\n| `QUORUM_MAX_CAP_USD` | Ceiling on any one report. A caller's own `capUsd` can only lower it. Default 0. |\n| `QUORUM_SPEND_PER_KEY_USD` | What one key may spend on metered sources per day. Default 0. |\n| `QUORUM_SPEND_TOTAL_USD` | What **every** key together may spend per day. Default 0. |\n\n</details>\n\n## Quick start\n\nRequires **Node 22.18 or newer**, and nothing else.\n\n```bash\nnpx quorum-api \"running shoes\"\n```\n\nThat is the whole install. Five packages come down from npm, about 240 KB with\nzero external dependencies, and the first report starts.\n\nWorking on Quorum itself is the clone path:\n\n```bash\ngit clone https://github.com/Godzilla-lab/Quorum-API && cd Quorum-API\nnpm install\nnpm run build\nnpm test          # 1,233 tests, offline, no keys\n```\n\nThen research something. The input is a **subject**, not a URL. Plain text\nworks, a product URL works, and a product URL the store refuses still works,\nwhich matters because four of four real store pages blocked a server side fetch\nwhen this was measured.\n\n```bash\nnpx quorum \"running shoes\" --offline    # corpus only, no network, no cost\nnpx quorum \"running shoes\" --offline --corpus ./mine.db   # somewhere else\n```\n\n> [!NOTE]\n> Nothing above needs a key, and `--offline` never touches the network at all.\n> Start with none of them and add one when a report tells you what it could not\n> reach.\n\n## Usage\n\n### CLI\n\nThe whole engine on your machine, against a corpus you own.\n\n```bash\n# A subject in plain words\nnpx quorum \"running shoes\" --communities running,runningshoegeeks\n\n# A product url, including one a store refuses to serve to a server\nnpx quorum \"https://allbirds.com/products/mens-wool-runners\"\n\n# Corpus only. No network, no cost, and it says so\nnpx quorum \"running shoes\" --offline\n\n# Machine readable. Progress goes to stderr, so this pipes cleanly\nnpx quorum \"running shoes\" --json | jq '.findings[].term'\n\n# Versus what: one full retrieval per rival\nnpx quorum \"wool runner\" --compare \"brooks ghost\"\n\n# Skip the one metered source explicitly\nnpx quorum \"running shoes\" --no-ads\n```\n\n`--compare` is one full retrieval per rival, and it has to be. Counting records\nin one corpus that happen to mention a rival measures co-occurrence: a comment\nsaying \"these run smaller than my Brooks\" names a rival and a complaint and\nattributes the complaint to neither. So every side is a corpus of its own,\nshares are compared rather than counts, and a gap inside the sampling noise for\nthose corpus sizes is reported as no difference rather than as a result.\n\nEvery claim it prints is either a **finding**, meaning at least three\nindependent receipts stand behind it, or a **weak signal**, which is shown so it\ncan be chased and is never stated as a market pattern. Before anything is\nprinted, every cited receipt is fetched back out of the corpus, and the run\nexits non zero if one of them does not resolve.\n\n`quorum --help` lists the flags. Every command above works through\n`npx quorum-api` with nothing cloned, or through the `quorum` bin inside a\ncheckout; they are the same binary.\n\n### HTTP API\n\nStart a report, poll it, then resolve the ids it cites.\n\n```bash\n# Start one. Returns 202 immediately with an id\ncurl -sX POST http://localhost:8787/v1/reports \\\n  -H \"authorization: Bearer $KEY\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"subject\":\"wool runner\",\"terms\":[\"sizing\",\"durability\"]}'\n```\n\n```json\n{ \"id\": \"rep_3568995af829290a\", \"status\": \"running\", \"coalesced\": false,\n  \"category\": \"wool runner\", \"queuePosition\": 1, \"estimatedSeconds\": 60 }\n```\n\n```bash\n# Poll it. Honours Retry-After while running, and ETags so polling costs nothing\ncurl -s http://localhost:8787/v1/reports/rep_3568995af829290a \\\n  -H \"authorization: Bearer $KEY\" -H 'if-none-match: \"rep_3568995af829290a-7\"'\n\n# Watch it instead of polling\ncurl -N http://localhost:8787/v1/reports/rep_3568995af829290a/stream \\\n  -H \"authorization: Bearer $KEY\"\n\n# Resolve one receipt. This is the endpoint everything else rests on\ncurl -s http://localhost:8787/v1/evidence/rc_a8697befab91e873 \\\n  -H \"authorization: Bearer $KEY\"\n\n# Resolve up to 200 at once, because a report cites more than one\ncurl -sX POST http://localhost:8787/v1/evidence/batch \\\n  -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n  -d '{\"receiptIds\":[\"rc_8f2a1\",\"rc_44b0e\",\"rc_91c37\"]}'\n\n# Is this category warm? Free, and worth asking before paying for a cold report\ncurl -s http://localhost:8787/v1/categories/running%20shoes \\\n  -H \"authorization: Bearer $KEY\"\n\n# Check somebody's claims, ours or a competitor's\ncurl -sX POST http://localhost:8787/v1/verify \\\n  -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n  -d '{\"claims\":[{\"text\":\"sizing runs small\",\"receipts\":[\"rc_8f2a1\"]}]}'\n```\n\n### JavaScript SDK\n\nTyped, zero dependency, written against [`spec/openapi.yaml`](spec/openapi.yaml).\nEvery method is one operationId from that file.\n\n```js\nimport { createClient } from '@quorum/sdk-js';\n\nconst quorum = createClient({ baseUrl: 'https://quorum-api-j15n.onrender.com', apiKey: process.env.QUORUM_KEY });\n\nconst found = await quorum.searchEvidence({ query: 'sizing', category: 'running shoes' });\nif (!found.ok) throw new Error(found.error.message);\n\nfor (const record of found.data.records) console.log(record.receiptId, record.text);\n```\n\n**Errors are values, never thrown**, the same rule the engine follows anywhere a\nvendor can be down. A caller gets `{ ok: false, error }` carrying the server's\n`type`, its `requestId` and any `retryAfterSeconds`, because a 429, a 503 and a\nreport that is simply not finished yet are all normal and none of them is\nexceptional.\n\n**It honours the server's pacing.** `waitForReport` polls to completion using\n`Retry-After` when the server sends one, and treats a 503 as the load shedder\nrather than a failure. A client that gives up on the first refusal reports a busy\nservice as a broken one, which is exactly what the shedder exists to prevent.\n\n```js\nconst started = await quorum.createReport({ subject: 'wool runner', offline: true });\nif (started.ok) {\n  const report = await quorum.waitForReport(started.data.id, {\n    onPoll: (r) => console.log(r.status),\n  });\n}\n```\n\n`streamReport` returns the same run as an async iterable of server sent events,\nparsed by hand because there is no EventSource in Node that accepts an\nAuthorization header.\n\n### Python SDK\n\nThe same client in Python, in [packages/sdk-py](packages/sdk-py), written\nagainst the same spec with the same two decisions: errors are values, never\nraised, and `wait_for_report` honours `Retry-After` so a busy service is not\nreported as a broken one. Standard library only, no dependencies, and the\ntransport is injectable so its tests run with no network.\n\n```bash\npip install quorum-api\n```\n\n```python\nfrom quorum_api import QuorumClient\n\nquorum = QuorumClient(\"https://quorum-api-j15n.onrender.com\", api_key=\"qk_...\")\nstarted = quorum.create_report(\"wool runner\", offline=True)\nif started.ok:\n    report = quorum.wait_for_report(started.data[\"id\"])\n```\n\n### MCP server\n\nFive tools spoken as JSON-RPC with **no dependency**, over two transports.\n\n**Remote**, for connector forms that want a URL. The hosted instance answers\nStreamable HTTP at:\n\n```\nhttps://quorum-api-j15n.onrender.com/mcp\n```\n\nOpen without a key: the four read only tools spend nothing and serve public\ndata, and anonymous callers share one rate allowance so they cannot crowd out\nkeyed customers. The `research_product` tool is never exposed remotely.\n\n**Local**, over stdio, for clients that launch a command:\n\n```bash\nQUORUM_CORPUS=./quorum.db node packages/mcp/src/bin.ts\n```\n\n```json\n{\n  \"mcpServers\": {\n    \"quorum\": {\n      \"command\": \"node\",\n      \"args\": [\"packages/mcp/src/bin.ts\"],\n      \"env\": { \"QUORUM_CORPUS\": \"./quorum.db\" }\n    }\n  }\n}\n```\n\n| tool | answers |\n|---|---|\n| `search_evidence` | How many independent records exist, across how many channels, whether that clears the threshold, and the loudest few quotes |\n| `get_receipt` | Resolves ids to the real records. **This is how an agent checks us** |\n| `category_warmth` | Whether asking is instant and free, or minutes and expensive |\n| `compare_formats` | Video versus static, from how long real ads ran |\n| `research_product` | A full report. **Off unless `QUORUM_MCP_RESEARCH=1`** |\n\nThree decisions worth knowing, because the tool schema is the expensive part to\nchange later:\n\n**Five tools, not one per endpoint.** A tool definition costs 100 to 500 tokens\non every turn, so a server mirroring ten routes spends the context window before\nthe model has done anything.\n\n**Aggregated, never a row dump.** `search_evidence` returns counts and a handful\nof quotes. A research tool that streams a corpus into a context window has spent\nthe budget it was meant to save.\n\n**Markdown, not JSON**, at roughly 60% of the tokens for the same content.\n\n`research_product` is off by default because a report is minutes of throttled\nretrieval against volunteer archives, and an agent should not be able to start\none by accident. The other four answer from what is already held and touch no\nnetwork.\n\nThe payoff is a thing no other research server can offer: **the calling agent can\nindependently verify every claim.** `search_evidence` cites ids and `get_receipt`\nresolves them, so an id that does not resolve is a claim that was never real, and\nit is the loudest line in the response when it happens.\n\n### Webhooks\n\nA report is minutes long, so `POST /v1/reports` can call you back instead of\nbeing polled. Set a signing secret and delivery turns on:\n\n```bash\nQUORUM_WEBHOOK_SECRET=$(openssl rand -base64 32) npm start\n```\n\n```bash\ncurl -sX POST http://localhost:8787/v1/reports \\\n  -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n  -d '{\"subject\":\"wool runner\",\"webhookUrl\":\"https://your-app.example/hooks/quorum\"}'\n```\n\nWhen the report reaches a terminal state you receive a POST whose body is byte\nidentical to `GET /v1/reports/{id}`, plus three headers following the\n[Standard Webhooks](https://www.standardwebhooks.com/) specification, so you can\nverify with an off the shelf library rather than with code we invented:\n\n| header | value |\n|---|---|\n| `webhook-id` | The report id. Stable across retries, so deduplicate on it |\n| `webhook-timestamp` | Unix seconds at the moment of this attempt |\n| `webhook-signature` | `v1,{base64 hmac-sha256}`, a space delimited list |\n\nThe signed content is `{webhook-id}.{webhook-timestamp}.{body}`, and the key is\nthe base64 body of your secret, not the `whsec_` label. **Your secret is the\n`webhookSecret` field on `GET /v1/usage`**: scoped to your key, stable, and\nderived rather than stored, so it rotates exactly when the operator rotates the\ninstance secret and never behind your back.\n\n```js\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nfunction verify(secret, headers, body, toleranceSeconds = 300) {\n  const id = headers['webhook-id'];\n  const timestamp = Number(headers['webhook-timestamp']);\n  /* A signature alone is replayable forever, so bound the age first. */\n  if (!Number.isFinite(timestamp)) return false;\n  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;\n\n  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');\n  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();\n\n  /* The header is a list so a secret can be rotated. Any one match is enough. */\n  return String(headers['webhook-signature']).split(' ').some((part) => {\n    const [version, value] = part.split(',');\n    if (version !== 'v1') return false;\n    const offered = Buffer.from(value ?? '', 'base64');\n    return offered.length === expected.length && timingSafeEqual(offered, expected);\n  });\n}\n```\n\n**Delivery is at least once, and retried for about 75 hours.** A 2xx is success.\nA 5xx, a 429, a timeout or a network failure is retried on the Standard Webhooks\nschedule (immediately, 5s, 5m, 30m, 2h, 5h, 10h, 14h, 20h, 24h, with jitter).\nAny other 4xx is not retried, because a receiver rejecting the body will reject\nit again. Deliveries are held in the database, so a restart does not lose them.\n\n> [!IMPORTANT]\n> **The URL is checked, and where it is checked decides what you get back.**\n> It must be `https` and must not carry credentials. A literal address is\n> settled at submit, so `https://169.254.169.254/` is an immediate `400`. A\n> hostname is settled at delivery instead, against the address that is then\n> pinned, because a name can resolve to something different between your request\n> and our connection. Every redirect hop is revalidated.\n>\n> Two limits, stated rather than left to be discovered. After the tenth attempt\n> a delivery is abandoned and logged; there is no endpoint registry here, so\n> nothing is disabled and nobody is emailed. And on an instance running without\n> `QUORUM_API_KEYS`, every caller shares one key label and therefore one signing\n> secret, so a signature proves the delivery came from that instance rather than\n> which caller asked for it.\n\n### Running the API yourself\n\nThe hosted API is one process and a corpus. It is the same engine the CLI runs.\n\n```bash\nQUORUM_CORPUS=./quorum.db \\\nQUORUM_API_KEYS=$(openssl rand -hex 32) \\\nQUORUM_WEBHOOK_SECRET=$(openssl rand -base64 32) \\\nPORT=8787 npm start\n```\n\n**Set `QUORUM_API_KEYS` or the instance is open**, which it will tell you on\nboot. Callers then authenticate with a bearer token:\n\n```bash\ncurl -H \"Authorization: Bearer $KEY\" localhost:8787/v1/categories/running%20shoes\n```\n\nOne instance serves everybody, and that is deliberate. The corpus is global\nbecause a category one caller warmed answers instantly for the next, which is\nthe whole point of keeping one. Reports and webhook deliveries are the\nexception: they are tenant owned and row level security enforces it, verified\nagainst a real PostgreSQL server.\n\n### Postgres\n\nSet `QUORUM_PG_URL` and the server uses Postgres instead of SQLite. Nothing else\nchanges.\n\n```bash\nnpm run migrate -w packages/corpus     # applies the schema, refuses to re-run one\n\nQUORUM_PG_URL='postgres://user:pass@host:5432/db?sslmode=require' \\\nQUORUM_PG_CA=./provider-ca.pem \\\nQUORUM_API_KEYS=$(openssl rand -hex 32) npm start\n```\n\n> [!WARNING]\n> **Do this for anything beyond a laptop.** `node:sqlite` is synchronous, so\n> every corpus read blocks the event loop: measured, evidence search topped out\n> at 124 requests a second while an indexed read managed 7,477, and identical\n> reports never coalesce because a second request cannot be received while the\n> first is running. On a host with no persistent disk it is not an option at\n> all, since the file disappears on every restart and takes the corpus with it.\n\n**Set `QUORUM_PG_CA`.** `sslmode=require` means encrypt and **not** verify, and a\nmanaged provider signs with its own CA so the public root store rejects it.\nDownload the provider's CA and the connection is verified rather than merely\nencrypted. The server says which of the two you got on boot.\n\n`pg` is the only runtime dependency in this repo, and it lives in\n`packages/server` alone. The corpus package still has none, which is why its\ndriver takes an injected client: somebody using the CLI with SQLite never\ninstalls a Postgres driver. See [`docs/postgres.md`](docs/postgres.md).\n\n## What the API does\n\nTen endpoints. The full contract, with schemas, is\n[`spec/openapi.yaml`](spec/openapi.yaml).\n\n### Reports, the slow path\n\nA report is minutes of throttled retrieval when a category is cold and about half\na second when it is warm, so it is a job rather than a request.\n\n| | |\n|---|---|\n| `POST /v1/reports` | Start one. Returns `202` with an id immediately. Identical subjects already in flight are **coalesced** onto one run, and the response says whether yours was. |\n| `GET /v1/reports/:id` | Poll it. Carries `findings`, `weakSignals` and `rejected`, and honours `Retry-After` while running. |\n| `GET /v1/reports/:id/stream` | The same run as server sent events, so a caller sees each stage rather than a spinner. |\n\n### Evidence, the fast path\n\nEvery number a report prints resolves here. This is the half that makes a claim\ncheckable, and it answers in single digit milliseconds against a warm corpus.\n\n| | |\n|---|---|\n| `GET /v1/evidence/:receiptId` | One record: the text somebody wrote, its score, channel, permalink and timestamp. |\n| `POST /v1/evidence/batch` | Up to 200 at once, because a report cites more than one. |\n| `POST /v1/evidence/search` | Full text search across the retained corpus, filterable by category. |\n| `GET /v1/evidence/ads/:adId` | Every observation of one ad, which is how a run duration becomes a fact rather than an estimate. |\n| `GET /v1/categories/:slug` | How warm a category is: records held, channels, age. Free, and worth checking before paying for a cold report. |\n\n### Verification\n\n| | |\n|---|---|\n| `POST /v1/verify` | Hand it claims with receipt ids and it re-resolves every one against the corpus. **It will verify our own output or anybody else's.** |\n| `GET /v1/usage` | What this key has used, what it is allowed, and its webhook signing secret. |\n| `GET /v1/healthz` | Liveness. Touches no database, and is never rate limited. |\n\n### Limits\n\nTwo quotas per key, not one, because a report is minutes of throttled upstream\nretrieval and an evidence lookup is one indexed read. A single shared limit\neither starves the lookups or leaves the reports unprotected.\n\n| | default |\n|---|---|\n| Reports started | 20 per minute |\n| Everything else | 600 per minute, which is 10 a second |\n| Reports in flight at once | 3 |\n\nEvery metered answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and\n`X-RateLimit-Reset`, success or refusal alike, so a caller can pace itself\nbefore being refused rather than after. `GET /v1/healthz` is exempt from the\nlimit and therefore sends none: an instance has to be able to say it is alive\nwhile it is busy. A refusal is a `429` with `Retry-After`, and **a refused request is not\ncounted against the window**, or a client in a retry loop could never get back\nin.\n\n**Limits are on even when auth is off.** An instance with no keys has one caller\nby definition, so the allowance is shared rather than absent. The one thing a\nlimit has to survive is somebody forgetting to configure it.\n\n### Metered sources, and the money\n\nCompetitor ads come from a paid vendor, so `includeAds` is available to every\ncaller and bounded by a budget rather than by a rate limit. **A rate limit bounds\nhow often, never how much**: twenty reports a minute with ads on is twenty\nmetered vendor runs a minute, which a rate limit permits happily.\n\n| | stops |\n|---|---|\n| `QUORUM_MAX_CAP_USD` | one runaway report draining the budget by itself |\n| `QUORUM_SPEND_PER_KEY_USD` | one caller draining it |\n| `QUORUM_SPEND_TOTAL_USD` | **all callers together** draining it |\n\nThe third is the one that matters when ads are open to everyone. Without it the\nreal ceiling is the per key figure multiplied by however many keys exist, which\nis not a ceiling.\n\n**All three default to zero, which leaves ads off.** An operator who has not set\na budget has not agreed to a bill, so forgetting produces a report without ads\nrather than an invoice. The server says which state it is in on boot.\n\nRunning out is **not an error**. The report still runs and still answers, it just\nanswers without the metered leg, and the degradation list says exactly why.\nFailing a whole report over an optional extra would throw away minutes of free\nretrieval.\n\nA coalesced run is paid for by **whoever started it**, and callers who join it\nride free. That is what coalescing is for, and splitting a bill across joiners\nwho arrived at different moments would be arbitrary in a way nobody could check.\nSpend is charged from what the cost meter actually recorded, never from an\nestimate, and `GET /v1/usage` reports it.\n\n## Why this is different\n\nThree properties, none of which is a matter of opinion.\n\n**A receipt does not rot.** Every other research API returns a URL captured\nagainst a live index at query time, and it dies when the page does. A receipt is\na stable id into a retained corpus. It resolves identically forever, including\nafter the source deletes the original.\n\n**Corroboration is arithmetic.** A claim needs at least three independent records\nbefore it prints as a finding, and the count travels with it. \"31 records across\n5 channels\" is a sentence no search API can produce, because none of them keep a\ncorpus to count against. It is also the prompt injection defence: a planted\ncomment cannot corroborate itself.\n\n**The archive can be asked about the past.** Because records are kept rather than\nfetched, the corpus answers questions a live index structurally cannot:\n\n- **Trend**, as share of conversation over time, not raw counts. Counting records\n  per month reports everything as rising, because it measures our harvesting\n  rather than the market.\n- **As of**, answering what the market said in March, filtered on when each\n  record was written rather than when we found it. The archive is allowed to know\n  more about March than we did in March.\n- **Diff**, what changed since the last report for the same subject, naming the\n  new receipts rather than counting them.\n- **Comparison**, one full retrieval per rival, because counting records in one\n  corpus that mention a competitor measures co-occurrence and attributes nothing.\n- **Attested evidence**, records a named party filed with a regulator, which\n  outranks any forum comment and which no listening tool holds.\n\nAnd the thing that is easy to miss: **Meta does not archive inactive commercial\nads.** Once a campaign stops, the record that it ran for 94 days is gone and no\namount of money brings it back. Shopify drops delisted products from\n`/products.json` the day they are pulled. For those sources the corpus is not a\ncache, it is the only copy that will ever exist, and only because something was\nrecording on the day.\n\n## Documentation\n\n| | |\n|---|---|\n| [`spec/openapi.yaml`](spec/openapi.yaml) | The API contract. The SDK is written against it |\n| [`docs/citation-integrity.md`](docs/citation-integrity.md) | Measured fabrication rates elsewhere, and the structure that makes them impossible here |\n| [`docs/gummysearch-alternative.md`](docs/gummysearch-alternative.md) | For GummySearch users: the overlap, the differences, and what this is not |\n| [`docs/postgres.md`](docs/postgres.md) | Running on Postgres, and what was found verifying it |\n| [`docs/rate-limits.md`](docs/rate-limits.md) | Every upstream, what it does when pushed, and what we do about it |\n| [`bench/README.md`](bench/README.md) | Load and abuse testing, with the defects it found |\n| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Adding a source, which is the main way to help |\n| [`SECURITY.md`](SECURITY.md) | Reporting a vulnerability |\n\n## Contributing\n\n**Adding a source is the main way to help**, and it touches only its own\ndirectory: one adapter behind the `Source` interface, one fixture, one\nconformance case. See [`CONTRIBUTING.md`](CONTRIBUTING.md).\n\n```\nnpm run build          # tsc, declarations only, node runs the source directly\nnpm test               # node:test, offline, no keys required\nnpm run verify         # build, test, copy style, security, drift, spec\n```\n\nTwo house rules that will fail your build if you miss them: **no em dashes or en\ndashes anywhere**, enforced by `npm run lint:copy`, and **named exports only**.\n\nFound a vulnerability? [`SECURITY.md`](SECURITY.md), not an issue.\n\n## Development\n\n```\nnpm run build          # tsc across workspaces\nnpm test               # node:test, all packages, offline, no keys\nnpm run lint:copy      # house style\nnpm run check:security # secrets, SSRF guard, no-auth rule, audit\nnpm run check:spec     # the spec against the code it describes\nnpm run migrate -w packages/corpus     # Postgres schema\nnpm run test:postgres  # the driver against a real server, needs QUORUM_PG_URL\n```\n\nCI runs the test suite inside a network namespace with no route off the host, so\nan adapter that quietly reaches for the wire fails immediately instead of flaking\nlater. Three of the 1,233 tests need a real PostgreSQL server and skip without\n`QUORUM_PG_URL`.\n\n## License\n\nApache 2.0 on the whole engine: every adapter, every gate, both corpus drivers,\nthe CLI and the SDK. Nothing is crippled.\n\nThe hosted service sells one thing the source cannot give you, which is a warm\ncorpus. Self hosting gets you the entire engine and a cold start. That is a real\nproduct, and if it is the right one for you, take it.\n\n## Acknowledgements\n\nThis runs on archives other people maintain, most of them for free:\n\n- [Arctic Shift](https://github.com/ArthurHeitmann/arctic_shift), the public\n  Reddit archive, which is a volunteer project and is treated as one\n- [Hacker News](https://hn.algolia.com/api) search\n- The [GitHub issue search API](https://docs.github.com/en/rest/search/search),\n  used keyless as documented, which is what puts filed defects with reaction\n  counts in tier B\n- The [Apple App Store](https://www.apple.com/app-store/) public review feeds\n- [CPSC](https://www.cpsc.gov/), [openFDA](https://open.fda.gov/),\n  [NHTSA](https://www.nhtsa.gov/) and the\n  [EU Safety Gate](https://ec.europa.eu/safety-gate/), four regulators whose\n  recall data is what makes attested evidence possible\n- [SEC EDGAR](https://www.sec.gov/edgar), which asks callers to identify\n  themselves and is the reason `QUORUM_CONTACT_EMAIL` exists\n- The [YouTube Data API](https://developers.google.com/youtube/v3), used with a\n  free key as documented: comments under review videos are buyers talking, at\n  one quota unit per hundred comments against a 10,000 unit free day\n- [Apify](https://apify.com/), the one paid vendor, carrying both metered legs:\n  the Meta Ad Library scraper and Amazon product reviews. The reviews leg reads\n  only what Amazon's public product page shows a logged out visitor, plans\n  nothing unless the subject is an Amazon product, and every call charges the\n  cost meter at rates verified against real billing\n- [Standard Webhooks](https://www.standardwebhooks.com/), so webhook signing is\n  somebody else's well reviewed design rather than ours\n\n> [!WARNING]\n> **Acceptable use.** Everything retrieved is public and logged off. This project\n> never authenticates to a scraped source and never will, because that is what\n> its legal footing rests on. A session cookie forfeits it, which is why the rule\n> is architectural rather than a preference. Certificate Transparency data is\n> used to discover brands and products, never for infrastructure enumeration.\n> Respect the archives above: they are mostly volunteers, and the throttle curve\n> in this repo exists because of them.\n",
  "bytes": 38125,
  "sha": "61c1241c6391dbb7ea1b1b24191826b56fb4d1f19c184c3dd8b466c7989569a9",
  "repo_slug": "godzilla-lab/quorum-api",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_godzilla_lab_quorum_b0b31a3a/readme"
}