# Company Atlas API — contract (v1)
Base: /api/v1 · JSON (orjson) · UTC ISO-8601 timestamps (Z) · immutable ids (co_…, sen_…, evt_…) · slugs for public URLs.
Public GET endpoints need no auth. Owner endpoints (watchlists, alerts) take X-CA-Owner-Token (client-generated random string ≥ 24 chars, stored hashed).
Admin endpoints take X-CA-Admin-Token. Errors: { "detail": string } with 4xx/5xx. Pagination: ?page=1&per_page=25 (max 200) →
{ items, page, per_page, total, pages }. Lists accept sort where documented; unknown params are ignored.
Interpretive language is careful by design (spec §167–168): events say "no longer listed", never "fired"; anything inferred carries a
confidence (0–1) and a confidence_label (VERIFIED | HIGH_CONFIDENCE | LIKELY | INFERRED | LOW_CONFIDENCE).
# Shapes
type CompanyCard = {
id: string; slug: string; display_name: string; legal_name: string | null; canonical_domain: string; website: string;
description: string | null; industries: string[]; industry_primary: string | null; country: string | null; hq_city: string | null;
hq_region: string | null; public_company: boolean; ticker: string | null; exchange: string | null; founded_year: number | null;
employees_band: string | null; logo_url: string | null; status: string; onboarding_status: string; importance: number; tier: 1|2|3|4;
metrics: Partial<Record<Metric, number>>; // activity_score, hiring_momentum_30d, product_velocity, ai_adoption, corporate_change_index, open_jobs …
counts: { sensors: number; observations: number; changes: number; events: number; jobs_open: number };
last_event_at: string | null; last_observed_at: string | null;
sparkline?: number[]; // 30 daily activity values when `?sparkline=1` (or on detail)
};
type Metric = 'activity_score'|'hiring_momentum_7d'|'hiring_momentum_30d'|'hiring_momentum_90d'|'open_jobs'|'ai_adoption'|'product_velocity'|
'geo_expansion'|'developer_momentum'|'communication_activity'|'pricing_activity'|'leadership_activity'|'corporate_change_index'|'anomaly_score'|'historical_coverage';
type Event = {
id: string; company: { id: string; slug: string; display_name: string; canonical_domain: string; country: string|null; logo_url: string|null };
event_type: string; event_subtype: string; importance: number; confidence: number; confidence_label: string;
title: string; summary: string | null; old_value: string | null; new_value: string | null;
payload: Record<string, unknown>; entities: Record<string, unknown>; tags: string[];
detected_at: string; effective_at: string | null; published_at: string | null;
source_url: string | null; surface: string | null; sensor_id: string | null; change_id: string | null; cluster_id: string | null;
origin: 'deterministic'|'llm'|'hybrid'|'backfill'; model_name: string | null; prompt_version: string | null; status: 'active'|'retracted'|'duplicate'|'review';
sources?: { source_url: string; surface: string | null; detected_at: string; kind: string; sensor_id: string | null }[]; // detail only
};
type Sensor = {
id: string; company_id: string; surface: string; connector_id: string; url: string; canonical_url: string; domain: string;
status: string; tier: 'A'|'B'|'C'|'D'|'E'; quality_score: number; discovery_confidence: number; discovery_method: string | null;
current_interval_s: number; next_run_at: string; last_run_at: string | null; last_success_at: string | null; last_change_at: string | null;
last_status: number | null; last_failure_class: string | null; consecutive_failures: number;
observation_count: number; snapshot_count: number; change_count: number; meaningful_change_count: number; event_count: number; created_at: string;
};
type Snapshot = { id: string; sensor_id: string; version_no: number; fetched_at: string; title: string | null; language: string | null;
text_length: number | null; block_count: number | null; extracted_summary: Record<string, number>; content_hash: string; previous_snapshot_id: string | null };
type Change = { id: string; sensor_id: string; surface: string; company_id: string; detected_at: string; significance: number; kind: string;
blocks_added: number; blocks_removed: number; blocks_modified: number; text_delta_ratio: number; similarity: number | null;
snapshot_before: string | null; snapshot_after: string; diff?: DiffPayload; structured_delta?: Record<string, unknown> };
type DiffPayload = { added: BlockDelta[]; removed: BlockDelta[]; modified: BlockDelta[]; moved: string[]; counts: Record<string, number>;
text_delta_ratio: number; similarity: number; reasons: string[] };
type BlockDelta = { key: string; kind: string; path: string; before: string | null; after: string | null; weight: number; similarity: number | null };
type Job = { id: string; title: string; department: string | null; location_text: string | null; city: string | null; country: string | null;
remote: boolean | null; employment_type: string | null; seniority: string | null; url: string | null; posted_at: string | null;
first_seen_at: string; last_seen_at: string; removed_at: string | null; status: 'open'|'no_longer_listed'; is_ai: boolean };
type Person = { id: string; name: string; title: string | null; role_category: string | null; is_executive: boolean; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null };
type Product = { id: string; name: string; category: string | null; description: string | null; url: string | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string };
type Plan = { id: string; plan_name: string; price: number | null; price_text: string | null; currency: string | null; billing_period: string | null; unit: string | null; features: string[]; contact_sales: boolean; version_no: number; valid_from: string; valid_to: string | null; status: string; source_url: string | null };
type Location = { id: string; kind: string; name: string | null; city: string | null; region: string | null; country: string | null; lat: number | null; lon: number | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null };
type NewsItem = { id: string; title: string; url: string; summary: string | null; category: string | null; published_at: string | null; first_seen_at: string; language: string | null };
type MetricPoint = { day: string; value: number; confidence: number };
type Signal = { id: string; company_id: string | null; scope: string; scope_key: string | null; kind: string; strength: number; confidence: number; title: string; explanation: string | null; evidence: Record<string, unknown>; window_days: number; detected_at: string; status: string };
# Endpoints
| Method |
Path |
Notes |
| GET |
/health, /ready |
service health (also at /api/v1/health) |
| GET |
/stats |
{ companies, companies_active, sensors, sensors_active, observations, snapshots, changes, meaningful_changes, events, jobs_open, countries, industries, observations_today, changes_today, events_today, dataset_started_at, dataset_age_days, oldest_history_days, last_observation_at, archive: {objects, bytes} } (cached 60 s) |
| GET |
/stats/history?days=90 |
{ items: GlobalDaily[] } — { day, companies_active, sensors_active, observations, changes, meaningful_changes, events, events_by_type, jobs_open, jobs_new, jobs_removed, activity_index } |
| GET |
/system |
public aggregate health only: { sensors_online, sensors_failing, observations_today, events_today, countries_covered, queue_lag_s, scheduler_last_tick_at, fetch_per_min, success_rate_24h } |
| GET |
/pulse |
homepage aggregate: { stats, live: Event[12], movers: CompanyCard[10], hiring: CompanyCard[8], launches: Event[8], pricing: Event[8], ai: CompanyCard[8], industries: IndustryRow[12], countries: CountryRow[12], trending: TrendRow[10], activity_index: {value, delta_7d, series: MetricPoint[30]}, map: MapBucket[] } (cached 60 s) |
| GET |
/live?limit=50&since=<iso>&event_type=&min_importance= |
latest active events (no cache); since returns only newer than that time |
| GET |
/live/stream |
SSE: event: event with an Event JSON per message (poll-based, ~5 s), event: heartbeat every 20 s, ?since= supported |
# Companies
| Method |
Path |
Notes |
| GET |
/companies |
filters: q, country, industry, tier, public (bool), status, has_events (bool), sort=activity|events|hiring|name|importance|recent|relevance (relevance is automatic with q), sparkline=1 → page of CompanyCard |
| GET |
/companies/{slug_or_id} |
CompanyCard & { aliases: string[], domains: {domain, kind}[], relationships: {kind, company: {slug, display_name} | null, to_name, valid_from, valid_to, confidence}[], metrics_detail: {metric, value, confidence, computed_at, inputs}[], sensors_by_surface: Record<string, number>, coverage: {historical_coverage, first_observed_at, days_observed, sensor_uptime}, signals: Signal[], sparklines: {activity_30d: number[], hiring_90d: number[]} } |
| GET |
/companies/{slug}/events |
filters event_type, event_subtype, since, until, min_importance, surface; sort=recent|importance → page of Event |
| GET |
/companies/{slug}/timeline?filter=all|products|jobs|pricing|leadership|locations|legal|news|developer&limit=200 |
{ items: (Event & {day: string})[], days: {day, count}[] } grouped for the timeline UI |
| GET |
/companies/{slug}/metrics?metric=activity_score&days=90 |
{ current: {metric, value, confidence, computed_at, formula_version, inputs}[], series: Record<Metric, MetricPoint[]> } |
| GET |
/companies/{slug}/jobs?status=open|removed|all&q=&country=&ai=1 |
page of Job + { summary: {open, new_7d, removed_7d, ai_open, by_country: {country,n}[], by_department: {department,n}[], remote_ratio} } as meta |
| GET |
/companies/{slug}/people |
{ listed: Person[], no_longer_listed: Person[] } |
| GET |
/companies/{slug}/products |
{ listed: Product[], removed: Product[] } |
| GET |
/companies/{slug}/pricing |
{ current: Plan[], history: Plan[] } |
| GET |
/companies/{slug}/locations |
{ items: Location[], countries: string[] } |
| GET |
/companies/{slug}/news?limit=50 |
{ items: NewsItem[] } |
| GET |
/companies/{slug}/sensors |
{ items: Sensor[] } |
| GET |
/companies/{slug}/history |
{ sensors: (Sensor & { versions: Snapshot[] })[] } — historical page viewer index (max 20 versions per sensor) |
| GET |
/companies/{slug}/similar?limit=8 |
{ items: CompanyCard[] } (same industry/country, closest importance) |
| GET |
/companies/compare?companies=stripe,adyen,block |
{ companies: CompanyCard[], metrics: Record<Metric, Record<slug, number>>, series: Record<slug, MetricPoint[]>, events_30d: Record<slug, Record<event_type, number>>, jobs: Record<slug, {open, ai_open, new_30d}>, locations: Record<slug, number> } (2–6 companies) |
# Sensors, snapshots, changes (provenance)
| Method |
Path |
Notes |
| GET |
/sensors/{id} |
Sensor & { company: CompanyRef, latest_snapshot: Snapshot | null } |
| GET |
/sensors/{id}/snapshots?limit=50 |
{ items: Snapshot[] } |
| GET |
/sensors/{id}/changes?limit=50 |
{ items: Change[] } |
| GET |
/snapshots/{id} |
Snapshot & { text: string (≤ 200 kB), blocks: Block[], extracted: object } |
| GET |
/snapshots/{id}/diff/{other_id} |
{ before: Snapshot, after: Snapshot, diff: DiffPayload } (computed on demand) |
| GET |
/changes/{id} |
Change with diff and structured_delta, plus events: Event[] |
| GET |
/events/{id} |
Event detail with sources, change (Change summary), company |
# Events
| Method |
Path |
Notes |
| GET |
/events |
filters event_type, event_subtype, country, industry, since, until, min_importance, min_confidence, q, surface, origin, company; sort=recent|importance → page of Event |
| GET |
/events/types |
{ types: {event_type, subtypes: {event_subtype, count_30d}[], count_30d}[] } |
| GET |
/events/summary?days=7&group=type|industry|country |
{ items: {key, count, delta_pct}[] } |
# Rankings, industries, countries, signals, trends
| Method |
Path |
Notes |
| GET |
/rankings?kind=most_active|hiring_growth|hiring_decline|product_velocity|ai_active|geo_expansion|developer_momentum|pricing_changes|unusual_activity&window=24h|7d|30d|90d|1y&country=&industry=&limit=50 |
{ kind, window, items: (CompanyCard & { rank: number; value: number; delta: number | null })[] } |
| GET |
/industries |
{ items: IndustryRow[] } — { slug, name, parent_slug, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, ai_adoption, top_event_types: string[] } |
| GET |
/industries/{slug} |
IndustryRow & { description, companies: CompanyCard[24] (most active), events: Event[20], hiring: {open, new_30d, removed_30d, momentum_30d}, series: MetricPoint[90] (activity), countries: {country, companies}[], trending: TrendRow[] } |
| GET |
/countries |
{ items: CountryRow[] } — { code, name, region, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, industry_mix: {industry, companies}[], lat, lon } |
| GET |
/countries/{code} |
CountryRow & { companies: CompanyCard[24], events: Event[20], movers: CompanyCard[10], new_entrants: CompanyCard[10], series: MetricPoint[90], industries: IndustryRow[] } |
| GET |
/signals?kind=&scope=company|industry|country|global&limit=50 |
{ items: Signal[] } |
| GET |
/trends?window=7d|30d|90d&limit=30 |
{ items: TrendRow[] } — { term, mentions, companies, momentum, series: number[] } |
| GET |
/map?metric=events_30d|companies|hiring |
{ buckets: MapBucket[] } — { lat, lon, country, city: string | null, companies: number, events_30d: number, jobs_open: number, top: {slug, display_name}[] } (clustered by city/country, ≤ 600 buckets) |
| GET |
/index |
Global Corporate Activity Index: { value, baseline: 100, delta_7d, delta_30d, series: MetricPoint[365], by_type: Record<string, number>, by_country: {key, value}[], by_industry: {key, value}[], formula_version } |
# Search
| Method |
Path |
Notes |
| GET |
/search?q=&types=companies,events,industries,countries,people,products&limit=10 |
{ query, companies: CompanyCard[], events: Event[], industries: IndustryRow[], countries: CountryRow[], people: (Person & {company: CompanyRef})[], products: (Product & {company: CompanyRef})[], took_ms } |
| GET |
/search/suggest?q= |
{ items: { kind: 'company'|'industry'|'country'|'event_type', label, sublabel, href }[] } (≤ 10, < 50 ms) |
| GET |
/ask?q= |
natural-language routing (deterministic parser → structured query; LLM optional): { interpretation: {filters…}, answer: string, companies: CompanyCard[], events: Event[], sources: string[] } |
# Watchlists & alerts (owner token)
| Method |
Path |
Notes |
| GET/POST |
/watchlist |
GET → { items: CompanyCard[], events: Event[30] (for the watched companies) }; POST { company: slug } adds; DELETE /watchlist/{slug} removes |
| GET/POST |
/alerts |
POST { name, company?: slug, condition: {event_types?: string[], min_importance?: number, metrics?: {activity_score?: {gt: number}}}, channel: 'web'|'webhook', target?: url }; DELETE /alerts/{id} |
| GET |
/alerts/deliveries?limit=50 |
recent deliveries for this owner |
# Exports & docs
| Method |
Path |
Notes |
| GET |
/export/events.{json,ndjson,csv}?since=&event_type=&country=&limit=10000 |
streamed export |
| GET |
/export/companies.{json,ndjson,csv}?country=&industry= |
streamed export |
| GET |
/export/jobs.ndjson?company=&since= |
|
| GET |
/sitemap?kind=companies|industries|countries&page= |
{ items: {slug, updated_at}[], pages } — only companies with indexed = true |
| GET |
/methodology |
{ metrics: {metric, formula_version, description, inputs: string[]}[], significance_bands, event_types: string[], confidence_labels } |
# Admin (X-CA-Admin-Token)
| Method |
Path |
Notes |
| GET |
/admin/overview |
{ companies_by_status, sensors_by_status, sensors_by_tier, queue: {pending, running, dead, oldest_pending_s}, llm: {pending, done_today, failed_today, budget_left}, failures_24h_by_class, fetch_rate_1h, change_rate_1h, meaningful_rate_1h, storage: {objects, bytes}, workers: {name, last_seen_at, inflight}[], cost_today: {fetch, browser, llm} } |
| GET |
/admin/connectors |
{ items: {id, name, version, category, enabled, sensors_active, sensors_failing, success_rate_24h, avg_latency_ms, change_rate_24h, errors_24h, last_run_at}[] } |
| GET |
/admin/sensors?status=&domain=&connector=&company=&filter=healthy|failing|stale|blocked|redirected|low_quality|high_activity&page= |
page of Sensor & {company: CompanyRef} |
| POST |
/admin/sensors/{id}/{action} |
action ∈ pause, resume, retry, rediscover, retire, run_now; body { interval_s?, connector_id? } for set_interval, set_connector |
| GET |
/admin/companies?onboarding_status=&page= · POST /admin/companies { website, display_name?, country?, industries? } (create + queue discovery) · POST /admin/companies/{slug}/rediscover |
|
| GET |
/admin/failures?class=&since=&page= · GET /admin/queue?kind=&status= · POST /admin/queue/requeue-dead |
|
| GET |
/admin/llm?status=&page= · GET /admin/reviews?kind=&status=open · POST /admin/reviews/{id} { resolution: 'accepted'|'rejected', note? } |
|
| POST |
/admin/events/{id}/retract { reason } · POST /admin/events/{id}/restore |
|
| GET |
/admin/quality |
{ coverage: {companies_active_pct, sensors_active_pct}, freshness: {sensors_checked_24h_pct, stale}, duplicate_rate, event_confidence_avg, unknown_surfaces, failed_sensors, calibration: {correct, duplicate, noise, misclassified} } |
| GET |
/admin/costs?days=30 |
{ items: {day, dimension, key, units, cost_estimate}[], per_1000_companies, per_million_observations, per_meaningful_event } |
| POST |
/admin/cache/clear |
|
# Rate limits & auth
| Tier |
How |
Limit (per client, per minute) |
| anonymous |
no header — keyed by client IP (first hop of X-Forwarded-For) |
120 |
| authenticated |
X-CA-API-Key: <key> (create with catlas api-key create <name>) |
600 |
| paid |
X-CA-API-Key with tier = paid (catlas api-key create <name> --tier paid) |
3 000 |
| internal |
X-CA-API-Key with tier = internal |
unlimited |
| admin |
valid X-CA-Admin-Token |
bypass (also required for /admin/*) |
Token bucket per process (refills continuously at limit / 60 tokens per second). Every limited response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Tier; when the bucket is empty the API answers 429 { "detail": "rate limit exceeded" } with a
Retry-After (seconds). /health, /ready, /api/v1/docs and /api/v1/openapi.json are never limited. Keys are stored as sha256 hashes
(api_keys.key_hash); the raw key is printed once by the CLI. last_used_at / request_count are updated lazily (≈ every 30 s). Owner endpoints
(/watchlist, /alerts*) need X-CA-Owner-Token (client-generated, 24–200 chars); the owner row is created on first use and the token is only
ever stored hashed. Admin endpoints answer 401 { "detail": "admin token required" } without a valid X-CA-Admin-Token.
# Implementation notes (API v1.0 — additive details)
- Caching / ETags. Cached public aggregates (
/pulse 60 s, /stats 60 s, /system 30 s, /rankings 120 s, /industries*, /countries*,
/index, /map, /trends, /events/types, /events/summary, /stats/history 300 s, /sitemap 600 s, /methodology 3600 s) return
Cache-Control: public, max-age=N, stale-while-revalidate=2N plus a weak ETag; a matching If-None-Match yields 304. Other public GETs
carry public, max-age=30…600 (per resource volatility); /live*, owner and admin routes are no-store. POST /admin/cache/clear?prefix=
clears one key prefix (e.g. pulse, industries:) or everything.
- Pagination totals.
total is exact up to 10 000 (bounded count) so deep feeds stay cheap; pages derives from it.
/live also accepts country and industry; the payload is { items, count, cursor, server_time } — pass cursor back as since.
/live/stream accepts ?since=&event_type=&min_importance=&max_s= (max_s ≤ 3600 bounds the connection for proxies with idle timeouts;
the stream ends with event: end carrying the last cursor). The first message is always an event: heartbeat with {at, cursor};
event: event messages carry id: <event id>. Response headers include X-Accel-Buffering: no.
/companies gains sort=relevance (automatic when q is present); q uses FTS (companies.search) for ≥ 3 characters with a
trigram fallback and a prefix match for shorter strings. sparkline=1 adds 30 daily activity_score points.
/companies/{slug} additionally returns company_type, employees, wikidata_id, indexed, discovered_at, first_observed_at, last_change_at, recent_events: Event[10] and sparkline.
/companies/{slug}/timeline accepts filter=corporate (FINANCING, M&A, PARTNERSHIP, STRATEGY) and before=<iso>; items carry day.
/companies/{slug}/jobs accepts department, remote, sort=recent|title|posted; q is a trigram ILIKE on title.
/companies/{slug}/history accepts versions=1…20; /companies/{slug}/sensors?include_retired=1; /companies/{slug}/news?category=;
/companies/{slug}/locations?status=all; /companies/{slug}/pricing?history_limit=.
/companies/compare accepts days= (7–365) for the activity series; unknown slugs → 404 company not found: <slug>; fewer than 2 → 422.
/events accepts status=active|retracted|duplicate|review|all (default active). Retracted events expose retracted_reason.
/events/{id} embeds the full CompanyCard under company (a superset of CompanyRef), sources (falls back to the event's own
source_url when event_sources is empty), change and cluster.
/events/types lists every taxonomy type/subtype (count 0 when unseen) so filter UIs can render on an empty database.
/events/summary items also carry previous (count of the preceding window).
/rankings items are CompanyCard & { rank, value, delta }; the payload also lists kinds. hiring_growth/hiring_decline read
hiring_momentum_7d|30d|90d according to the window; pricing_changes counts PRICING events in the window; delta is the difference with
the metric_series value at the start of the window (null when no history yet).
/countries/{code} accepts an ISO-2 code or a name slug (/countries/canada); CountryRow includes slug and subregion.
/industries/{slug} adds children, signals, companies_total; trending is [] until per-industry trend data exists (never faked).
/signals accepts scope_key, company, min_strength; items include a company ref when scoped to a company.
/search people/products use trigram similarity (migrations/pending/api.sql adds the two supporting GIN indexes). /search/suggest
items add slug / code / event_type next to href.
/ask returns engine (deterministic | llm) and events_total; the parser is services/llm/ask.py when present (deterministic
parse + optional LLM refinement), else api/ask_fallback.py. Answers only phrase measured counts.
/snapshots/{id} accepts include=text,blocks,extracted and returns text_truncated (text capped at 200 kB), sensor, object_keys.
/snapshots/{a}/diff/{b} orders the pair by fetched_at, returns source: "change" (stored diff, with change_id) or "computed"
(sdk.diff.compare); 501 when the diff engine is unavailable, 404 when block objects are missing.
- Exports.
/export/events.{fmt} also accepts until, company, min_importance, limit (≤ 10 000); /export/companies.{fmt} accepts
status, tier, limit (≤ 20 000); /export/jobs.{json|ndjson|csv} accepts status=open|removed|all, country, ai, limit (≤ 10 000).
CSV flattens the company ref (company_slug, company_name, company_domain, country).
/sitemap pages hold 5 000 entries; kind=companies returns total; industries/countries only list entries with ≥ 1 active company.
- Admin additions.
GET /admin/sensors accepts surface, sort=recent|next_run|failures|quality|changes|created; sensor actions return
{ ok, action, sensor, queued? , interval_s? } (set_interval is clamped to CA_MIN_INTERVAL_S…CA_MAX_INTERVAL_S and recomputes the tier).
POST /admin/companies → 201 { ok, company, queued } (409 when the registrable domain already exists; 422 for non-http(s) URLs, unknown
country codes or industry slugs). GET /admin/failures returns by_class and classes; GET /admin/queue returns counts + items;
POST /admin/queue/requeue-dead { kind? }; GET /admin/llm includes stats; POST /admin/reviews/{id} accepts label (correct |
duplicate | noise | misclassified) feeding /admin/quality.calibration; retract/restore append an audit trail under payload._audit and
never delete; GET /admin/storage reports the object store. /admin/overview also returns companies_by_onboarding, scheduler_heartbeat.
# Profile & facts (additive, 2026-09-13 — services/enrichment.py)
Companies are enriched from Wikidata (entity), Wikipedia (lead summary, CC BY-SA 4.0), the homepage (meta description,
JSON-LD Organization, icons) and, only when no Wikipedia text exists, a grounded LLM description built from the company's own pages.
Every accepted value keeps its provenance; a weaker source never overwrites a better one (description: wikipedia > llm > homepage > wikidata;
other facts: wikidata > homepage > registry > wikipedia). Nothing is inferred — absent facts are null.
type Provenance = { field: string; source: 'wikidata'|'wikipedia'|'homepage'|'llm'|'registry'; url: string | null; retrieved_at: string };
type Money = { value: number; currency: string; year: number }; // ISO 4217; the fiscal year the figure refers to
type CompanyProfile = {
description: string | null; description_source: 'wikipedia'|'wikidata'|'homepage'|'llm'|null; description_url: string | null;
description_license: string | null; // 'CC BY-SA 4.0' for Wikipedia text (attribution required: show description_url)
description_attribution: string | null; // e.g. 'Text from Wikipedia (en), CC BY-SA 4.0' · "Generated from the company's public pages"
logo_url: string | null; icon_url: string | null; founded_year: number | null; legal_form: string | null; legal_name: string | null;
employees: number | null; employees_year: number | null; revenue: Money | null; net_income: Money | null; total_assets: Money | null;
hq: { city: string | null; region: string | null; country: string | null; address: string | null; lat: number | null; lon: number | null };
ticker: string | null; exchange: string | null; isin: string | null; lei: string | null; sec_cik: string | null; public_company: boolean;
wikipedia_url: string | null; wikidata_url: string | null; official_website: string | null; phone: string | null;
products: string[]; industries: string[]; industry_labels: string[];
socials: { linkedin?: string; x?: string; youtube?: string; facebook?: string; instagram?: string; github?: string; tiktok?: string; crunchbase?: string };
enriched_at: string | null; sources: Provenance[]; version: 'profile-v1';
};
type Fact = { key: 'founded'|'headquarters'|'employees'|'revenue'|'net_income'|'total_assets'|'legal_form'|'listing'|'isin'|'lei'|'sec_cik'|'website'|'wikipedia';
label: string; value: string; raw: unknown; source: string | null; url: string | null; retrieved_at: string | null };
CompanyCard.profile?: CompanyProfile — present on every card (lists, rankings, detail…) once the company has been enriched
(companies.source_meta.profile); absent before. sources[] holds one entry per populated field.
GET /companies/{slug} adds profile: CompanyProfile | null and facts: Fact[] (display-ready strings such as "47,756 (2013)",
"USD 305.6 B (2023)", "Mountain View, California, US"; raw carries the underlying value). relationships rows now carry
provenance: {source, property, qid?, retrieved_at?}, first_seen_at, last_seen_at, and kind ∈ PARENT_OF | SUBSIDIARY_OF | OWNED_BY | OWNER_OF | ACQUIRED_BY | ACQUIRED | PARTNER_OF | COMPETITOR_OF | INVESTOR_IN | BRAND_OF (Wikidata rows: confidence 0.85, valid_from /
valid_to from the start/end qualifiers; company is set when the counterpart is in the atlas, else only to_name). Current
relationships are listed before ended ones (limit 200).
GET /companies/{slug}/people rows add source: 'wikidata' | 'page' (derived from source_url; Wikidata rows have
title = role such as Chief Executive Officer, Chairperson, Founder, or the position label for key people) and the payload adds
sources: string[]. A person whose Wikidata role ended is no_longer_listed (never "left"); page-sourced rows are never overridden by Wikidata.
- Columns back-filled by enrichment (
description, logo_url, hq_city, hq_region, country, founded_year, employees, ticker,
exchange, legal_name, lei, sec_cik, industries) record their source in companies.source_meta.provenance[column]
({source, url, retrieved_at, previous?}), so CompanyCard.description may now be a Wikipedia paragraph — show the attribution from
profile.description_attribution / profile.description_url when profile.description_source === 'wikipedia'.
- CLI:
catlas enrich-companies [--limit N] [--company slug…] [--source wikidata|wikipedia|homepage|llm|all] [--concurrency N] [--no-llm],
catlas profile <slug> [--json]. Periodic task company-enrichment (every 10 min, batch CA_ENRICH_BATCH = 300; never-enriched first,
active companies first, then profiles older than CA_ENRICH_REFRESH_DAYS = 30).
# Conventions for implementers
- Every list endpoint is bounded (
per_page ≤ 200, limit ≤ 500), uses indexed predicates, and returns Cache-Control: public, max-age=60 for public aggregates (/pulse, /stats, /rankings, /industries, /countries) and no-store for /live*, owner and admin routes.
- Company lookups accept slug or id. Unknown → 404
{detail: "company not found"}.
- Text search: Postgres FTS (
companies.search, events.search) + trigram fallback for short/partial queries; never LIKE on unindexed columns.
- Numbers: metrics are 0–100 floats rounded to 1 decimal except
hiring_momentum_* (percentage, may be negative) and open_jobs (int).
- Never fabricate: when a metric has no inputs, omit it (or
null) rather than returning 0 as if measured.