Cormorant API v1
Base URL: https://api.cormorant.ch. All endpoints below are
under /v1 except health and metrics. The contract is versioned: fields are
only ever added within v1; nothing is renamed or removed. Guides, the
interactive reference and llms.txt are served at /docs (see the
changelog for what changed).
Authentication#
Every /v1 request needs a project API key (except the public
service status, /v1/status*):
Authorization: Bearer cmr_...
Keys belong to exactly one project, are stored only as HMAC-SHA256 hashes, and
are created/revoked by the operator (Mission Control, or
python -m app.cli create-key <project> [--role account_admin],
revoke-key <prefix>). Missing or invalid keys get 401.
There are two kinds of keys, and the prefix tells them apart:
| Prefix | Role (X-Cormorant-Key-Role) |
Sees |
|---|---|---|
cma_… |
account_admin — the account admin key |
everything of the customer account where an endpoint offers it: all its keys, projects, usage and invoices |
cmr_… |
standard — an additional key |
its own usage only |
Both authenticate every /v1 endpoint the same way. A key's role is fixed when
it is created; to change it, have a new key issued and the old one revoked.
Keys issued before roles existed are standard keys. Every authenticated /v1
response carries the header X-Cormorant-Key-Role: account_admin or
X-Cormorant-Key-Role: standard. Each project has a
request-rate limit (default 200 requests/s, fixed one-second window); excess
requests get 429 with Retry-After: 1. A batch call counts as one request.
Every authenticated response carries RateLimit-Policy and RateLimit
(IETF draft; "project";q=200;w=1 and, for a key with its own limit,
"key";q=10;w=1).
Debug requests (available on request). When you report a problem with a
specific request, Cormorant support can enable debugging for one of your keys.
Send that key's synchronous POST /v1/search or POST /v1/fetch request with
the header X-Cormorant-Debug: 1: the request is recorded in full detail for
support, and the answer carries debug_url, a link to that record (it opens in
the operator console, not with an API key). Quote it in your report. Without
debugging enabled for the key, the header is ignored and the answer is the
usual one. Debug requests are billed like any other request.
Per-key limits. The operator can set, per key, a monthly spend cap (in the
customer's invoice currency; provider cost in USD for projects without a
customer), monthly request quotas per category (search, fetch,
fetch_premium, company; SEC EDGAR requests count as fetch), requests per
second and the
allowed categories. They are
enforced on every request and batch item: 403 key_category_not_allowed,
429 key_quota_exhausted / key_budget_exhausted (Retry-After until the
month resets, UTC) and 429 key_rate_limited. A key without fetch_premium
(not allowed, or its quota used up) still fetches, without the premium routes.
Keys with limits get X-Cormorant-Key-Budget-Remaining: 12.40 USD and
X-Cormorant-Quota-Remaining: fetch=18766, search=900 on every response.
Request counts are exact; spend is the aggregates (5 minutes) plus an
estimate since then.
Keys of a suspended customer get 403 with
{"detail": {"error": "customer_suspended", "message": "…"}}.
Every response carries a request_id (req_…; batch items use
<job_id>:<index>). Quote it when debugging — Mission Control can show the
full attempt history for any request id.
cost_usd is Cormorant's provider spend caused by this request in USD
(search fallback and premium routes); cache hits and standard routes cause no provider cost. Spend is attributed only to
the project whose request made the provider call; the caches are shared. Keys of
an external customer get cost_usd: null: customers see what they are
charged — per request in GET /v1/requests (charge), aggregated in
GET /v1/usage — never Cormorant's provider cost. Every search, fetch and
SEC result (also batch items) carries billing_category
(search, search_fallback, search_cached, fetch_own, fetch_premium,
fetch_cached, sec, sec_cached; company data requests count as
company / company_cached; new categories may be added): the category,
and so the plan meters, the request counts toward. SEC EDGAR requests (/v1/sec/… and fetches of
SEC hosts) are sec / sec_cached: no provider cost, billed at your plan's
price for them, and counted against a key's fetch quota.
POST /v1/search#
{
"query": "Nestlé sustainability report 2025",
"language": "en", // optional, ISO 639-1 (SearXNG language, Brave search_lang)
"country": "CH", // optional, ISO 3166-1 alpha-2 (combined with language for SearXNG)
"max_results": 20, // 1..50, default 20
"freshness": "month", // optional: day | week | month | year (pd/pw/pm/py accepted)
"cache_ttl_s": 604800, // optional: max acceptable cache age; 0 = bypass the cache
"project_ref": "company:4711" // optional your own text, stored with usage
}
Response 200:
{
"query": "Nestlé sustainability report 2025",
"results": [
{
"url": "https://www.nestle.com/sustainability/...",
"title": "Creating Shared Value and Sustainability Report 2025",
"snippets": ["Our 2025 report covers ...", "Download the full report ..."],
"engines": ["google", "bing", "brave_api"],
"published_at": "2026-03-02T00:00:00",
"rank": 1
}
],
"provider": "searxng", // "searxng" | "brave"
"fallback_reason": null, // why Brave was used (see below), else null
"unresponsive_engines": [], // SearXNG engines that failed for this query
"cached": false,
"cost_usd": 0.0, // null for keys of an external customer
"billing_category": "search", // search | search_fallback | search_cached
"error": null,
"request_id": "req_6b1f..."
}
Order of operations: shared search cache (key = normalised query + language, country, max_results, freshness; 7 days) → our SearXNG instances (least-loaded across all hosts, a second instance on transport failure) → Brave Search API.
Brave is used when SearXNG errors or times out (searxng_error,
searxng_timeout, no_searxng_instance), reports unresponsive engines
(unresponsive_engines, or captcha_engines when any reason mentions a
CAPTCHA), or returns fewer than MIN_RESULTS (3) usable results
(too_few_results). By default unresponsive engines trigger the fallback only
when fewer than two engines returned results (SEARCH_FALLBACK_ON_UNRESPONSIVE= quorum, SEARCH_QUORUM_ENGINES=2); majority (as many engines failed as
answered) and any are stricter. A Brave rate limit (429) is retried after the
indicated wait; batch items that still fail with it are retried later.
Results are deduplicated by normalised URL (scheme, www., trailing slash and
tracking parameters ignored). No snippet is lost: every distinct snippet
and engine seen for a URL is kept, in order of first appearance. When Brave is
used, Brave's results come first, followed by any SearXNG-only results.
If Brave is unavailable or the project budget is exhausted, whatever SearXNG
returned is served (not cached) with fallback_reason set. A genuinely
empty search — the engines (and Brave, when consulted) answered and found
nothing — returns results: [] with error: null. Only when the providers
could not answer is error set (e.g. budget_exhausted, brave_http_429,
searxng_timeout).
POST /v1/fetch#
{
"url": "https://www.example.com/annual-report-2025.pdf",
"render": "auto", // auto | never | always
"max_bytes": 15728640, // default 15 MB, max 50 MB (batch items left at the default: PDFs up to 200 MB)
"accept_pdf": true,
"cache_ttl_s": 604800, // optional max acceptable cache age; 0 = bypass
"min_text_chars": 500, // below this, HTML main text counts as "thin"
"allow_archive": true, // allow an archived copy when the live site blocks us, is gone or 404s
"include_html": true, // default true here, false for batch items
"allow_premium": true, // false: never use proxy / proxy_render
"tiers": ["direct", "browser", "archive"], // optional allowlist of tiers
"timeout_s": 60, // optional overall deadline, 5–150 s
"country": "CH", // optional geotargeting (premium routes)
"user_agent": "Example Research contact@example.org", // must be on the project allowlist
"accept_language": "de-CH,de;q=0.9",
"project_ref": "company:4711"
}
Response 200 (also for unsuccessful fetches — check error):
{
"url": "https://www.example.com/annual-report-2025.pdf",
"final_url": "https://www.example.com/annual-report-2025.pdf", // null when unknown (proxy_render)
"redirects": [ // one entry per redirect before final_url
{"url": "http://example.com/ar25", "status": 301, "location": "https://www.example.com/annual-report-2025.pdf"}
],
"status": 200,
"content_type": "application/pdf", // what the body is (rendered pages: text/html)
"origin_content_type": "application/pdf", // what the origin sent; null when unknown
"headers": {"content-type": "application/pdf", "last-modified": "Tue, 03 Mar 2026 09:00:00 GMT"},
"tier": "direct", // direct | browser | archive | proxy | proxy_render
"attempts": [
{"tier": "direct", "status": 200, "reason": "ok", "ms": 412}
],
"html": null, // decoded HTML for HTML pages
"text": "Annual Report 2025\n\n...",
"pdf_text_pages": [{"page": 1, "text": "Annual Report 2025"}, {"page": 2, "text": "..."}],
"pdf_page_count": 148, // PDFs: number of pages; null otherwise
"text_truncated": false, // PDF text stopped at the extraction limits
"title": "Annual Report 2025",
"meta": null, // article metadata for HTML pages (see below)
"sha256": "a3f1...", // of the retrieved bytes
"bytes": 2483112,
"cached": false,
"revalidated": false, // true: expired cache entry confirmed by the origin (304)
"retrieved_at": "2026-10-19T08:12:44.120Z",
"etag": "\"5f2-1a\"",
"last_modified": "Tue, 03 Mar 2026 09:00:00 GMT",
"archived_at": null, // snapshot time (tier = archive) or common_crawl capture time
"served_from_archive_because": null, // domain_dead | not_found when the archive stands in
"body_url": "https://api.cormorant.ch/v1/bodies/a3f1...", // exact retrieved bytes
"body_kind": "origin", // origin | rendered_dom (what sha256 covers)
"content_kind": "pdf", // html | pdf | text | json | xml | binary
"paywall": null, // HTML only: none | metered | hard | unknown
"paywall_evidence": [], // e.g. ["jsonld_not_free", "vendor_piano"]
"text_complete": null, // false when text is only a paywall teaser
"alternate_of": null, // set when text comes from a verified copy elsewhere
"alternate_source": null, // publisher_feed | guardian_api | open_access | syndication | common_crawl
"match_score": null,
"primary_sources": [], // [{url, kind, text}] for paywalled/unavailable pages
"cost_usd": 0.0, // null for keys of an external customer
"billing_category": "fetch_own", // fetch_own | fetch_premium | fetch_cached | sec | sec_cached
"error": null,
"request_id": "req_91c2..."
}
text is exactly the retained text Cormorant stores and serves again on cache
hits (main text via trafilatura, readability fallback, then visible body text;
whitespace normalised). Anchor quotes against it. For PDFs, text is the pages
joined with blank lines and pdf_text_pages has the per-page text.
Large PDFs. max_bytes applies to every response of /v1/fetch. In batch
fetches whose items leave max_bytes unset, a PDF (by
Content-Type: application/pdf or the %PDF- signature) may be up to
200 MB; other content keeps the default 15 MB. An item that sets
max_bytes gets exactly that limit, for PDFs too. Such a PDF is fetched by
the direct or proxy tier (the browser, archive and rendering tiers keep
max_bytes); its exact bytes are available from body_url. Downloading it
may take at most 5 minutes in total (or the item's timeout_s, when
shorter); a slower download ends with error: timeout and an attempt with
reason pdf_download_timeout. Text extraction stops after 5,000 pages or
5,000,000 characters of page text: then text_truncated is true,
pdf_text_pages covers the pages read so far (the last one possibly cut)
and pdf_page_count still counts every page. To skip PDFs entirely, set
accept_pdf: false.
Non-HTML content. content_kind classifies the response (content type,
sniffed when missing or generic). json: text is the decoded document
(still valid JSON). xml (feeds, sitemaps, XBRL…): text is the text content,
one node per line, title from the first <title>; external entities are
never resolved. text (plain, CSV, Markdown): decoded text. binary
(spreadsheets, archives, images…): text is empty — the bytes are in
body_url. All of these are successful fetches (error: null).
Paywalls. HTML pages carry a paywall verdict. Cormorant detects
paywalls and never circumvents them (no crawler User-Agents, cookie clearing,
script blocking or referer tricks).
paywall |
Meaning | text_complete |
|---|---|---|
hard |
text is only the teaser: the publisher declares the article subscriber-only (isAccessibleForFree: false) and the text is cut short, or a subscription call to action follows a teaser together with paywall markup, vendor scripts or truncation |
false |
metered |
a meter ("2 articles left this month") or a declared paywall, but the full text came through | true |
unknown |
weak signals only (a paywall vendor's script, paywall-like markup) | true |
none |
no paywall signal | true |
paywall_evidence lists the signals: jsonld_not_free, vendor_<name>
(piano, poool, zephr, pelcro, laterpay, steady, blendle, …),
paywall_markup, truncation_markup, phrase_<lang>, metered_<lang> (en,
de, fr, it) and length_mismatch (declared word count far above the
extracted text). A hard teaser is a successful fetch (error: null)
returned at once — other tiers see the same paywall — and is not cached.
Domains whose articles are repeatedly hard-paywalled skip the premium routes
(paywall_skip_premium attempts). Wayback copies of articles the publisher marks
as subscriber-only are not served (archive_blocked_paywall); the operator can allow
them per project (archive_paywalled).
Alternative sources. When the page is a hard-paywall teaser,
disallowed by robots.txt, gone (404/410, domain_dead) or blocked on every tier
(all_tiers_failed, domain_unavailable), Cormorant looks for a verified
open copy elsewhere (allow_alternates, default true):
alternate_source |
How it is found and verified |
|---|---|
publisher_feed |
tried first, for paywall teasers and blocked pages only: the article's item in the publisher's own full-text RSS/Atom feed (feeds Cormorant found linked from the publisher's pages or the operator added, and detected to carry whole articles). The item must link to the requested URL (tracking parameters ignored; match_score 1.0) or have the same title and contain ≥ 50 % of the teaser's opening word 5-grams; items that are themselves teasers (short, or ending in a subscribe call-to-action) are never used. final_url is the feed |
guardian_api |
theguardian.com URLs via the Guardian Content API (when the operator configured a key) |
open_access |
a DOI in the URL or the page's citation metadata → the best open-access location OpenAlex knows (repository, arXiv, Europe PMC, publisher OA), fetched with the standard direct route; match_score 1.0 |
syndication |
the headline is searched; copies on other sites (wire services first) are fetched with direct and accepted only if ≥ 50 % of the teaser's opening word 5-grams appear in them and they are not paywalled themselves; match_score is that share |
common_crawl |
batch fetches only (too slow for synchronous calls), for pages that are gone (404/410, domain_dead) or blocked on every tier — never for paywall teasers or robots-disallowed pages: the newest HTTP 200 capture of the exact URL in the last three Common Crawl crawls (CDX index, then the single WARC record by HTTP Range request). Domains known to be paywalled are skipped, and a capture whose markup declares the article subscriber-only (isAccessibleForFree: false) or that is itself a paywall teaser is not served. archived_at is the capture time, final_url the captured URL, html the captured page; match_score 1.0 |
The result then has tier: "source", alternate_of (the URL asked for),
final_url (the copy), text_complete: true and paywall still describing
the original page. Without a verified copy the original answer stands (the
flagged teaser or the error) and attempts show what was tried.
primary_sources lists links from the page to court decisions,
government/regulator pages, press releases, DOIs and PDFs. Searching for
copies may use the Brave fallback (billed like a search). The operator can
turn alternates off per project (set-option <project> alternates off).
Provenance. redirects, headers (an allowlist of the origin's final
response headers: content type/length/language/disposition, ETag,
Last-Modified, Cache-Control, Expires, Date, Link, X-Robots-Tag — never
cookies) and origin_content_type are kept with cache entries and returned on
hits. For tier = archive, final_url is the archived copy's URL
(https://web.archive.org/web/<timestamp>id_/<url>) and archived_at the
snapshot time. The rendering tier (proxy_render) does not report the post-redirect
URL or origin headers: final_url, origin_content_type are null and
headers is empty rather than guessed.
Per-request controls. allow_premium=false removes the premium routes;
tiers restricts the ladder to the listed tiers (an empty intersection is
error: no_tier_allowed; cache hits from other tiers are not used).
timeout_s is a deadline for the whole fetch across tiers — when it runs out
the result is error: timeout with a request_timeout attempt (or the best
page so far). country geotargets the premium routes (proxy, proxy_render).
user_agent replaces our browser User-Agent for direct, browser and
proxy (not proxy_render); it must be on the project's allowlist, which the
operator sets (set-option <project> allowed_user_agents '["…"]') — services
such as SEC EDGAR and Wikimedia require a contact User-Agent. Otherwise
403 user_agent_not_allowed. accept_language sets the Accept-Language
header / browser locale header.
Cache hits are used only when they satisfy the request: render=always
needs a rendered copy, render=never an origin copy; HTML text must reach
min_text_chars; a cached body larger than max_bytes answers too_large
(as a fresh fetch would). Otherwise the page is fetched again. Pages returned
as best effort (every tier gave thin text) are never cached.
Revalidation. An expired entry (or one older than cache_ttl_s) that
would otherwise satisfy the request and has an ETag or Last-Modified is
revalidated: the direct tier sends If-None-Match / If-Modified-Since.
304 Not Modified returns the cached copy with cached: true,
revalidated: true, a single direct attempt with reason not_modified,
cost_usd: 0, a new retrieved_at, and renews the entry's freshness (new
validators from the 304 are kept). Any other answer takes the normal path (a
200 replaces the entry). cache_ttl_s: 0 never revalidates. Other tiers
never send conditional requests.
Article metadata: meta#
For HTML pages meta describes the article; it is null for other content
kinds and on errors. It is kept with cache entries (identical on hits) and
included in batch results.
"meta": {
"author": "Anna Muster; Beat Beispiel",
"published_at": "2026-03-19T08:30:00Z",
"modified_at": "2026-03-19T10:05:12Z",
"language": "de-CH",
"canonical_url": "https://www.example.com/news/central-bank-cuts-rates",
"publisher": "Example Media AG",
"site_name": "Example News",
"description": "The central bank cut its policy rate by 0.25 percentage points.",
"section": "Economy",
"word_count": 1240
}
| Field | Meaning |
|---|---|
author |
Author name(s); several are separated by ; |
published_at |
Publication date, ISO 8601 (format below) |
modified_at |
Last modification date, ISO 8601 |
language |
Language tag (BCP 47), e.g. de-CH |
canonical_url |
Canonical URL the page declares, absolute |
publisher |
Publishing organisation |
site_name |
Name of the site or periodical |
description |
The page's own summary (at most 2,000 characters) |
section |
Section or category (the first one) |
word_count |
Words in text — what was retrieved, not what the page declares |
Every field is null when the page does not say. Sources, in order of
preference: JSON-LD (the NewsArticle, Article, BlogPosting,
ScholarlyArticle or Report node, also inside @graph or arrays;
malformed JSON-LD is skipped or repaired, never an error), OpenGraph and
article:* meta tags, Highwire citation_* meta tags (scholarly pages),
<html lang> and <link rel=canonical> (which wins for canonical_url),
then trafilatura's metadata detection. Dates are
YYYY-MM-DDTHH:MM:SSZ (converted to UTC) when the page gives a time zone,
YYYY-MM-DDTHH:MM:SS when it gives a time without zone, and YYYY-MM-DD
(or YYYY-MM, YYYY) when it gives only a date — no time or zone is
invented.
Exact bytes: GET /v1/bodies/{sha256}#
Every fetch stores the exact bytes it retrieved, content-addressed by
sha256, and returns body_url (null when object storage is unavailable).
body_kind says what the hash covers:
origin: the HTTP entity body the origin (or the archive, fortier = archive) sent, after content-encoding (gzip/br) is removed — the bytes a plain HTTP client would hand you;rendered_dom: the document serialized by a browser (browser,proxy_render) after scripts ran — use it for text, not as proof of the origin's bytes. Non-HTML documents a browser downloads (PDFs) areorigin.
GET /v1/bodies/{sha256} (same Bearer key) streams the bytes with the origin
Content-Type, Content-Length, ETag: "<sha256>", X-Body-Kind and
Cache-Control: immutable; If-None-Match gives 304; HEAD returns the
headers only. A project can download only bodies it has retrieved itself
(fresh or from the shared cache); anything else is 404. Bodies are kept
while a cache entry points to them or a project retrieved them within the
last 30 days.
Tiers and escalation#
| Tier | What | Route |
|---|---|---|
direct |
HTTP with Chrome's TLS/HTTP2 fingerprint and headers, redirects, compression | standard |
browser |
Chromium (new headless) with per-site cookies; waits out JS interstitials | standard |
archive |
recent Wayback Machine snapshot (≤ 365 days), only when blocked | standard |
proxy |
residential network (several networks, failover between them) | premium (also per GB) |
proxy_render |
rendering service behind residential addresses | premium |
The ladder starts at direct (or at a tier learned for the domain, see
architecture). It escalates on network errors, HTTP 403/429/451/5xx and
bot-challenge pages (Cloudflare, Akamai, DataDome, PerimeterX, Imperva,
Kasada, generic interstitials). Thin main text escalates direct → browser
only. archive and the premium routes are used only when the other standard routes were
blocked — never for a page that is simply short. Definitive answers (404,
410, 401, …) are returned without escalation — a 404/410 on direct never
reaches a premium route (at most the archived copy, see below). No CAPTCHA is
solved unless the operator enabled it for the project.
Domains that refuse the premium routes too. When a fetch fails on every tier
and every premium route it tried was refused by the site (403/429/451, a
challenge, a rendering-tier captcha or "cannot render", the provider's
content policy —
not provider errors or timeouts) in 2 fetches in a row, the domain is
premium-blocked for 6 hours (then 24 h, then 3 days on repeats): its fetches
still try direct, browser and the archive, but skip proxy and
proxy_render (attempt reason premium_skipped_domain_refuses, no premium bytes)
and go straight to the alternative sources, or fail with all_tiers_failed.
5 % of these fetches re-probe the premium routes anyway; one premium success ends
the block.
Timing. Each attempt's ms splits into wait_ms (waiting for a polite
per-domain slot) and upstream_ms (the site, browser, archive or
premium route). timing sums them for the request and adds queue_ms, the time
in Cormorant's queues before a worker ran it (synchronous: from the API to a
worker; batch: since admission, politeness requeues included; plus the render
queue after a browser handoff): {"queue_ms": 35, "politeness_wait_ms": 0, "upstream_ms": 412}. The same three components are stored per request in the
request log and summarised in GET /v1/usage.
Every attempt carries premium_bytes (what the residential network billed for it;
0 for standard routes and proxy_render, which bills per call). The proxy
route can show more than one attempt: when the site refuses a residential
exit, or the network fails, the request is retried at once through another
residential network, each attempt with its own status, reason and premium
bytes. Prices are per route and do not depend on which network served the
page. A failed fetch says
which premium routes were tried in premium_routes_tried and summarises every attempt
in error_detail, e.g. "all_tiers_failed: direct (http_403), browser (challenge_akamai), proxy (http_403, 16211 premium bytes), proxy_render (http_403); premium routes tried: proxy, proxy_render (16211 premium bytes)".
Dead domains and missing pages. A host whose name does not exist
(NXDOMAIN — not a temporary DNS failure such as SERVFAIL or a timeout, which
stay blocked_destination with reason dns_error) twice within 6 hours is
remembered as dead for 6 hours: fetches answer error: "domain_dead" at once,
with a single {"tier": "dns", "reason": "domain_dead"} attempt and no tier
work. When a fetch ends in domain_dead or a 404/410 and allow_archive is
true (and tiers, if given, includes archive), Cormorant looks for a Wayback
snapshot (≤ 365 days) and, if there is one, returns it instead: tier: "archive", archived_at, and served_from_archive_because: "domain_dead" or
"not_found"; attempts still shows the 404 or the dead host. These stand-in
copies are not cached. The archive's paywall rules apply (no copies for known
paywalled domains or of articles marked subscriber-only, archive_blocked_paywall).
Without a snapshot the answer stays domain_dead or http_404/http_410
(with an archive_not_found attempt), and only then are alternative sources
looked for. Other definitive statuses (401, …) never use the archive.
render=never uses direct → archive → proxy; render=always uses
browser → proxy_render.
Known hard domains. For sites on Cormorant's list of hard domains
(nestle.com, nestleusa.com, beyondmeat.com, apnews.com, nytimes.com,
researchgate.net, … — see Fetching → Known hard
domains) tiers that do not work for the
site are skipped (hard_domain_skip attempts), the residential exit country is
pinned (unless the request sets country) and proxy_render starts at the
site's difficulty. Errors — and archive or alternative copies standing in —
carry hint (the recommended route) and, on errors, alternative_urls. An
explicit tiers list overrides the route. The proxy tier sends GET requests
with Chrome's TLS/HTTP2 fingerprint, like direct.
attempts[].reason values include ok, thin_content, not_modified,
challenge_<vendor>, http_<status>, timeout, tls_error, dns_error,
dns_nxdomain, domain_dead, network_*, not_configured, budget_exhausted,
hard_domain_skip,
archive_not_found, premium-route reasons (…_captcha_rejected,
…_no_credits, …_render_503), paywall (teaser
of a hard paywall, returned as is), paywall_skip_premium, premium_skipped_domain_refuses,
archive_blocked_paywall, and
for the alternative-source steps (tier: source) alternate_<source> (with the copy's
URL in detail), syndication_candidates_<n>, open_access_none,
open_access_miss, guardian_api_miss, publisher_feed_miss,
publisher_feed_teaser, common_crawl_none, common_crawl_not_free,
common_crawl_paywalled_domain, common_crawl_index_error,
common_crawl_record_error, source_timeout. proxy_render attempts carry
detail with the difficulty used (low, medium, high); other attempts
omit detail. Busy rendering-tier answers (…_render_503) are retried
once and appear as two attempts.
Errors#
error |
Meaning |
|---|---|
invalid_url |
not an http(s) URL |
blocked_destination |
private, loopback, link-local or metadata address (also after redirects) |
robots_disallowed |
robots.txt disallows the path for CormorantBot/* |
too_large |
body exceeds max_bytes (batch PDFs with max_bytes unset: 200 MB) |
pdf_not_accepted |
PDF with accept_pdf=false |
http_<status> |
definitive upstream status (e.g. http_404) |
all_tiers_failed |
every allowed tier failed or was blocked (status = last HTTP status; error_detail and premium_routes_tried say what was tried) |
domain_unavailable |
every tier failed for this domain in 3 consecutive fetches: it is skipped for a while (10 min, then 30 min, 2 h, 6 h); retry_after_s says when to try again. Any success ends the skip |
domain_dead |
the host name does not exist (NXDOMAIN twice within 6 h); answered at once for 6 h, without trying. With allow_archive, a snapshot is returned instead when one exists (served_from_archive_because: "domain_dead") |
timeout |
timeout_s ran out (see per-request controls), or a large PDF took longer than 5 minutes to download (attempt reason pdf_download_timeout) |
no_tier_allowed |
tiers/allow_premium left no tier to try |
politeness_timeout |
could not get a per-domain slot in time (transient; batch items are requeued) |
domain_busy |
synchronous calls only, with HTTP 503 and Retry-After: the expected wait for one of the domain's polite slots exceeds the 15 s a synchronous call waits; retry_after_s is that expected wait. Lower the parallelism per domain or use a batch (whose items wait instead) |
sec_not_configured |
SEC EDGAR URL, but the operator has not configured Cormorant's SEC identity: SEC is disabled |
rate_limited |
SEC EDGAR URL: the cluster-wide SEC rate limit (at most 10 requests/s for all clients) had no slot within 10 s; retry after retry_after_s (transient) |
sec_unavailable |
SEC EDGAR throttled (403/429/503: all SEC requests back off), failed (5xx) or could not be reached; retry after retry_after_s (transient) |
redirect_not_allowed |
SEC EDGAR URL that redirected to a host outside SEC (not followed) |
premium_route_required |
a host fetched only through premium routes with allow_premium: false or a tiers list without proxy/proxy_render |
budget_exhausted |
such a host, and the project's budget leaves nothing for the premium routes |
When no tier reaches min_text_chars, the attempt with the most text is
returned (no error).
Batch: POST /v1/batch/search and POST /v1/batch/fetch#
{
"items": [{"query": "..."}, {"query": "..."}], // same objects as the single endpoints
"idempotency_key": "chipiron-run-2026-10-20-search-0001",
"callback_url": "https://chipiron.example/hooks/cormorant" // optional
}
Response 202 {"job_id": "…", "created": true}. Re-posting the same
idempotency_key returns the existing job (200, created: false); the same
key with a different kind or item count is 409. Up to 50,000 items per job.
Fetch items may set "include_html": true to keep the HTML in stored results
(default: text only). Batch fetch items that leave max_bytes unset accept
PDFs up to 200 MB (see Large PDFs above).
Items are admitted to the queues gradually (fair share across running jobs), executed with the same logic as the single endpoints, and retried with backoff only for transient problems. A batch fetch item blocked on our own tiers is retried once after a cool-down before a premium route is used.
When the job completes, callback_url receives a POST with
{"job_id","kind","status","total","succeeded","failed","finished_at"} (3
attempts; private addresses are refused). The host is resolved once, checked,
and the connection goes to that checked address (Host header and TLS name
stay the callback's host name), so a DNS answer that changes in between cannot
redirect it; redirects are not followed.
GET /v1/jobs/{job_id}#
{
"job_id": "1d0c...",
"kind": "fetch",
"status": "running", // queued | running | completed | cancelled
"counts": {"total": 3000, "pending": 1200, "queued": 380, "done": 1390, "failed": 30, "cancelled": 0},
"created_at": "...", "updated_at": "...", "finished_at": null,
"callback_url": null, "callback_status": null,
"items_url": "/v1/jobs/1d0c...?include=items&offset=0&limit=100"
}
Add ?include=items&offset=0&limit=1000 (max 1000) to page through item
results, optionally &status=failed:
"items": [
{"index": 0, "status": "done", "input": {"url": "..."}, "result": { ...fetch response... }, "error": null}
]
Retention: jobs and their item results are kept for 30 days after the job
finishes, then deleted (JOB_RETENTION_DAYS). Download results you need to
keep before that. If a stored result cannot be read (object storage
unavailable), the item has "result": null and "error": "result_unavailable";
retry the request later.
DELETE /v1/jobs/{job_id}#
Cancels the job: no new items are admitted (the admission pump runs every
3 s, well within 60 s), pending items become cancelled, queued items skip
themselves. Returns the job status. Jobs are only visible to their project.
SEC EDGAR: /v1/sec/...#
Company and filing data from SEC EDGAR. Cormorant sends its own declared
identity to SEC ("<Org name> <contact email>", set by the operator) and
keeps all clients together under SEC's fair-access limit: one cluster-wide
rate limit per egress IP (default 8 requests/s, never more than 10), queued
for up to 10 s, and a shared backoff when SEC throttles (403/429/503). Only
the direct tier is used — never the browser, archived copies, the residential network or
alternative sources. Do not send your own User-Agent for SEC: a
user_agent/accept_language in POST /v1/fetch is ignored for SEC hosts.
The same policy applies to POST /v1/fetch and batch items for URLs on
www.sec.gov, data.sec.gov and efts.sec.gov.
All endpoints need an API key, count towards the project's rate limit and
are metered as kind sec (no provider cost). Answers are HTTP 200 also when
they failed; check error:
error |
Meaning |
|---|---|
unknown_identifier |
neither SEC nor Cormorant knows this ticker, CIK, name or ISIN (a home-market ticker of a non-US company: use its ADR ticker or the ISIN) |
not_sec_filer |
the company is known (a non-US listing such as NESN.SW / XSWX:NESN, the company register, GLEIF for an ISIN) but not registered with the SEC under this identifier; hint: company, listing, sec_candidates (SEC filers with its name, e.g. the ADR) |
not_found |
SEC has no such filing or document (also: no filing of the requested forms) |
invalid_identifier |
a CIK, accession number, document name, form type, date or query is malformed (message says which); nothing was requested from SEC |
rate_limited |
no slot in the shared SEC rate limit within 10 s; retry after retry_after_s |
sec_unavailable |
SEC throttles (all SEC requests back off) or failed; retry after retry_after_s |
sec_not_configured |
SEC is not enabled on this deployment |
timeout, too_large, robots_disallowed, … |
the fetch errors |
Identifiers: CIKs as a number or 10 digits (100493, 0000100493);
accession numbers with or without dashes (0000100493-25-000070,
000010049325000070); form types comma-separated (10-K,20-F,40-F,10-K/A).
Only the SEC URL patterns behind each endpoint can be requested.
Every answer has error, message, retry_after_s, cached (true when
all SEC documents came from Cormorant's cache), retrieved_at (oldest
document), source_url (the main SEC document), body_url and sha256
(its exact bytes, e.g. the raw submissions JSON) and request_id.
Caching. Filing documents and indexes (/Archives/edgar/data/{cik}/{accession}/…)
are immutable and cached for 365 days (revalidated only when you ask with
cache_ttl_s); company_tickers.json, submissions and XBRL facts for 12 h,
then revalidated with conditional requests; full-text search for 1 h.
GET /v1/sec/companies/resolve?ticker=TSN#
Also ?cik=100493 or ?name=tyson foods (exactly one). From
company_tickers.json and company_tickers_exchange.json:
{"cik": 100493, "cik_padded": "0000100493", "title": "TYSON FOODS, INC.",
"tickers": ["TSN"], "exchanges": ["NYSE"], "ambiguous": false,
"candidates": [{"cik": 100493, "cik_padded": "0000100493", "title": "TYSON FOODS, INC.", "tickers": ["TSN"], "exchanges": ["NYSE"]}],
"error": null, "cached": true, "source_url": "https://www.sec.gov/files/company_tickers.json", "…": "…"}
BRK.B and BRK-B are the same ticker. A name matches exact titles first,
then titles starting with or containing it; when several companies match,
ambiguous is true, the company fields are null and candidates lists
up to 10. A CIK without a listed ticker is resolved from its submissions.
GET /v1/sec/companies/{cik}/submissions#
Query: forms, since, until (filing dates, YYYY-MM-DD), limit
(default 100, max 10,000), history (default true: also read the pages of
older filings SEC lists under files, only those that can hold filings
after since). Company fields cik, cik_padded, name, title,
tickers, exchanges, entity_type, sic, sic_description, category,
fiscal_year_end, state_of_incorporation, ein, lei, website,
phone, business_address, former_names; filings (newest first, at most
limit), filings_total, truncated, and files (name, filing_count,
filing_from, filing_to, url, fetched, body_url). Each filing:
| Field | From SEC's column |
|---|---|
accession |
accessionNumber |
form, filing_date, report_date, acceptance_datetime |
form, filingDate, reportDate, acceptanceDateTime |
act, file_number, film_number, items, core_type, size |
act, fileNumber, filmNumber, items, core_type, size |
is_xbrl, is_inline_xbrl |
isXBRL, isInlineXBRL (as booleans) |
primary_document, primary_document_description |
primaryDocument, primaryDocDescription |
primary_document_url, index_url |
built: the document and the -index.htm page (null URL when the primary document is in a sub-folder, e.g. ownership forms) |
is_amendment |
built: the form ends in /A |
GET /v1/sec/companies/{cik}/filings/latest?forms=10-K,20-F,40-F#
The newest filing (by filing date) of forms — default 10-K,20-F,40-F
(annual reports; amendments only when you list them, e.g. 10-K,10-K/A):
the filing fields above at the top level (accession, form,
filing_date, report_date, primary_document, primary_document_url,
index_url, is_amendment, …) plus cik, cik_padded, name, tickers,
exchanges. Older filing pages are searched when the recent list has none;
none at all: not_found.
GET /v1/sec/filings/{cik}/{accession}#
The filing index: form, filing_date, report_date, accepted,
company, is_amendment, index_url, folder_url, accession,
accession_nodash, index_source (index_htm, or index_json when only the
folder listing has the documents) and documents: sequence,
description, document (file name), type (10-K, EX-21, EX-101.SCH,
…), size, url. Sub-folders are not listed.
POST /v1/sec/documents/fetch#
{"cik": 100493, "accession": "0000100493-25-000070", "document": "tsn-20250927.htm",
"max_bytes": 52428800}
Fetches one document of a filing (HTML/iXBRL, PDF, exhibit, XML) and answers
exactly like POST /v1/fetch (text, pdf_text_pages, content_kind,
body_url, sha256, body_kind: origin, …). Optional: include_html
(default true), accept_pdf, cache_ttl_s, timeout_s, project_ref.
Invalid identifiers answer error: invalid_identifier in that shape.
GET /v1/sec/companies/{cik}/facts#
A summary of data.sec.gov/api/xbrl/companyfacts/CIK##########.json:
cik, cik_padded, name, taxonomies (concepts per taxonomy),
concepts, bytes; the complete JSON is at body_url.
GET /v1/sec/search?q=…&forms=…&dateRange=…#
EDGAR full-text search (filings since 2001). Query: q (EDGAR syntax),
forms, dateRange (all, 10y, 5y, 1y, 30d, or custom with
startdt/enddt), from (offset, pages of 100). Answer: query, total,
total_relation, hits (accession, ciks, display_names, form,
root_forms, file_type, file_description, file_date, period_ending,
document, url, index_url, score).
Batch: POST /v1/batch/sec#
Up to 50,000 SEC operations as one job of kind sec:
{
"items": [
{"op": "resolve", "ticker": "TSN"},
{"op": "latest", "ticker": "TSN", "forms": "10-K"},
{"op": "latest", "cik": 100493},
{"op": "document", "cik": 100493, "accession": "0000100493-25-000070", "document": "tsn-20250927.htm"}
],
"idempotency_key": "chipiron-run-2026-10-20-sec-0001",
"callback_url": "https://chipiron.example/hooks/cormorant"
}
op is resolve (ticker, cik or name), submissions (cik or
ticker, forms, since, until, limit, history), latest (cik or
ticker, forms), facts (cik or ticker), filing (cik,
accession) or document (cik, accession, document, max_bytes,
accept_pdf, include_html — default false in a batch); project_ref
on any item. Identifiers are checked when the job is created (422 invalid_item naming the item). Item results have the shape of the single
endpoint (plus op); document results that of POST /v1/sec/documents/fetch. Items share the cluster-wide SEC rate limit with
every other SEC request (at most 10 requests/s per egress IP) and are
admitted a few at a time: a full limiter or a SEC backoff puts an item back
in the queue instead of failing it. Each item is metered once as kind sec
(a ticker resolved inside an item is not extra) and counts as fetch for
key limits.
GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD#
Usage of the caller's scope and what it costs. Inclusive dates (UTC), default: current month to date, at most 400 days. Read from aggregates refreshed every 5 minutes, never from the request log, so it answers in the same time at any volume.
Who sees what. A standard key (cmr_…) sees only its own requests. An
account admin key (cma_…) sees every key and project of its customer —
revoked keys included, and usage recorded before per-key attribution as
"key": null. A project without a customer is its own account. Nothing ever
crosses a customer boundary; a key or project you cannot see is 404, like
one that does not exist.
Parameters
| Parameter | Meaning |
|---|---|
from, to |
Range of UTC days (inclusive). |
group_by |
Comma-separated, combinable: key, project, category, day, hour (hourly data: last 35 days). |
key |
Only these keys (comma-separated prefixes, e.g. cmr_AbCdEf). |
project |
Only these projects (comma-separated names). |
category |
Only these billing categories: search, search_fallback, search_cached, fetch_own, fetch_premium, fetch_cached, company, company_cached, sec, sec_cached, company_profile, company_profile_cached. |
format |
csv (same as Accept: text/csv). |
{
"project": "acme-crawler", "key": "cma_XyZaBc", "role": "account_admin",
"scope": {"customer": "Acme", "projects": ["acme-crawler", "acme-search"], "keys": "all"},
"from": "2026-10-01", "to": "2026-10-19",
"as_of": "2026-10-19T11:05:02+00:00", "final": false, "currency": "USD",
"totals": {"requests": 9120, "succeeded": 8800, "failed": 320, "success_rate": 0.9649,
"cached": 1200, "cache_hit_rate": 0.1316,
"errors": {"http_404": 210, "all_tiers_failed": 110},
"latency_ms_p50": 900, "latency_ms_p95": 7400, "bytes": 812345678,
"premium_requests": 90, "premium_share": 0.0113, "charge": "15.96",
"billable": {"fetch_own": {"quantity": "8710", "included": "1000",
"billable": "7710", "amount": "15.42"},
"fetch_premium": {"quantity": "90", "included": "0",
"billable": "90", "amount": "0.36"},
"premium_gb": {"quantity": null, "included": null,
"billable": null, "amount": "0.18"}}},
"search": {"requests": 1200, "cached": 300, "cache_hit_rate": 0.25, "succeeded": 1198,
"success_rate": 0.998, "bytes": 0, "premium_bytes": 0,
"latency_ms_p50": 1100, "latency_ms_p95": 3900, "fallbacks": 60,
"fallback_rate": 0.0667, "providers": {"searxng": {"requests": 840, ...}, "brave": {...}}},
"fetch": {"requests": 7920, "cached": 900, "tiers": {"direct": {...}, "proxy": {...}},
"errors": {"http_404": 210},
"by_outcome": {"succeeded": {"requests": 7600, "bytes": 800000000,
"premium_bytes": 8800000, "premium_requests": 80},
"failed": {"requests": 320, "bytes": 0,
"premium_bytes": 323456, "premium_requests": 10}}, ...},
"company_profile": {}, // extended company profiles (kind company_profile), same fields; {} when none
"group_by": ["key"],
"groups": [{"key": "cmr_AbCdEf", "key_label": "crawler", "key_role": "standard",
"key_status": "active", "project": "acme-crawler", "requests": 9000, ...,
"charge": "15.70", "billable": {...}}],
"keys": [{"prefix": "cmr_AbCdEf", "label": "crawler", "role": "standard",
"project": "acme-crawler", "active": true, "requests": 9000, "cached": 1150,
"succeeded": 8690, "search_requests": 1100, "fetch_requests": 7900, "company_requests": 0,
"company_profile_requests": 0,
"premium_bytes": 9123456, "charge": "15.70"}],
"account": [{"period": "2026-10", "from": "2026-10-01", "to": "2026-10-19", "currency": "USD",
"complimentary": true, "usage_charges": "15.96", "account_level_amount": "-15.96",
"account_lines": [{"kind": "complimentary", "description": "Complimentary plan: 100 % discount",
"amount": "-15.96"}],
"list_total": "15.96", "net_total": "0.00"}]
}
- Metrics (in
totalsand every group):requests(cache hits included),succeeded,failed,cached,errors(failures by error code, top 50),latency_ms_p50/latency_ms_p95of uncached requests (estimated from a latency histogram with bounds 100, 250, 500 ms, 1, 2, 5, 10, 20, 30, 60 and 120 s — exact to the bucket),bytes,premium_requestsandpremium_share(fetches that needed the premium routes, of all uncached fetches). - Charges (projects with a customer):
chargeis what the group costs at your plan's prices in your invoice currency, as a decimal string;billablelists each price-plan meter with the measuredquantity, theincludedunits it used (its pro-rata share of the plan's included units of the month),billableandamount. Meters billed on provider cost show only the amount. The period is rated exactly like the invoice and each usage line is split to the cent, first per key, then per day and category: any grouping adds up to the same total, and the keys add up to the usage lines of the estimate (GET /v1/account) or invoice. Base fee, minimum, contract discount and the complimentary credit are account-level lines: account admin keys see them per period inaccount. Complimentary accounts see list-price charges and the 100 %complimentaryline. - Provider cost —
cost_usdand the projectbudget(Cormorant's spend with its search and proxy providers) appear only for projects without an external customer (internal use). Customers see what they pay, never what Cormorant pays. - Freshness:
as_ofis the time up to which every request is included;final: true(top level, and per group withgroup_by=day) means the days are closed and will not change (a day becomes final two hours after it ends). searchandfetchkeep the per-kind blocks of earlier versions (now for the caller's scope), withproviders/tiersbreakdowns. Withkeyingroup_by,keyskeeps the earlier per-key list as well.- Latency components: every per-kind block has
latency_components(queue_ms,politeness_wait_ms,upstream_ms, each{p50, p95}of uncached requests), and each entry oftiers/providersaddslatency_ms_p50/p95and<component>_p50/_p95, so a slow tier can be told apart from a busy domain or a queue backlog. Estimated from histograms like the other percentiles; requests from before these were recorded are not included. by_outcome(in every per-kind block) splits the same requests intosucceededandfailed, each withrequests,bytes,premium_bytes,premium_requests(fetch) andcost_usd(provider cost, internal projects only):fetch.by_outcome.failed.premium_bytesis what blocked pages cost before the fetch gave up. Whether failed requests are billed depends on your plan (billable).
CSV. Accept: text/csv (or format=csv) returns one row per group — or
one row of totals without group_by — with the dimensions, the metrics,
charge, the meters as <meter>_quantity, <meter>_included,
<meter>_billable, <meter>_amount and errors as code=n;code=n.
curl -sS "$CORMORANT_URL/v1/usage?from=2026-10-01&to=2026-10-31&group_by=key,day" \
-H "Authorization: Bearer $CORMORANT_API_KEY" -H "Accept: text/csv" -o usage.csv
GET /v1/usage/timeseries#
The same metrics per granularity=hour (last 35 days; default range: today)
or granularity=day (default: month to date), one point per bucket up to now
— buckets without usage are zero, so the points can be charted directly.
group_by (key, project, category, combinable) gives one series per
value; key, project and category filter as for GET /v1/usage. Same
scope rules, same charges (an hour's charge is its share of the day's), CSV
with Accept: text/csv.
{"granularity": "hour", "from": "2026-10-19", "to": "2026-10-19", "as_of": "2026-10-19T11:05:02+00:00",
"final": false, "currency": "USD", "group_by": ["key"],
"series": [{"key": "cmr_AbCdEf", "key_label": "crawler", "key_role": "standard",
"key_status": "active", "project": "acme-crawler",
"points": [{"t": "2026-10-19T00:00:00+00:00", "final": false, "requests": 410,
"succeeded": 402, "failed": 8, "latency_ms_p95": 6100,
"premium_share": 0.01, "charge": "0.82", ...}, ...]}]}
GET /v1/usage/domains#
The busiest fetched domains of the caller's keys over the range (kept 90
days): requests, succeeded, success_rate, premium_requests and
premium_share per domain, busiest first (limit, default 50, at most 500).
group_by=key returns a top list per key. Built from each key's 100 busiest
domains per day; usage recorded before per-key attribution is not included.
{"from": "2026-10-01", "to": "2026-10-19",
"domains": [{"domain": "www.example.com", "requests": 1200, "succeeded": 1150,
"success_rate": 0.9583, "premium_requests": 40, "premium_share": 0.0333}]}
GET /v1/requests#
The request ledger, newest first and in real time: one row per search, fetch or batch item of the caller's scope (a standard key: its own requests; an account admin key: the whole customer). Kept as long as the request log (90 days; default range: the last 30 days).
| Parameter | Meaning |
|---|---|
from, to |
Range of UTC days. |
key, project, category |
As for GET /v1/usage. |
kind |
search, fetch, company (/v1/companies/…), sec (SEC EDGAR: /v1/sec/… and fetches of SEC hosts) or company_profile (extended company profiles). |
outcome |
succeeded or failed. |
error, domain, project_ref, job_id, cached |
Exact matches. |
limit |
Rows per page, 1–1000 (default 100). |
cursor |
next_cursor of the previous page. |
{"from": "2026-09-20", "to": "2026-10-19", "as_of": "2026-10-19T11:05:02+00:00",
"currency": "USD", "limit": 100, "next_cursor": "MjAyNi0xMC0xOVQxMTowMjoxMyswMDowMHw5ODc2",
"requests": [{"request_id": "req_6b1f2c0e9a7d4b5c8e3f1a2b3c4d5e6f",
"time": "2026-10-19T11:02:13+00:00", "key": "cmr_AbCdEf", "key_role": "standard",
"project": "acme-crawler", "project_ref": "company:4711", "job_id": null,
"kind": "fetch", "domain": "www.example.com", "tier": "direct", "status": 200,
"ok": true, "error": null, "latency_ms": 840, "cached": false, "bytes": 48211,
"premium": false, "category": "fetch_own", "charge": "0.001842"}]}
Page with cursor until next_cursor is null; the next page is also in
the Link: <…>; rel="next" header (and X-Next-Cursor). Pages are keyset
pages, so they stay fast and consistent however deep you go, while new
requests arrive on top. charge is the request's share of its key's
charges for that day and category (their average price, 6 decimals); it is
null until the request is aggregated (within 5 minutes) and settles when
the day is final. GET /v1/requests/{request_id} returns one request with
reason and the tiers tried (attempts); another key's (standard keys) or
another customer's request is 404. CSV with Accept: text/csv (one page;
the next cursor is in X-Next-Cursor).
GET /v1/quota#
Every limit that applies to the caller this month, each with limit,
used, reserved, remaining, resets_at, projected_end_of_period
(linear extrapolation to the end of the month) and status (ok,
warning from 80 %, exhausted): the key's spend cap
(monthly_spend, money as decimal strings), request quotas
(monthly_requests.<category>) and rate limit; the project's rate limit
(and provider budget, for projects without an external customer). An account
admin key gets every active key of the account and, for a customer, the plan's
included units (included.<meter>).
{"key": "cmr_AbCdEf", "role": "standard", "period": "2026-10",
"resets_at": "2026-11-01T00:00:00+00:00", "as_of": "2026-10-19T11:05:02+00:00",
"usage_refreshed_at": "2026-10-19T11:05:00+00:00",
"keys": [{"key": "cmr_AbCdEf", "key_label": "crawler", "key_role": "standard",
"project": "acme-crawler", "is_self": true, "allowed_categories": null,
"usage": {"requests": {"search": 120, "fetch": 81234, "fetch_premium": 412},
"spend": "38.12", "currency": "USD"},
"limits": [{"name": "monthly_requests.fetch", "unit": "requests", "limit": 100000,
"used": 81234, "reserved": 0, "remaining": 18766,
"resets_at": "2026-11-01T00:00:00+00:00",
"projected_end_of_period": 132550, "status": "warning"},
{"name": "monthly_spend", "unit": "USD", "limit": "50.00", "used": "37.90",
"reserved": "0.22", "remaining": "11.88", "status": "ok", ...}]}],
"project": {"project": "acme-crawler",
"limits": [{"name": "rate_limit", "unit": "requests/s", "limit": 200, "used": 3, ...}]}}
reserved is the spend estimated since the last aggregation (or, for the
provider budget, spend held for calls in flight). events lists the
thresholds crossed this month in your scope (see Alerts below).
alert_email says whether alert e-mails reach your account's alert
addresses (there may be several): status is deliverable, not_set,
partially_undeliverable (some addresses) or undeliverable (all), with
addresses, undeliverable (counts), and reason (bounced or
complained) and since of the first undeliverable one. After a hard
bounce or a spam complaint no further alert mail goes to that address until
it is changed (contact us); webhooks are not affected.
Alerts: POST, GET, DELETE /v1/alerts#
A webhook receives an event whenever something in its scope crosses 50, 80
or 100 % of a limit or allowance in a month (each threshold fires once per
month): a key's spend cap or request quota, a project's provider budget
(projects without an external customer) or the plan's included units of the
account. The same events are listed in GET /v1/quota (events).
POST /v1/alerts
{"url": "https://hooks.example.com/cormorant", "scope": "customer", "thresholds": [80, 100]}
201 {"id": 7, "url": "https://hooks.example.com/cormorant", "scope": "customer", "key": null,
"project": null, "thresholds": [80, 100], "created_at": "2026-10-19T11:00:00+00:00",
"last_delivery_at": null, "last_status": null, "secret": "whsec_9f2c…"}
scope:key(one key;key= its prefix, default the calling key),project(project= name; the project and its keys) orcustomer(the whole account). A standard key can only watch itself (403 account_admin_requiredotherwise); an account admin key anything in its account. Default:keyfor standard keys,customerfor admin keys.thresholds: any of50,80,100(default all).urlmust behttpsand resolve to a public address; at most 20 webhooks per account.secretis returned only once.GET /v1/alertslists the webhooks you can see (never secrets) withlast_delivery_atandlast_status;DELETE /v1/alerts/{id}removes one.
Deliveries are POST <url> with the event as JSON:
{"id": "evt_42", "created_at": "2026-10-19T11:05:03+00:00", "type": "usage.threshold",
"scope": "key", "customer": "Acme", "project": "acme-crawler", "key": "cmr_AbCdEf",
"key_role": "standard",
"limit": {"name": "monthly_spend", "unit": "USD", "limit": "50.00", "used": "40.12",
"threshold": 80, "period": "2026-10", "resets_at": "2026-11-01T00:00:00+00:00"}}
Headers: X-Cormorant-Event: usage.threshold, X-Cormorant-Delivery: dlv_…
and X-Cormorant-Signature: t=1760871903,v1=5257a8…, where v1 is the hex
HMAC-SHA256 of "<t>.<raw body>" with your secret. Verify it in constant
time and reject a t older than a few minutes:
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
mac = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256)
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(mac.hexdigest(), parts["v1"])
Any 2xx answer counts as delivered; other answers and network errors are
retried after 1, 5, 15, 60, 180, 360 and 720 minutes (8 attempts). Redirects
are not followed, and each delivery connects to the address that passed the
check (no DNS rebinding). Events are evaluated with the usage aggregates (every 5
minutes). Use id to ignore duplicates.
GET /v1/keys and GET /v1/keys/self#
The keys the caller may see — by prefix, label and role, never the key itself:
every key of the customer's projects for an account admin key (revoked keys
included), only itself for a standard key. The prefix always shows the role:
cma_… is an account admin key, cmr_… a standard key.
{"role": "account_admin",
"keys": [{"prefix": "cma_XyZaBc", "label": "ops", "role": "account_admin",
"project": "acme-crawler", "status": "active",
"created_at": "2026-09-01T08:00:00+00:00", "revoked_at": null,
"last_used_at": "2026-10-19T11:02:00+00:00", "is_self": true},
{"prefix": "cmr_AbCdEf", "label": "crawler", "role": "standard", "status": "revoked", ...}]}
Each key also has limits (monthly_spend_cap, currency, monthly_requests,
rate_limit_rps, allowed_categories; null without limits).
GET /v1/keys/self returns the calling key in the same shape plus its use
this month (usage) and quota state (quota, the entries of GET /v1/quota).
Account and invoices#
Keys belong to a project; a project may belong to a customer (the billed
account). These endpoints show the key's own project usage plus the plan,
current-period estimate and issued invoices of that customer — never another
customer's data, never drafts. Money is a decimal string in the invoice
currency (USD, CHF or EUR). Usage is aggregated every 5 minutes (usage_refreshed_at).
See billing.md for pricing rules.
GET /v1/account#
{
"project": {"id": 7, "name": "acme-crawler"},
"period": {"period": "2026-10", "from": "2026-10-01", "to": "2026-10-31"},
"usage_refreshed_at": "2026-10-19T11:00:04+00:00",
"project_usage": {"fetch_own": {"requests": 9120, "succeeded": 8800, "bytes": 812345678,
"bytes_ok": 800000000, "premium_bytes": 0, "premium_bytes_ok": 0,
"cost_usd": "0", "cost_ok_usd": "0"}, "search": {...}},
// cost_usd / cost_ok_usd: null for projects of an external customer
"customer": {"name": "Acme", "company_name": "Acme Research AG", "currency": "USD",
"status": "active", "complimentary": true},
"customer_usage": {...},
"plan": {"id": 1, "code": "standard-usd", "version": 1, "name": "Standard (USD)",
"currency": "USD", "complimentary": false},
"estimate": {"period": "2026-10", "currency": "USD",
"lines": [{"kind": "usage", "component": "fetch_own",
"description": "Page fetches (1,000 included)", "quantity": "8800",
"unit": "fetch", "unit_price": null, "amount": "15.60",
"detail": {"included": "1000", "billable": "7800",
"tiers": [{"from": "0", "to": "100000", "quantity": "7800",
"unit_price": "0.002", "amount": "15.60"}]}},
{"kind": "complimentary", "description": "Complimentary plan: 100 % discount", "amount": "-15.60", ...}],
"subtotal": "15.60", "discount_total": "15.60", "net_total": "0.00",
"vat_rate": "8.1", "vat_amount": "0.00", "total": "0.00", "complimentary": true,
"list_total": "15.60"}
}
keys breaks the period down per API key. An account admin key (cma_…)
sees every key of the customer's projects (or of this project without a
customer); an additional key (cmr_…) sees only itself. Keys appear by
prefix, label and role, never ids or secrets, with its usage by category and amount, its share of
the usage charges at list price. Each usage line of the estimate is split
between keys in proportion to their share of that meter (to the cent), so the
amounts add up to the usage lines; base fee, minimum, discounts and the
complimentary credit stay on the account. A row with "prefix": null is usage recorded
before per-key attribution.
"keys": [{"prefix": "cma_XyZaBc", "label": "billing", "role": "account_admin", "project": "acme-crawler",
"active": true, "requests": 9120, "succeeded": 8800,
"usage": {"fetch_own": {"requests": 9120, ...}}, "amount": "15.60"}]
Projects without a customer get "customer": null, "plan": null, "estimate": null
(and amount: null per key).
Categories: search, search_fallback, search_cached, fetch_own,
fetch_premium, fetch_cached, company, company_cached, sec, sec_cached,
company_profile, company_profile_cached.
GET /v1/account/invoices#
{"invoices": [{"number": "CORM-2026-0001", "status": "issued|paid|void", "period": "2026-09", "period_start", "period_end", "currency", "plan_label", "complimentary", "subtotal", "discount_total", "net_total", "vat_rate", "vat_amount", "total", "vat_note", "issued_at", "due_at", "paid_at", "voided_at", "pdf_url": "/v1/account/invoices/CORM-2026-0001/pdf"}]}
GET /v1/account/invoices/{number} and /pdf#
The invoice with its lines (and tier breakdown), or the PDF. Another
customer's number (or a draft) is 404.
Company data#
The company register: one source-attributed record (company/v1) per
company Cormorant has been asked about, kept and versioned. Sources are
official registers, GLEIF and other open, legitimate sources
(GET /v1/companies/sources). Every data call is metered as company
(billing category and meter company; one request per call or batch item).
GET /v1/companies/sources, GET /v1/schemas/company and watch management
are not metered.
| Endpoint | Purpose |
|---|---|
GET /v1/companies/search?q=&country=&identifier=&status=&legal_form=&limit=&cursor=&live= |
Register matches (trigram/full-text over current, former and trading names; exact on identifier) plus live hits from the sources covering country on the first page (unless the register has a near-exact hit, score ≥ 0.95; live=true always, live=false never). match_score 0–1, in_register, ref; live_sources[] per source {source, ok, status, error, ms} |
GET /v1/companies/{id} |
The record. id = cmp_… or scheme:value. max_age, refresh, include=people,ownership,filings,financials,events,claims, sources= |
GET /v1/companies/{id}/history?field= |
Claims timeline: every value each source asserted, with observed_at, valid_from, valid_to, current |
GET /v1/companies/{id}/{events,people,ownership,relationships,filings,financials,sources} |
Sub-resources with paging (limit, cursor); people accepts include_former |
POST /v1/companies/resolve |
{name, country, city, identifiers, isin, persist} → match (confident only; added to the register unless persist: false, so it has its cmp_ id and in_register: true) and ranked candidates |
POST /v1/companies/batch, POST /v1/companies/batch/csv |
Job of kind company: get, refresh or resolve per item; JSON or CSV in; results via GET /v1/jobs/{job_id} or GET /v1/companies/batch/{job_id}.csv |
GET /v1/companies/changes?since=&cursor=&country= |
Change feed of the register (company and changed top-level fields), oldest first |
POST/GET /v1/companies/watches, DELETE /v1/companies/watches/{id} |
Signed company.changed webhooks for a list of companies |
GET /v1/companies/sources |
Sources: countries, schemes, capabilities, precedence, licence URL, freshness classes, live health |
GET /v1/schemas/company |
JSON Schema (2020-12) of company/v1 |
Identifiers are written scheme:value and normalised (spaces, dots and
case do not matter; check digits are verified): ch_uid
(CHE-101.329.561), ch_chid, ch_ehraid, lei, us_cik, uk_crn,
de_register (München:HRB:6684), fr_siren, fr_siret, no_orgnr,
dk_cvr, fi_ytunnus, ee_regcode, cz_ico, pl_krs, be_kbo, au_abn,
au_acn, nz_nzbn, jp_corporate_number, br_cnpj, sg_uen, ca_corp,
vat (DE129273398, CHE101329561), isin (CH0038863350: the issuer,
found through GLEIF's ISIN-to-LEI mapping; the ISIN is then kept on the
record), ticker (XNAS:AAPL), wikidata. An invalid identifier (e.g. a
wrong ISIN check digit) is 422 invalid_identifier.
Refresh on request. A request answers from the register. Each source's
data about the company is re-fetched first when it is older than the
source's freshness class for that field group (e.g. registry core 24 h,
people 7 days) or than max_age (seconds); refresh=true re-fetches
everything. The answer's refresh lists per source and group what was
refreshed, fresh, failed (error class; the register still answers),
not_found, in_progress, pending (a refresh slower than 2 s, error: "deadline", or of a source whose canaries fail, error: "canary_failed":
it finishes in the background and the next request sees its data) or
skipped (the source backs off after repeated failures, error: "backoff"). Stale sources are refreshed concurrently and the answer waits
at most about 2 s for them; batch items wait for every refresh. A company not yet in the register is looked
up in the sources that know the identifier's scheme and added; unknown
everywhere is 404 company_not_found, and remembered (ISIN 24 h, other
schemes 1 h; refresh=true asks again): within that time the sources are
not asked and refresh shows not_found with error: "cached". Companies nobody asks for are never
re-scraped; official change feeds (e.g. GLEIF's daily updates) keep the ones
in the register current.
Provenance. Every value is a claim of a source; the record shows the
resolved value of each field, provenance (source, observed_at,
confirmed_at, sha256 of the raw document, conflict), quality
(completeness, conflicts where sources disagree) and sources[] (raw
documents with body_url — downloadable with GET /v1/bodies/{sha256} once
you have received the record). include=claims adds every current claim.
Precedence when sources disagree: official register > official gazette >
GLEIF > regulatory filings > crosswalks > commercial sources, then each
field's declared rule. names.legal is the national register's primary
entry and does not depend on refresh order (CH: zefix_rest >
lindas_zefix > uid_register > ch_cantonal_excerpt > sogc > gleif);
other sources' forms are in names.alternates ({name, source}), language
versions in names.translations (with source). A merged
record's cmp_ id keeps working and answers with the surviving record and
redirected_from.
curl -s "https://api.cormorant.ch/v1/companies/ch_uid:CHE-101.329.561?include=ownership" \
-H "Authorization: Bearer $CORMORANT_API_KEY"
{
"schema_version": "company/v1",
"id": "cmp_01JA2B3C4D5E6F7G8H9J0KMNPQ",
"identifiers": [{"scheme": "ch_uid", "value": "CHE-101.329.561", "issuer": "Federal Statistical Office (BFS)", "status": "active", "parts": null, "sources": ["gleif"]},
{"scheme": "lei", "value": "BFM8T61CT2L1QCEMIK50", "issuer": "GLEIF", "status": "active", "parts": null, "sources": ["gleif"]}],
"names": {"legal": "UBS AG", "previous": [{"name": "SBC AG", "lang": "en"}], "trading": [], "translations": [], "alternates": []},
"legal_form": {"local_code": "AG", "local_name": "Company limited by shares", "elf_code": "MVII", "category": "corporation"},
"status": {"value": "active", "local_value": "ACTIVE"},
"jurisdiction": {"country": "CH", "subdivision": null, "register": {"authority": "RA000549", "court": null, "entity_id": "CHE-101.329.561"}},
"addresses": [{"type": "registered", "lines": ["Bahnhofstrasse 45"], "street": "Bahnhofstrasse 45", "house_number": null, "postal_code": "8001", "city": "Zurich", "region": "CH-ZH", "country": "CH"}],
"dates": {"incorporated": "1978-02-28", "registered": null, "deregistered": null, "fiscal_year_end": null},
"ownership": [{"type": "direct_parent", "holder": {"name": "UBS Group AG", "identifiers": [{"scheme": "lei", "value": "549300SZJ9VS8SGXAN81"}], "country": "CH"}, "source": "gleif"}],
"provenance": {"names.legal": {"source": "gleif", "observed_at": "2026-10-19T08:00:00+00:00", "confirmed_at": "2026-10-19T08:00:00+00:00", "sha256": "9f86…", "conflict": false}},
"sources": [{"source": "gleif", "url": "https://api.gleif.org/…/BFM8T61CT2L1QCEMIK50", "retrieved_at": "2026-10-19T08:00:00+00:00", "sha256": "9f86…", "body_url": "/v1/bodies/9f86…", "content_type": "application/vnd.api+json", "licence": "https://www.gleif.org/en/meta/lei-data-terms-of-use", "parser_version": "gleif/1"}],
"quality": {"completeness": 0.778, "conflicts": []},
"refresh": [{"source": "gleif", "group": "core", "status": "fresh"}, {"source": "gleif", "group": "ownership", "status": "refreshed", "at": "2026-10-19T08:00:01+00:00"}],
"redirected_from": null,
"updated_at": "2026-10-19T08:00:01+00:00"
}
Latency. A company in the register is answered from it: resolve
with a confident register match and search with a near-exact register hit
ask no source. Otherwise the live sources are asked concurrently and waited
for at most 2.5 s (batch resolve items: 12 s); slower sources finish in
the background, sources backing off after repeated failures or with failing
canaries are not asked. live_sources (search and resolve) reports each
source as ok, failed (error: error class), timeout or skipped
(error: backoff or canary_failed) with ms. A newly stored resolve
match gets the other sources' data in the background.
Batch. POST /v1/companies/batch takes {action, items: [{ref} | {name, country, city, identifiers, isin}], include, max_age, persist, idempotency_key, callback_url}; POST /v1/companies/batch/csv takes a CSV body (header
ref,name,country,city,isin,identifiers or any subset, identifiers
separated by ;) with the same options as query parameters. An item with an
invalid ISIN fails alone (invalid_identifier). Item results: {company_id, record} for
get/refresh (the record as above), {match, candidates, company_id} for
resolve, or {error} (company_not_found, invalid_identifier).
Change feed and watches. GET /v1/companies/changes returns {items: [{seq, company_id, kind: created|updated|merged|split, fields, country, changed_at}], next_cursor}; start with since (ISO 8601) and continue with
cursor. POST /v1/companies/watches with {companies: [ids or scheme:value], url} (HTTPS, public address) returns the watch with its
secret once (at most 20 watches per project, 1000 companies each). Changes
of watched companies are POSTed every minute as {event: "company.changed", id, watch_id, changes} with X-Cormorant-Event, X-Cormorant-Delivery and
X-Cormorant-Signature: t=<unix>,v1=<hex> (HMAC-SHA256 of "<t>.<raw body>", as for usage alerts). Any 2xx is delivered; otherwise retried after
1, 5, 15, 60 and 180 minutes, then hourly, without skipping changes. A
watched record merged into another is followed.
Errors: 404 company_not_found, 422 invalid_identifier,
422 invalid_parameter (e.g. unknown include), 422 invalid_item (batch);
otherwise as for every endpoint (401, 403, 429).
Service status#
Public status of the service for your monitoring, and the page at
/status. No authentication (an Authorization header is
accepted and ignored), CORS-enabled, refreshed at most every 15 s and shared
by all callers (Cache-Control: public, max-age=15, ETag /
If-None-Match → 304). Limited to 120 requests per minute per client IP
(429 with Retry-After). The status never contains host names, addresses,
instance URLs, project names, keys or internal error strings — only component
ids, statuses, reason codes and aggregate numbers.
The status endpoints answer 200 whatever the service status: the
status is in the body. Alert on the body (status is not operational), and
treat a timeout, a connection error or any other HTTP code as "Cormorant is
unreachable" — a status endpoint that answered 503 during an outage could
not be told apart from a broken proxy or network in between.
| Endpoint | Returns |
|---|---|
GET /v1/status |
overall status, every component with reasons and metrics, incidents and maintenance |
GET /v1/status/summary |
only status, description, updated_at (for uptime monitors) |
GET /v1/status/components/{id} |
one component plus the incidents and maintenance naming it (404 for an id not in this deployment) |
GET /v1/status/history?hours=24 |
worst status per 5 minutes (per hour beyond 24 h, up to 168 h) and uptime per component; overall as _overall |
{
"status": "degraded",
"description": "Degraded performance",
"updated_at": "2026-10-19T09:41:15+00:00",
"window_s": 600,
"components": [
{"id": "api", "name": "API", "description": "…", "status": "operational", "reasons": [], "metrics": {}, "group": null},
{"id": "fetch", "name": "Fetch", "description": "…", "status": "degraded", "reasons": ["low_success_rate"], "metrics": {}, "group": null},
{"id": "company_gleif", "name": "Company data: GLEIF", "description": "…", "status": "operational", "reasons": [], "metrics": {}, "group": "company_data"}
],
"incidents": [
{"id": "inc_3f9a1c0b7e", "title": "Elevated fetch failures", "status": "identified",
"impact": "degraded", "components": ["fetch"], "created_at": "…", "updated_at": "…",
"resolved_at": null, "updates": [{"at": "…", "status": "identified", "message": "…"}]}
],
"maintenance": []
}
Statuses, best to worst: operational, maintenance, degraded,
partial_outage, major_outage. The overall status is the worst component
status, except that a major outage of a component other than api,
search, fetch or storage counts as a partial outage, and it is
maintenance when the only components not operational are under announced
maintenance. An unresolved incident shows its components at least at its
impact. Components not part of the deployment are not listed (archive
when archived copies are disabled, premium_routes when no premium route is
configured, sec while no SEC identity is configured, company_profiles while no
premium route is configured) — do not alert on a missing component.
Counts and rates cover the last window_s = 600 s. A component with fewer
than its minimum sample there (20 uncached requests; 10 attempts for a tier)
reports requests/attempts, failures and success_rate over the last
hour instead; metrics.window_s (<tier>_window_s in premium_routes) says
which window was used, and the status is judged on that window. null rates
mean even the hour has too few samples.
| Component | Metrics (rates 0–1; failures and window_s with every rate) |
Not operational when |
|---|---|---|
api |
— | database or cache unreachable (major) |
search |
success_rate, requests, engines_healthy_share, engines, instances_healthy_share, fallback_available, fallback_share, fallback_success_rate, workers_alive, queue_lag_s |
success rate of uncached searches < 0.95 / 0.80 / 0.50 (degraded / partial / major); healthy search instances < 50 % (degraded) or none (partial with the fallback search service, major without); healthy engines < 34 % (degraded); no worker; queue lag. engines_healthy_share: share of search engines (per instance) whose latest answer in the last 15 minutes returned results (the instance share until searches were observed); engines: [{engine, status}] with ok (healthy everywhere), degraded (on some instances) or down (rate limited, CAPTCHA or suspended everywhere) |
fetch |
success_rate, requests, latency_p95_ms, workers_alive, sync_queue_backlog, sync_queue_lag_s, batch_queue_backlog |
success rate of uncached fetches < 0.85 / 0.60 / 0.30 (4xx answers of the site, robots.txt, invalid or blocked URLs, too large, PDF not accepted and deferred are not failures); p95 > 60 s (degraded); no worker (major); queue lag |
browser_rendering |
success_rate, attempts, workers_alive, sync_queue_backlog, sync_queue_lag_s, batch_queue_backlog |
browser attempts failing for infrastructure reasons: success < 0.80 / 0.50 / 0.10; no browser worker (major) |
archive |
success_rate, attempts |
archive unreachable or erroring: success < 0.80 / 0.50 / 0.10 |
premium_routes |
proxy_success_rate, proxy_attempts, rendering_success_rate, rendering_attempts (configured tiers only) |
provider errors: success < 0.80 / 0.50 / 0.10 |
sec |
success_rate, requests, throttle_wait_p95_ms (p95 wait for a slot in the cluster-wide SEC rate limiter), backoff_active, rate_limit_rps |
SEC backing off after 403/429/503 (degraded, sec_backoff); success rate of uncached SEC requests < 0.85 / 0.60 / 0.30 (4xx, invalid identifiers and not found are not failures); p95 wait > 5 s (degraded, throttled) |
providers |
— | an outside service Cormorant relies on (search fallback, premium routes, alternative sources, notifications) currently refuses our access (degraded, provider_access); which one is not shown — the operator is alerted |
company_profiles |
success_rate, requests, latency_p95_ms (uncached extended company profile operations), cache_hit_rate, refused_share (operations the source refused: blocked, rate_limited), rendering_share (share served by the rendering service rather than the residential proxy), throttle_wait_p95_ms (p95 wait for a slot in the shared rate limit), backoff_active, proxy_skipped (the residential proxy is being skipped after a recent refusal); always over the last 600 s |
success rate of uncached operations < 0.85 / 0.60 / 0.30 (not found, invalid identifiers, robots.txt, premium routes not allowed and exhausted budgets are not failures; null below 10); refused share ≥ 0.20 (degraded, upstream_refusing); the site throttle backing off after 429 (degraded, site_backoff); p95 wait > 10 s (degraded, throttled) |
batch_jobs |
scheduler_last_run_s, workers_alive, running_jobs, oldest_waiting_job_s, queue_backlog |
scheduler last ran > 30 s (degraded) / > 120 s ago (partial); no batch worker (major); a job waiting to start > 15 min (degraded) / > 60 min (partial) |
storage |
database_ok, cache_ok, object_storage_ok (null: not configured) |
database or cache unreachable (major); object storage unreachable (degraded) |
company_<source> (group company_data, one per active company data source, e.g. company_gleif) |
success_rate, requests (last hour of calls to the source; null below 10), latency_p95_ms, parse_failure_rate, canary_ok, backoff_active, schema_drift (fields that used to be filled and now come back empty), change_feed_lag_s |
success < 0.85 / 0.50 / 0.20; canaries (known companies checked every 15 minutes) failing (partial); backoff after repeated failures, schema drift, parse failures > 10 % (degraded). Counts at most as degraded for the overall status |
Queue lag (queue_lag_s, sync_queue_lag_s: how long the oldest synchronous
request has waited for a worker) above 15 / 60 / 120 s is degraded / partial
/ major. Rates computed on fewer than 20 requests (10 tier attempts) are
reported but do not change a status. New metric keys may be added.
Reason codes: no_workers, queue_lag, low_success_rate,
high_latency, engines_reduced, engines_unavailable, tier_errors,
scheduler_stalled, jobs_waiting, database_unreachable,
cache_unreachable, object_storage_unreachable, sec_backoff,
throttled, provider_access, upstream_refusing, site_backoff, incident, maintenance; company data sources also canary_failed, source_backoff,
schema_drift, parse_failures.
Incidents and maintenance are posted by the operator. incidents lists
unresolved incidents (investigating, identified, monitoring) and those
resolved within 7 days, newest first, each with its timeline (updates,
newest first). maintenance lists scheduled and in_progress windows and
those completed within 7 days, soonest first; an empty components list
means the whole service.
History: GET /v1/status/history returns, per component, statuses
(oldest first; null where nothing was recorded) and uptime over the
period, where a partial_outage bucket counts as 30 % downtime and a
major_outage bucket as 100 %. Kept 7 days.
Health and metrics#
GET /health→200 {"status":"ok"}(process is up). For monitoring the service as a whole useGET /v1/status.GET /ready→200when PostgreSQL and Redis answer and at least one SearXNG instance is registered, else503; body lists the checks, never secrets.GET /metrics→ Prometheus text (private network only): requests by kind, provider and outcome, cost, bytes, premium bytes, search fallbacks, latency histogram, queue lengths, SearXNG instance count — aggregated across all hosts.
Examples#
export CORMORANT_URL=https://api.cormorant.ch CORMORANT_API_KEY=cmr_...
curl -s "$CORMORANT_URL/v1/search" -H "Authorization: Bearer $CORMORANT_API_KEY" \
-H 'Content-Type: application/json' -d '{"query":"Holcim annual report 2025","country":"CH"}'
curl -s "$CORMORANT_URL/v1/fetch" -H "Authorization: Bearer $CORMORANT_API_KEY" \
-H 'Content-Type: application/json' -d '{"url":"https://www.holcim.com/investors"}'
curl -s "$CORMORANT_URL/v1/batch/fetch" -H "Authorization: Bearer $CORMORANT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"idempotency_key":"demo-1","items":[{"url":"https://example.com"},{"url":"https://example.org"}]}'
import httpx
client = httpx.Client(
base_url=CORMORANT_URL, headers={"Authorization": f"Bearer {KEY}"}, timeout=180
)
page = client.post("/v1/fetch", json={"url": "https://www.holcim.com/investors"}).json()
if page["error"] is None:
text = page["text"]
Sync calls wait up to 150 s for the result (then 504 with the request id);
use the batch endpoints for bulk work.